Browse Source

Merge branch 'ghostfolio:main' into Overview_Graph

pull/5570/head
Batwam 3 weeks ago
committed by GitHub
parent
commit
8c201a9138
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      .github/workflows/extract-locales.yml
  2. 4
      .vscode/extensions.json
  3. 2
      .vscode/settings.json
  4. 237
      CHANGELOG.md
  5. 4
      Dockerfile
  6. 23
      README.md
  7. 3
      apps/api/src/app/access/access.controller.ts
  8. 2
      apps/api/src/app/account-balance/account-balance.controller.ts
  9. 3
      apps/api/src/app/account-balance/account-balance.service.ts
  10. 16
      apps/api/src/app/account/account.controller.ts
  11. 12
      apps/api/src/app/admin/admin.controller.ts
  12. 25
      apps/api/src/app/admin/admin.service.ts
  13. 2
      apps/api/src/app/app.module.ts
  14. 58
      apps/api/src/app/auth/auth.controller.ts
  15. 82
      apps/api/src/app/auth/auth.module.ts
  16. 14
      apps/api/src/app/auth/auth.service.ts
  17. 21
      apps/api/src/app/auth/interfaces/interfaces.ts
  18. 114
      apps/api/src/app/auth/oidc-state.store.ts
  19. 69
      apps/api/src/app/auth/oidc.strategy.ts
  20. 11
      apps/api/src/app/auth/web-auth.service.ts
  21. 2
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  22. 3
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  23. 24
      apps/api/src/app/endpoints/market-data/update-bulk-market-data.dto.ts
  24. 22
      apps/api/src/app/endpoints/platforms/platforms.controller.ts
  25. 11
      apps/api/src/app/endpoints/platforms/platforms.module.ts
  26. 1
      apps/api/src/app/endpoints/public/public.controller.ts
  27. 4
      apps/api/src/app/endpoints/sitemap/sitemap.service.ts
  28. 4
      apps/api/src/app/endpoints/tags/tags.controller.ts
  29. 2
      apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
  30. 2
      apps/api/src/app/exchange-rate/exchange-rate.controller.ts
  31. 2
      apps/api/src/app/export/export.controller.ts
  32. 11
      apps/api/src/app/import/import-data.dto.ts
  33. 1
      apps/api/src/app/import/import.controller.ts
  34. 2
      apps/api/src/app/import/import.module.ts
  35. 78
      apps/api/src/app/import/import.service.ts
  36. 17
      apps/api/src/app/info/info.service.ts
  37. 4
      apps/api/src/app/order/order.controller.ts
  38. 179
      apps/api/src/app/order/order.service.ts
  39. 5
      apps/api/src/app/platform/platform.controller.ts
  40. 7
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  41. 50
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  42. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  43. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  44. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  45. 12
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  46. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts
  47. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  48. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  49. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  50. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  51. 290
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  52. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  53. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  54. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  55. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts
  56. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  57. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  58. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  59. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  60. 19
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  61. 4
      apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts
  62. 3
      apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts
  63. 209
      apps/api/src/app/portfolio/portfolio.service.ts
  64. 2
      apps/api/src/app/portfolio/rules.service.ts
  65. 9
      apps/api/src/app/subscription/subscription.service.ts
  66. 8
      apps/api/src/app/symbol/symbol.controller.ts
  67. 11
      apps/api/src/app/symbol/symbol.service.ts
  68. 10
      apps/api/src/app/user/user.controller.ts
  69. 15
      apps/api/src/app/user/user.service.ts
  70. 576
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  71. 11
      apps/api/src/events/asset-profile-changed.event.ts
  72. 61
      apps/api/src/events/asset-profile-changed.listener.ts
  73. 17
      apps/api/src/events/events.module.ts
  74. 22
      apps/api/src/helper/object.helper.spec.ts
  75. 11
      apps/api/src/helper/object.helper.ts
  76. 7
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  77. 33
      apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts
  78. 60
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  79. 4
      apps/api/src/middlewares/html-template.middleware.ts
  80. 3
      apps/api/src/models/interfaces/rule.interface.ts
  81. 2
      apps/api/src/models/rule.ts
  82. 7
      apps/api/src/models/rules/account-cluster-risk/current-investment.ts
  83. 7
      apps/api/src/models/rules/account-cluster-risk/single-account.ts
  84. 7
      apps/api/src/models/rules/asset-class-cluster-risk/equity.ts
  85. 7
      apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts
  86. 7
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  87. 7
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  88. 3
      apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts
  89. 3
      apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts
  90. 3
      apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts
  91. 3
      apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts
  92. 3
      apps/api/src/models/rules/liquidity/buying-power.ts
  93. 2
      apps/api/src/models/rules/regional-market-cluster-risk/interfaces/rule-settings.interface.ts
  94. 31
      apps/api/src/services/configuration/configuration.service.ts
  95. 4
      apps/api/src/services/cron/cron.service.ts
  96. 8
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  97. 38
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  98. 2
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  99. 6
      apps/api/src/services/data-provider/data-provider.service.ts
  100. 51
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts

4
.github/workflows/extract-locales.yml

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

4
.vscode/extensions.json

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

2
.vscode/settings.json

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

237
CHANGELOG.md

@ -5,7 +5,239 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.215.0-beta.1 - 2025-11-05 ## Unreleased
### Added
- Set up the language localization for Korean (`ko`)
## 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
- Included wealth projection data calculated for the retirement date in the _FIRE_ section (experimental)
### Changed
- Moved the notification module to `@ghostfolio/ui`
- Improved the language localization for German (`de`)
### Fixed
- Fixed a calculation issue that resulted in the incorrect assignment of unknown data in the portfolio proportion chart component
## 2.222.0 - 2025-12-07
### Added
- Introduced data source transformation support in the import functionality for self-hosted environments
- Added _OpenID Connect_ (`OIDC`) as a new login provider for self-hosted environments (experimental)
- Added an optional 3D hover effect to the membership card component
### Changed
- Increased the numerical precision for cryptocurrency quantities in the holding detail dialog
- Upgraded `envalid` from version `8.1.0` to `8.1.1`
- Upgraded `prettier` from version `3.7.3` to `3.7.4`
## 2.221.0 - 2025-12-01
### Changed
- Refactored the API query parameters in various data provider services
- Extended the _Storybook_ stories of the portfolio proportion chart component by a story using percentage values
- Upgraded `@internationalized/number` from version `3.6.3` to `3.6.5`
- Upgraded `prettier` from version `3.7.2` to `3.7.3`
### Fixed
- Improved the country weightings in the _Financial Modeling Prep_ service
- Improved the search functionality by name in the _Financial Modeling Prep_ service
- Resolved an issue in the user endpoint where the list was returning empty in the admin control panel’s users section
## 2.220.0 - 2025-11-29
### Changed
- Restricted the asset profile data gathering on Sundays to only process outdated asset profiles
- Removed the _Cypress_ testing setup
- Eliminated `uuid` in favor of using `randomUUID` from `node:crypto`
- Upgraded `color` from version `5.0.0` to `5.0.3`
- Upgraded `prettier` from version `3.6.2` to `3.7.2`
### Fixed
- Fixed an issue with the exchange rate calculation when converting between derived currencies and their root currencies
## 2.219.0 - 2025-11-23
### Added
- Extended the user detail dialog of the admin control panel’s users section by the authentication method
### Changed
- Disabled the action to delete activities if the activities table is empty
- Improved the validation of the currency management in the admin control panel
- Improved the content of the pricing page
- Resolved the data source of the `GHOSTFOLIO` data provider in the export functionality
- Resolved the data source of the `GHOSTFOLIO` data provider in the import functionality
- Refreshed the cryptocurrencies list
- Improved the language localization for German (`de`)
- Upgraded `yahoo-finance2` from version `3.10.1` to `3.10.2`
### Fixed
- Fixed an issue with the edit of future activities (drafts)
## 2.218.0 - 2025-11-20
### Added
- Extended the accounts table menu with a _View Details_ item
- Extended the portfolio summary tab on the home page by percentage values (experimental)
- Added the _OSS Gallery_ logo to the logo carousel on the landing page
### Changed
- Improved the dynamic numerical precision for various values in the portfolio summary tab on the home page
- Upgraded `yahoo-finance2` from version `3.10.0` to `3.10.1`
## 2.217.1 - 2025-11-16
### Added
- Introduced support for automatically gathering required exchange rates, exposed as an environment variable (`ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES`)
- Added a blog post: _Black Weeks 2025_
### Changed
- Refactored the get holding functionality in the portfolio service
- Changed the user data loading in the user detail dialog of the admin control panel’s users section to fetch data on demand
- Exposed the authentication with access token as an environment variable (`ENABLE_FEATURE_AUTH_TOKEN`)
- Improved the search functionality of the _Financial Modeling Prep_ service
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `6.18.0` to `6.19.0`
### Todo
- Rename the environment variable from `ENABLE_FEATURE_SOCIAL_LOGIN` to `ENABLE_FEATURE_AUTH_GOOGLE`
## 2.216.0 - 2025-11-10
### Changed
- Improved the language localization for Chinese (`zh`)
- Upgraded `chart.js` from version `4.5.0` to `4.5.1`
- Upgraded `svgmap` from version `2.12.2` to `2.14.0`
## 2.215.0 - 2025-11-06
### Added ### Added
@ -18,6 +250,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Changed the build executor of the client from `@nx/angular:webpack-browser` to `@nx/angular:browser-esbuild` - Changed the build executor of the client from `@nx/angular:webpack-browser` to `@nx/angular:browser-esbuild`
- Refactored the app component to standalone - Refactored the app component to standalone
- Improved the language localization for German (`de`) - Improved the language localization for German (`de`)
- Upgraded `@ionic/angular` from version `8.7.3` to `8.7.8`
### Fixed ### Fixed
@ -2162,7 +2395,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed an issue in the portfolio summary with the currency conversion of fees - Fixed an issue in the portfolio summary with the currency conversion of fees
- Fixed an issue in the the search for a holding - Fixed an issue in the search for a holding
- Removed the show condition of the experimental features setting in the user settings - Removed the show condition of the experimental features setting in the user settings
## 2.95.0 - 2024-07-12 ## 2.95.0 - 2024-07-12

4
Dockerfile

@ -22,10 +22,6 @@ COPY ./prisma/schema.prisma prisma/
RUN npm install RUN npm install
# See https://github.com/nrwl/nx/issues/6586 for further details
COPY ./decorate-angular-cli.js decorate-angular-cli.js
RUN node decorate-angular-cli.js
COPY ./apps apps/ COPY ./apps apps/
COPY ./libs libs/ COPY ./libs libs/
COPY ./jest.config.ts jest.config.ts COPY ./jest.config.ts jest.config.ts

23
README.md

@ -86,7 +86,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables ### Supported Environment Variables
| Name | Type | Default Value | Description | | Name | Type | Default Value | Description |
| ------------------------ | --------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ------------------------ | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | | `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | | `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | | `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
@ -103,6 +103,21 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | | `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running | | `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | | `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 |
| `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 |
| `OIDC_CLIENT_SECRET` | `string` | | The OIDC client secret |
| `OIDC_ISSUER` | `string` | | The OIDC issuer URL, used to discover the OIDC configuration via `/.well-known/openid-configuration` |
| `OIDC_SCOPE` | `string[]` (optional) | `["openid"]` | The OIDC scope to request, e.g. `["email","openid","profile"]` |
| `OIDC_TOKEN_URL` | `string` (optional) | | Manual override for the OIDC token endpoint (falls back to the discovery from the issuer) |
| `OIDC_USER_INFO_URL` | `string` (optional) | | Manual override for the OIDC user info endpoint (falls back to the discovery from the issuer) |
### Run with Docker Compose ### Run with Docker Compose
@ -226,7 +241,7 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
| `accountId` | `string` (optional) | Id of the account | | `accountId` | `string` (optional) | Id of the account |
| `comment` | `string` (optional) | Comment of the activity | | `comment` | `string` (optional) | Comment of the activity |
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. | | `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` | | `date` | `string` | Date in the format `ISO-8601` |
| `fee` | `number` | Fee of the activity | | `fee` | `number` | Fee of the activity |
| `quantity` | `number` | Quantity of the activity | | `quantity` | `number` | Quantity of the activity |
@ -316,6 +331,8 @@ If you like to support this project, become a [**Sponsor**](https://github.com/s
## License ## 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). Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
[^1]: Available with [**Ghostfolio Premium**](https://ghostfol.io/en/pricing).

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

@ -1,6 +1,7 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { Access } from '@ghostfolio/common/interfaces'; import { Access } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -23,8 +24,6 @@ import { Access as AccessModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessService } from './access.service'; import { AccessService } from './access.service';
import { CreateAccessDto } from './create-access.dto';
import { UpdateAccessDto } from './update-access.dto';
@Controller('access') @Controller('access')
export class AccessController { export class AccessController {

2
apps/api/src/app/account-balance/account-balance.controller.ts

@ -1,6 +1,7 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -20,7 +21,6 @@ import { AccountBalance } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountBalanceService } from './account-balance.service'; import { AccountBalanceService } from './account-balance.service';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Controller('account-balance') @Controller('account-balance')
export class AccountBalanceController { export class AccountBalanceController {

3
apps/api/src/app/account-balance/account-balance.service.ts

@ -2,6 +2,7 @@ import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos';
import { DATE_FORMAT, getSum, resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getSum, resetHours } from '@ghostfolio/common/helper';
import { import {
AccountBalancesResponse, AccountBalancesResponse,
@ -15,8 +16,6 @@ import { AccountBalance, Prisma } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Injectable() @Injectable()
export class AccountBalanceService { export class AccountBalanceService {
public constructor( public constructor(

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

@ -7,15 +7,18 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
CreateAccountDto,
TransferBalanceDto,
UpdateAccountDto
} from '@ghostfolio/common/dtos';
import { import {
AccountBalancesResponse, AccountBalancesResponse,
AccountResponse,
AccountsResponse AccountsResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { import type { RequestWithUser } from '@ghostfolio/common/types';
AccountWithValue,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -38,9 +41,6 @@ import { Account as AccountModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
import { CreateAccountDto } from './create-account.dto';
import { TransferBalanceDto } from './transfer-balance.dto';
import { UpdateAccountDto } from './update-account.dto';
@Controller('account') @Controller('account')
export class AccountController { export class AccountController {
@ -114,7 +114,7 @@ export class AccountController {
public async getAccountById( public async getAccountById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string @Param('id') id: string
): Promise<AccountWithValue> { ): Promise<AccountResponse> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);

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

@ -4,7 +4,6 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { DemoService } from '@ghostfolio/api/services/demo/demo.service'; import { DemoService } from '@ghostfolio/api/services/demo/demo.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import {
@ -13,6 +12,10 @@ import {
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import {
UpdateAssetProfileDto,
UpdatePropertyDto
} from '@ghostfolio/common/dtos';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
@ -52,7 +55,6 @@ import { isDate, parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
@ -91,7 +93,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMax(): Promise<void> { public async gatherMax(): Promise<void> {
const assetProfileIdentifiers = const assetProfileIdentifiers =
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers(); await this.dataGatheringService.getActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -118,7 +120,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileData(): Promise<void> { public async gatherProfileData(): Promise<void> {
const assetProfileIdentifiers = const assetProfileIdentifiers =
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers(); await this.dataGatheringService.getActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -305,7 +307,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateProperty( public async updateProperty(
@Param('key') key: string, @Param('key') key: string,
@Body() data: PropertyDto @Body() data: UpdatePropertyDto
) { ) {
return this.adminService.putSetting(key, data.value); return this.adminService.putSetting(key, data.value);
} }

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

@ -532,12 +532,7 @@ export class AdminService {
this.countUsersWithAnalytics(), this.countUsersWithAnalytics(),
this.getUsersWithAnalytics({ this.getUsersWithAnalytics({
skip, skip,
take, take
where: {
NOT: {
analytics: null
}
}
}) })
]); ]);
@ -855,6 +850,20 @@ export class AdminService {
} }
} }
]; ];
const noAnalyticsCondition: Prisma.UserWhereInput['NOT'] = {
analytics: null
};
if (where) {
if (where.NOT) {
where.NOT = { ...where.NOT, ...noAnalyticsCondition };
} else {
where.NOT = noAnalyticsCondition;
}
} else {
where = { NOT: noAnalyticsCondition };
}
} }
const usersWithAnalytics = await this.prismaService.user.findMany({ const usersWithAnalytics = await this.prismaService.user.findMany({
@ -876,6 +885,7 @@ export class AdminService {
}, },
createdAt: true, createdAt: true,
id: true, id: true,
provider: true,
role: true, role: true,
subscriptions: { subscriptions: {
orderBy: { orderBy: {
@ -892,7 +902,7 @@ export class AdminService {
}); });
return usersWithAnalytics.map( return usersWithAnalytics.map(
({ _count, analytics, createdAt, id, role, subscriptions }) => { ({ _count, analytics, createdAt, id, provider, role, subscriptions }) => {
const daysSinceRegistration = const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1; differenceInDays(new Date(), createdAt) + 1;
const engagement = analytics const engagement = analytics
@ -909,6 +919,7 @@ export class AdminService {
createdAt, createdAt,
engagement, engagement,
id, id,
provider,
role, role,
subscription, subscription,
accountCount: _count.accounts || 0, accountCount: _count.accounts || 0,

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

@ -37,6 +37,7 @@ import { AssetsModule } from './endpoints/assets/assets.module';
import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module'; import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { MarketDataModule } from './endpoints/market-data/market-data.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 { PublicModule } from './endpoints/public/public.module';
import { SitemapModule } from './endpoints/sitemap/sitemap.module'; import { SitemapModule } from './endpoints/sitemap/sitemap.module';
import { TagsModule } from './endpoints/tags/tags.module'; import { TagsModule } from './endpoints/tags/tags.module';
@ -95,6 +96,7 @@ import { UserModule } from './user/user.module';
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,
PlatformModule, PlatformModule,
PlatformsModule,
PortfolioModule, PortfolioModule,
PortfolioSnapshotQueueModule, PortfolioSnapshotQueueModule,
PrismaModule, PrismaModule,

58
apps/api/src/app/auth/auth.controller.ts

@ -2,7 +2,11 @@ import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { OAuthResponse } from '@ghostfolio/common/interfaces'; import {
AssertionCredentialJSON,
AttestationCredentialJSON,
OAuthResponse
} from '@ghostfolio/common/interfaces';
import { import {
Body, Body,
@ -22,10 +26,6 @@ import { Request, Response } from 'express';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from './interfaces/simplewebauthn';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
@ -84,7 +84,6 @@ export class AuthController {
@Req() request: Request, @Req() request: Request,
@Res() response: Response @Res() response: Response
) { ) {
// Handles the Google OAuth2 callback
const jwt: string = (request.user as any).jwt; const jwt: string = (request.user as any).jwt;
if (jwt) { if (jwt) {
@ -102,6 +101,46 @@ export class AuthController {
} }
} }
@Get('oidc')
@UseGuards(AuthGuard('oidc'))
@Version(VERSION_NEUTRAL)
public oidcLogin() {
if (!this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
@Get('oidc/callback')
@UseGuards(AuthGuard('oidc'))
@Version(VERSION_NEUTRAL)
public oidcLoginCallback(@Req() request: Request, @Res() response: Response) {
const jwt: string = (request.user as any).jwt;
if (jwt) {
response.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
);
} else {
response.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth`
);
}
}
@Post('webauthn/generate-authentication-options')
public async generateAuthenticationOptions(
@Body() body: { deviceId: string }
) {
return this.webAuthService.generateAuthenticationOptions(body.deviceId);
}
@Get('webauthn/generate-registration-options') @Get('webauthn/generate-registration-options')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async generateRegistrationOptions() { public async generateRegistrationOptions() {
@ -116,13 +155,6 @@ export class AuthController {
return this.webAuthService.verifyAttestation(body.credential); return this.webAuthService.verifyAttestation(body.credential);
} }
@Post('webauthn/generate-authentication-options')
public async generateAuthenticationOptions(
@Body() body: { deviceId: string }
) {
return this.webAuthService.generateAuthenticationOptions(body.deviceId);
}
@Post('webauthn/verify-authentication') @Post('webauthn/verify-authentication')
public async verifyAuthentication( public async verifyAuthentication(
@Body() body: { deviceId: string; credential: AssertionCredentialJSON } @Body() body: { deviceId: string; credential: AssertionCredentialJSON }

82
apps/api/src/app/auth/auth.module.ts

@ -4,17 +4,20 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Logger, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import type { StrategyOptions } from 'passport-openidconnect';
import { ApiKeyStrategy } from './api-key.strategy'; import { ApiKeyStrategy } from './api-key.strategy';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy'; import { GoogleStrategy } from './google.strategy';
import { JwtStrategy } from './jwt.strategy'; import { JwtStrategy } from './jwt.strategy';
import { OidcStrategy } from './oidc.strategy';
@Module({ @Module({
controllers: [AuthController], controllers: [AuthController],
@ -36,6 +39,83 @@ import { JwtStrategy } from './jwt.strategy';
AuthService, AuthService,
GoogleStrategy, GoogleStrategy,
JwtStrategy, JwtStrategy,
{
inject: [AuthService, ConfigurationService],
provide: OidcStrategy,
useFactory: async (
authService: AuthService,
configurationService: ConfigurationService
) => {
const isOidcEnabled = configurationService.get(
'ENABLE_FEATURE_AUTH_OIDC'
);
if (!isOidcEnabled) {
return null;
}
const issuer = configurationService.get('OIDC_ISSUER');
const scope = configurationService.get('OIDC_SCOPE');
const callbackUrl =
configurationService.get('OIDC_CALLBACK_URL') ||
`${configurationService.get('ROOT_URL')}/api/auth/oidc/callback`;
// Check for manual URL overrides
const manualAuthorizationUrl = configurationService.get(
'OIDC_AUTHORIZATION_URL'
);
const manualTokenUrl = configurationService.get('OIDC_TOKEN_URL');
const manualUserInfoUrl =
configurationService.get('OIDC_USER_INFO_URL');
let authorizationURL: string;
let tokenURL: string;
let userInfoURL: string;
if (manualAuthorizationUrl && manualTokenUrl && manualUserInfoUrl) {
// Use manual URLs
authorizationURL = manualAuthorizationUrl;
tokenURL = manualTokenUrl;
userInfoURL = manualUserInfoUrl;
} else {
// Fetch OIDC configuration from discovery endpoint
try {
const response = await fetch(
`${issuer}/.well-known/openid-configuration`
);
const config = (await response.json()) as {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
};
// Manual URLs take priority over discovered ones
authorizationURL =
manualAuthorizationUrl || config.authorization_endpoint;
tokenURL = manualTokenUrl || config.token_endpoint;
userInfoURL = manualUserInfoUrl || config.userinfo_endpoint;
} catch (error) {
Logger.error(error, 'OidcStrategy');
throw new Error('Failed to fetch OIDC configuration from issuer');
}
}
const options: StrategyOptions = {
authorizationURL,
issuer,
scope,
tokenURL,
userInfoURL,
callbackURL: callbackUrl,
clientID: configurationService.get('OIDC_CLIENT_ID'),
clientSecret: configurationService.get('OIDC_CLIENT_SECRET')
};
return new OidcStrategy(authService, options);
}
},
WebAuthService WebAuthService
] ]
}) })

14
apps/api/src/app/auth/auth.service.ts

@ -17,8 +17,6 @@ export class AuthService {
) {} ) {}
public async validateAnonymousLogin(accessToken: string): Promise<string> { public async validateAnonymousLogin(accessToken: string): Promise<string> {
return new Promise(async (resolve, reject) => {
try {
const hashedAccessToken = this.userService.createAccessToken({ const hashedAccessToken = this.userService.createAccessToken({
password: accessToken, password: accessToken,
salt: this.configurationService.get('ACCESS_TOKEN_SALT') salt: this.configurationService.get('ACCESS_TOKEN_SALT')
@ -29,19 +27,13 @@ export class AuthService {
}); });
if (user) { if (user) {
const jwt = this.jwtService.sign({ return this.jwtService.sign({
id: user.id id: user.id
}); });
}
resolve(jwt);
} else {
throw new Error(); throw new Error();
} }
} catch {
reject();
}
});
}
public async validateOAuthLogin({ public async validateOAuthLogin({
provider, provider,
@ -75,7 +67,7 @@ export class AuthService {
} catch (error) { } catch (error) {
throw new InternalServerErrorException( throw new InternalServerErrorException(
'validateOAuthLogin', 'validateOAuthLogin',
error.message error instanceof Error ? error.message : 'Unknown error'
); );
} }
} }

21
apps/api/src/app/auth/interfaces/interfaces.ts

@ -1,4 +1,4 @@
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; import { AuthDeviceDto } from '@ghostfolio/common/dtos';
import { Provider } from '@prisma/client'; import { Provider } from '@prisma/client';
@ -6,6 +6,25 @@ export interface AuthDeviceDialogParams {
authDevice: AuthDeviceDto; authDevice: AuthDeviceDto;
} }
export interface OidcContext {
claims?: {
sub?: string;
};
}
export interface OidcIdToken {
sub?: string;
}
export interface OidcParams {
sub?: string;
}
export interface OidcProfile {
id?: string;
sub?: string;
}
export interface ValidateOAuthLoginParams { export interface ValidateOAuthLoginParams {
provider: Provider; provider: Provider;
thirdPartyId: string; thirdPartyId: string;

114
apps/api/src/app/auth/oidc-state.store.ts

@ -0,0 +1,114 @@
import ms from 'ms';
/**
* Custom state store for OIDC authentication that doesn't rely on express-session.
* This store manages OAuth2 state parameters in memory with automatic cleanup.
*/
export class OidcStateStore {
private readonly STATE_EXPIRY_MS = ms('10 minutes');
private stateMap = new Map<
string,
{
appState?: unknown;
ctx: { issued?: Date; maxAge?: number; nonce?: string };
meta?: unknown;
timestamp: number;
}
>();
/**
* Store request state.
* Signature matches passport-openidconnect SessionStore
*/
public store(
_req: unknown,
_meta: unknown,
appState: unknown,
ctx: { maxAge?: number; nonce?: string; issued?: Date },
callback: (err: Error | null, handle?: string) => void
) {
try {
// Generate a unique handle for this state
const handle = this.generateHandle();
this.stateMap.set(handle, {
appState,
ctx,
meta: _meta,
timestamp: Date.now()
});
// Clean up expired states
this.cleanup();
callback(null, handle);
} catch (error) {
callback(error as Error);
}
}
/**
* Verify request state.
* Signature matches passport-openidconnect SessionStore
*/
public verify(
_req: unknown,
handle: string,
callback: (
err: Error | null,
appState?: unknown,
ctx?: { maxAge?: number; nonce?: string; issued?: Date }
) => void
) {
try {
const data = this.stateMap.get(handle);
if (!data) {
return callback(null, undefined, undefined);
}
if (Date.now() - data.timestamp > this.STATE_EXPIRY_MS) {
// State has expired
this.stateMap.delete(handle);
return callback(null, undefined, undefined);
}
// Remove state after verification (one-time use)
this.stateMap.delete(handle);
callback(null, data.ctx, data.appState);
} catch (error) {
callback(error as Error);
}
}
/**
* Clean up expired states
*/
private cleanup() {
const now = Date.now();
const expiredKeys: string[] = [];
for (const [key, value] of this.stateMap.entries()) {
if (now - value.timestamp > this.STATE_EXPIRY_MS) {
expiredKeys.push(key);
}
}
for (const key of expiredKeys) {
this.stateMap.delete(key);
}
}
/**
* Generate a cryptographically secure random handle
*/
private generateHandle() {
return (
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15) +
Date.now().toString(36)
);
}
}

69
apps/api/src/app/auth/oidc.strategy.ts

@ -0,0 +1,69 @@
import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client';
import { Request } from 'express';
import { Strategy, type StrategyOptions } from 'passport-openidconnect';
import { AuthService } from './auth.service';
import {
OidcContext,
OidcIdToken,
OidcParams,
OidcProfile
} from './interfaces/interfaces';
import { OidcStateStore } from './oidc-state.store';
@Injectable()
export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
private static readonly stateStore = new OidcStateStore();
public constructor(
private readonly authService: AuthService,
options: StrategyOptions
) {
super({
...options,
passReqToCallback: true,
store: OidcStrategy.stateStore
});
}
public async validate(
_request: Request,
issuer: string,
profile: OidcProfile,
context: OidcContext,
idToken: OidcIdToken,
_accessToken: string,
_refreshToken: string,
params: OidcParams
) {
try {
const thirdPartyId =
profile?.id ??
profile?.sub ??
idToken?.sub ??
params?.sub ??
context?.claims?.sub;
const jwt = await this.authService.validateOAuthLogin({
thirdPartyId,
provider: Provider.OIDC
});
if (!thirdPartyId) {
Logger.error(
`Missing subject identifier in OIDC response from ${issuer}`,
'OidcStrategy'
);
throw new Error('Missing subject identifier in OIDC response');
}
return { jwt };
} catch (error) {
Logger.error(error, 'OidcStrategy');
throw error;
}
}
}

11
apps/api/src/app/auth/web-auth.service.ts

@ -1,7 +1,11 @@
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { AuthDeviceDto } from '@ghostfolio/common/dtos';
import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -27,11 +31,6 @@ import {
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers'; import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
import ms from 'ms'; import ms from 'ms';
import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from './interfaces/simplewebauthn';
@Injectable() @Injectable()
export class WebAuthService { export class WebAuthService {
public constructor( public constructor(

2
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -8,7 +8,6 @@ import {
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { DataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
@ -18,6 +17,7 @@ import {
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config'; import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import { import {
DataProviderGhostfolioAssetProfileResponse, DataProviderGhostfolioAssetProfileResponse,
DataProviderHistoricalResponse,
DataProviderInfo, DataProviderInfo,
DividendsResponse, DividendsResponse,
HistoricalResponse, HistoricalResponse,

3
apps/api/src/app/endpoints/market-data/market-data.controller.ts

@ -10,6 +10,7 @@ import {
ghostfolioFearAndGreedIndexSymbolCryptocurrencies, ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks ghostfolioFearAndGreedIndexSymbolStocks
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { UpdateBulkMarketDataDto } from '@ghostfolio/common/dtos';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { import {
MarketDataDetailsResponse, MarketDataDetailsResponse,
@ -35,8 +36,6 @@ import { DataSource, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
@Controller('market-data') @Controller('market-data')
export class MarketDataController { export class MarketDataController {
public constructor( public constructor(

24
apps/api/src/app/endpoints/market-data/update-bulk-market-data.dto.ts

@ -1,24 +0,0 @@
import { Type } from 'class-transformer';
import {
ArrayNotEmpty,
IsArray,
IsISO8601,
IsNumber,
IsOptional
} from 'class-validator';
export class UpdateBulkMarketDataDto {
@ArrayNotEmpty()
@IsArray()
@Type(() => UpdateMarketDataDto)
marketData: UpdateMarketDataDto[];
}
class UpdateMarketDataDto {
@IsISO8601()
@IsOptional()
date?: string;
@IsNumber()
marketPrice: number;
}

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

@ -0,0 +1,22 @@
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();
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 {}

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

@ -82,7 +82,6 @@ export class PublicController {
]); ]);
const { activities } = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
includeDrafts: false,
sortColumn: 'date', sortColumn: 'date',
sortDirection: 'desc', sortDirection: 'desc',
take: 10, take: 10,

4
apps/api/src/app/endpoints/sitemap/sitemap.service.ts

@ -116,6 +116,10 @@ export class SitemapService {
{ {
languageCode: 'en', languageCode: 'en',
routerLink: ['2025', '09', 'hacktoberfest-2025'] routerLink: ['2025', '09', 'hacktoberfest-2025']
},
{
languageCode: 'en',
routerLink: ['2025', '11', 'black-weeks-2025']
} }
] ]
.map(({ languageCode, routerLink }) => { .map(({ languageCode, routerLink }) => {

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

@ -1,6 +1,7 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
@ -21,9 +22,6 @@ import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateTagDto } from './create-tag.dto';
import { UpdateTagDto } from './update-tag.dto';
@Controller('tags') @Controller('tags')
export class TagsController { export class TagsController {
public constructor( public constructor(

2
apps/api/src/app/endpoints/watchlist/watchlist.controller.ts

@ -4,6 +4,7 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { CreateWatchlistItemDto } from '@ghostfolio/common/dtos';
import { WatchlistResponse } from '@ghostfolio/common/interfaces'; import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
@ -26,7 +27,6 @@ import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateWatchlistItemDto } from './create-watchlist-item.dto';
import { WatchlistService } from './watchlist.service'; import { WatchlistService } from './watchlist.service';
@Controller('watchlist') @Controller('watchlist')

2
apps/api/src/app/exchange-rate/exchange-rate.controller.ts

@ -1,5 +1,5 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { DataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { DataProviderHistoricalResponse } from '@ghostfolio/common/interfaces';
import { import {
Controller, Controller,

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

@ -1,5 +1,6 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { 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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ExportResponse } from '@ghostfolio/common/interfaces'; import { ExportResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -28,6 +29,7 @@ export class ExportController {
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async export( public async export(
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('activityIds') filterByActivityIds?: string, @Query('activityIds') filterByActivityIds?: string,

11
apps/api/src/app/import/import-data.dto.ts

@ -1,12 +1,13 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import {
CreateAccountWithBalancesDto,
CreateAssetProfileWithMarketDataDto,
CreateOrderDto,
CreateTagDto
} from '@ghostfolio/common/dtos';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsArray, IsOptional, ValidateNested } from 'class-validator'; import { IsArray, IsOptional, ValidateNested } from 'class-validator';
import { CreateTagDto } from '../endpoints/tags/create-tag.dto';
import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto';
import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto';
export class ImportDataDto { export class ImportDataDto {
@IsArray() @IsArray()
@IsOptional() @IsOptional()

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

@ -103,6 +103,7 @@ export class ImportController {
const activities = await this.importService.getDividends({ const activities = await this.importService.getDividends({
dataSource, dataSource,
symbol, symbol,
userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id userId: this.request.user.id
}); });

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

@ -6,6 +6,7 @@ import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -24,6 +25,7 @@ import { ImportService } from './import.service';
controllers: [ImportController], controllers: [ImportController],
imports: [ imports: [
AccountModule, AccountModule,
ApiModule,
CacheModule, CacheModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,

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

@ -1,13 +1,8 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import {
Activity,
ActivityError
} from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
@ -15,14 +10,23 @@ import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathe
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import {
CreateAssetProfileDto,
CreateAccountDto,
CreateOrderDto
} from '@ghostfolio/common/dtos';
import { import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import {
Activity,
ActivityError,
AssetProfileIdentifier
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { import {
AccountWithPlatform, AccountWithValue,
OrderWithAccount, OrderWithAccount,
UserWithSettings UserWithSettings
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
@ -32,15 +36,15 @@ import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns'; import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns';
import { omit, uniqBy } from 'lodash'; import { omit, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { randomUUID } from 'node:crypto';
import { CreateAssetProfileDto } from '../admin/create-asset-profile.dto';
import { ImportDataDto } from './import-data.dto'; import { ImportDataDto } from './import-data.dto';
@Injectable() @Injectable()
export class ImportService { export class ImportService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
@ -55,18 +59,44 @@ export class ImportService {
public async getDividends({ public async getDividends({
dataSource, dataSource,
symbol, symbol,
userCurrency,
userId userId
}: AssetProfileIdentifier & { userId: string }): Promise<Activity[]> { }: AssetProfileIdentifier & {
userCurrency: string;
userId: string;
}): Promise<Activity[]> {
try { try {
const { activities, firstBuyDate, historicalData } = const holding = await this.portfolioService.getHolding({
await this.portfolioService.getHolding({
dataSource, dataSource,
symbol, symbol,
userId, userId,
impersonationId: undefined impersonationId: undefined
}); });
const [[assetProfile], dividends] = await Promise.all([ if (!holding) {
return [];
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByDataSource: dataSource,
filterBySymbol: symbol
});
const { firstBuyDate, historicalData } = holding;
const [{ accounts }, { activities }, [assetProfile], dividends] =
await Promise.all([
this.portfolioService.getAccountsWithAggregations({
filters,
userId,
withExcludedAccounts: true
}),
this.orderService.getOrders({
filters,
userCurrency,
userId,
startDate: parseDate(firstBuyDate)
}),
this.symbolProfileService.getSymbolProfiles([ this.symbolProfileService.getSymbolProfiles([
{ {
dataSource, dataSource,
@ -82,18 +112,10 @@ export class ImportService {
}) })
]); ]);
const accounts = activities
.filter(({ account }) => {
return !!account;
})
.map(({ account }) => {
return account;
});
const account = this.isUniqueAccount(accounts) ? accounts[0] : undefined; const account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
return await Promise.all( return await Promise.all(
Object.entries(dividends).map(async ([dateString, { marketPrice }]) => { Object.entries(dividends).map(([dateString, { marketPrice }]) => {
const quantity = const quantity =
historicalData.find((historicalDataItem) => { historicalData.find((historicalDataItem) => {
return historicalDataItem.date === dateString; return historicalDataItem.date === dateString;
@ -270,7 +292,7 @@ export class ImportService {
// Asset profile belongs to a different user // Asset profile belongs to a different user
if (existingAssetProfile) { if (existingAssetProfile) {
const symbol = uuidv4(); const symbol = randomUUID();
assetProfileSymbolMapping[assetProfile.symbol] = symbol; assetProfileSymbolMapping[assetProfile.symbol] = symbol;
assetProfile.symbol = symbol; assetProfile.symbol = symbol;
} }
@ -489,7 +511,7 @@ export class ImportService {
accountId: validatedAccount?.id, accountId: validatedAccount?.id,
accountUserId: undefined, accountUserId: undefined,
createdAt: new Date(), createdAt: new Date(),
id: uuidv4(), id: randomUUID(),
isDraft: isAfter(date, endOfToday()), isDraft: isAfter(date, endOfToday()),
SymbolProfile: { SymbolProfile: {
assetClass, assetClass,
@ -688,11 +710,11 @@ export class ImportService {
); );
} }
private isUniqueAccount(accounts: AccountWithPlatform[]) { private isUniqueAccount(accounts: AccountWithValue[]) {
const uniqueAccountIds = new Set<string>(); const uniqueAccountIds = new Set<string>();
for (const account of accounts) { for (const { id } of accounts) {
uniqueAccountIds.add(account.id); uniqueAccountIds.add(id);
} }
return uniqueAccountIds.size === 1; return uniqueAccountIds.size === 1;

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

@ -51,6 +51,18 @@ export class InfoService {
const globalPermissions: string[] = []; const globalPermissions: string[] = [];
if (this.configurationService.get('ENABLE_FEATURE_AUTH_GOOGLE')) {
globalPermissions.push(permissions.enableAuthGoogle);
}
if (this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) {
globalPermissions.push(permissions.enableAuthOidc);
}
if (this.configurationService.get('ENABLE_FEATURE_AUTH_TOKEN')) {
globalPermissions.push(permissions.enableAuthToken);
}
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
info.fearAndGreedDataSource = encodeDataSource( info.fearAndGreedDataSource = encodeDataSource(
@ -70,10 +82,6 @@ export class InfoService {
); );
} }
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
globalPermissions.push(permissions.enableSocialLogin);
}
if (this.configurationService.get('ENABLE_FEATURE_STATISTICS')) { if (this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
globalPermissions.push(permissions.enableStatistics); globalPermissions.push(permissions.enableStatistics);
} }
@ -85,7 +93,6 @@ export class InfoService {
(await this.propertyService.getByKey<string[]>( (await this.propertyService.getByKey<string[]>(
PROPERTY_COUNTRIES_OF_SUBSCRIBERS PROPERTY_COUNTRIES_OF_SUBSCRIBERS
)) ?? []; )) ?? [];
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
} }
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) { if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {

4
apps/api/src/app/order/order.controller.ts

@ -11,6 +11,7 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
HEADER_KEY_IMPERSONATION HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { import {
ActivitiesResponse, ActivitiesResponse,
ActivityResponse ActivityResponse
@ -39,9 +40,7 @@ import { Order as OrderModel, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateOrderDto } from './create-order.dto';
import { OrderService } from './order.service'; import { OrderService } from './order.service';
import { UpdateOrderDto } from './update-order.dto';
@Controller('order') @Controller('order')
export class OrderController { export class OrderController {
@ -167,6 +166,7 @@ export class OrderController {
const { activities } = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
userCurrency, userCurrency,
includeDrafts: true,
userId: impersonationUserId || this.request.user.id, userId: impersonationUserId || this.request.user.id,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });

179
apps/api/src/app/order/order.service.ts

@ -1,6 +1,10 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
@ -15,6 +19,7 @@ import {
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
ActivitiesResponse, ActivitiesResponse,
Activity,
AssetProfileIdentifier, AssetProfileIdentifier,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter Filter
@ -36,13 +41,15 @@ import { Big } from 'big.js';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { groupBy, uniqBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { randomUUID } from 'node:crypto';
@Injectable() @Injectable()
export class OrderService { export class OrderService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
@ -142,7 +149,7 @@ export class OrderService {
} else { } else {
// Create custom asset profile // Create custom asset profile
name = name ?? data.SymbolProfile.connectOrCreate.create.symbol; name = name ?? data.SymbolProfile.connectOrCreate.create.symbol;
symbol = uuidv4(); symbol = randomUUID();
} }
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass; data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
@ -225,6 +232,15 @@ export class OrderService {
}); });
} }
this.eventEmitter.emit(
AssetProfileChangedEvent.getName(),
new AssetProfileChangedEvent({
currency: order.SymbolProfile.currency,
dataSource: order.SymbolProfile.dataSource,
symbol: order.SymbolProfile.symbol
})
);
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ new PortfolioChangedEvent({
@ -307,6 +323,131 @@ export class OrderService {
return count; return count;
} }
/**
* Generates synthetic orders 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 getCashOrders({
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 getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) { public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.order.findFirst({ return this.prismaService.order.findFirst({
orderBy: { orderBy: {
@ -600,22 +741,52 @@ export class OrderService {
return { activities, count }; return { activities, count };
} }
/**
* Retrieves all orders required for the portfolio calculator, including both standard asset orders
* and optional synthetic orders representing cash activities.
*/
@LogPerformance @LogPerformance
public async getOrdersForPortfolioCalculator({ public async getOrdersForPortfolioCalculator({
filters, filters,
userCurrency, userCurrency,
userId userId,
withCash = false
}: { }: {
/** Optional filters to apply to the orders. */
filters?: Filter[]; filters?: Filter[];
/** The base currency of the user. */
userCurrency: string; userCurrency: string;
/** The ID of the user. */
userId: string; userId: string;
/** Whether to include cash activities in the result. */
withCash?: boolean;
}) { }) {
return this.getOrders({ const orders = await this.getOrders({
filters, filters,
userCurrency, userCurrency,
userId, userId,
withExcludedAccountsAndActivities: false // TODO withExcludedAccountsAndActivities: false // TODO
}); });
if (withCash) {
const cashDetails = await this.accountService.getCashDetails({
filters,
userId,
currency: userCurrency
});
const cashOrders = await this.getCashOrders({
cashDetails,
filters,
userCurrency,
userId
});
orders.activities.push(...cashOrders.activities);
orders.count += cashOrders.count;
}
return orders;
} }
public async getStatisticsByCurrency( public async getStatisticsByCurrency(

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

@ -1,5 +1,6 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { import {
@ -17,16 +18,14 @@ import { AuthGuard } from '@nestjs/passport';
import { Platform } from '@prisma/client'; import { Platform } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreatePlatformDto } from './create-platform.dto';
import { PlatformService } from './platform.service'; import { PlatformService } from './platform.service';
import { UpdatePlatformDto } from './update-platform.dto';
@Controller('platform') @Controller('platform')
export class PlatformController { export class PlatformController {
public constructor(private readonly platformService: PlatformService) {} public constructor(private readonly platformService: PlatformService) {}
@Get() @Get()
@HasPermission(permissions.readPlatforms) @HasPermission(permissions.readPlatformsWithAccountCount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPlatforms() { public async getPlatforms() {
return this.platformService.getPlatformsWithAccountCount(); return this.platformService.getPlatformsWithAccountCount();

7
apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts

@ -1,10 +1,13 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces'; import {
Activity,
Filter,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';

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

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
@ -26,6 +25,7 @@ import {
resetHours resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
Activity,
AssetProfileIdentifier, AssetProfileIdentifier,
DataProviderInfo, DataProviderInfo,
Filter, Filter,
@ -39,16 +39,21 @@ import { GroupBy } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { AssetSubClass } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { plainToClass } from 'class-transformer'; import { plainToClass } from 'class-transformer';
import { import {
differenceInDays, differenceInDays,
eachDayOfInterval, eachDayOfInterval,
eachYearOfInterval,
endOfDay, endOfDay,
endOfYear,
format, format,
isAfter, isAfter,
isBefore, isBefore,
isWithinInterval,
min, min,
startOfYear,
subDays subDays
} from 'date-fns'; } from 'date-fns';
import { isNumber, sortBy, sum, uniqBy } from 'lodash'; import { isNumber, sortBy, sum, uniqBy } from 'lodash';
@ -199,13 +204,19 @@ export abstract class PortfolioCalculator {
let totalInterestWithCurrencyEffect = new Big(0); let totalInterestWithCurrencyEffect = new Big(0);
let totalLiabilitiesWithCurrencyEffect = new Big(0); let totalLiabilitiesWithCurrencyEffect = new Big(0);
for (const { currency, dataSource, symbol } of transactionPoints[ for (const {
firstIndex - 1 assetSubClass,
].items) { currency,
dataSource,
symbol
} of transactionPoints[firstIndex - 1].items) {
// Gather data for all assets except CASH
if (assetSubClass !== 'CASH') {
dataGatheringItems.push({ dataGatheringItems.push({
dataSource, dataSource,
symbol symbol
}); });
}
currencies[symbol] = currency; currencies[symbol] = currency;
} }
@ -379,6 +390,10 @@ export abstract class PortfolioCalculator {
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
const includeInTotalAssetValue =
item.assetSubClass !== AssetSubClass.CASH;
if (includeInTotalAssetValue) {
valuesBySymbol[item.symbol] = { valuesBySymbol[item.symbol] = {
currentValues, currentValues,
currentValuesWithCurrencyEffect, currentValuesWithCurrencyEffect,
@ -390,16 +405,18 @@ export abstract class PortfolioCalculator {
timeWeightedInvestmentValues, timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect timeWeightedInvestmentValuesWithCurrencyEffect
}; };
}
positions.push({ positions.push({
feeInBaseCurrency, feeInBaseCurrency,
includeInTotalAssetValue,
timeWeightedInvestment, timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect, timeWeightedInvestmentWithCurrencyEffect,
dividend: totalDividend,
dividendInBaseCurrency: totalDividendInBaseCurrency,
averagePrice: item.averagePrice, averagePrice: item.averagePrice,
currency: item.currency, currency: item.currency,
dataSource: item.dataSource, dataSource: item.dataSource,
dividend: totalDividend,
dividendInBaseCurrency: totalDividendInBaseCurrency,
fee: item.fee, fee: item.fee,
firstBuyDate: item.firstBuyDate, firstBuyDate: item.firstBuyDate,
grossPerformance: !hasErrors ? (grossPerformance ?? null) : null, grossPerformance: !hasErrors ? (grossPerformance ?? null) : null,
@ -889,6 +906,24 @@ export abstract class PortfolioCalculator {
} }
} }
// Make sure the first and last date of each calendar year is present
const interval = { start: startDate, end: endDate };
for (const date of eachYearOfInterval(interval)) {
const yearStart = startOfYear(date);
const yearEnd = endOfYear(date);
if (isWithinInterval(yearStart, interval)) {
// Add start of year (YYYY-01-01)
chartDateMap[format(yearStart, DATE_FORMAT)] = true;
}
if (isWithinInterval(yearEnd, interval)) {
// Add end of year (YYYY-12-31)
chartDateMap[format(yearEnd, DATE_FORMAT)] = true;
}
}
return chartDateMap; return chartDateMap;
} }
@ -911,6 +946,7 @@ export abstract class PortfolioCalculator {
} of this.activities) { } of this.activities) {
let currentTransactionPointItem: TransactionPointSymbol; let currentTransactionPointItem: TransactionPointSymbol;
const assetSubClass = SymbolProfile.assetSubClass;
const currency = SymbolProfile.currency; const currency = SymbolProfile.currency;
const dataSource = SymbolProfile.dataSource; const dataSource = SymbolProfile.dataSource;
const factor = getFactor(type); const factor = getFactor(type);
@ -955,6 +991,7 @@ export abstract class PortfolioCalculator {
} }
currentTransactionPointItem = { currentTransactionPointItem = {
assetSubClass,
currency, currency,
dataSource, dataSource,
investment, investment,
@ -973,6 +1010,7 @@ export abstract class PortfolioCalculator {
}; };
} else { } else {
currentTransactionPointItem = { currentTransactionPointItem = {
assetSubClass,
currency, currency,
dataSource, dataSource,
fee, fee,

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

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, symbolProfileDummyData,
@ -14,6 +13,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, symbolProfileDummyData,
@ -14,6 +13,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';

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

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, symbolProfileDummyData,
@ -14,6 +13,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';

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

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, symbolProfileDummyData,
@ -14,6 +13,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
@ -109,6 +109,12 @@ describe('PortfolioCalculator', () => {
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const historicalDataDates = portfolioSnapshot.historicalData.map(
({ date }) => {
return date;
}
);
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
@ -170,8 +176,12 @@ describe('PortfolioCalculator', () => {
totalLiabilitiesWithCurrencyEffect: new Big('0') totalLiabilitiesWithCurrencyEffect: new Big('0')
}); });
expect(historicalDataDates).not.toContain('2021-01-01');
expect(historicalDataDates).not.toContain('2021-12-31');
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({ expect.objectContaining({
date: '2021-12-18',
netPerformance: 23.05, netPerformance: 23.05,
netPerformanceInPercentage: 0.08437042459736457, netPerformanceInPercentage: 0.08437042459736457,
netPerformanceInPercentageWithCurrencyEffect: 0.08437042459736457, netPerformanceInPercentageWithCurrencyEffect: 0.08437042459736457,

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

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
loadExportFile, loadExportFile,
@ -16,7 +15,7 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { ExportResponse } from '@ghostfolio/common/interfaces'; import { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
@ -131,6 +130,17 @@ describe('PortfolioCalculator', () => {
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const historicalDataDates = portfolioSnapshot.historicalData.map(
({ date }) => {
return date;
}
);
expect(historicalDataDates).not.toContain('2021-01-01');
expect(historicalDataDates).toContain('2021-12-31');
expect(historicalDataDates).toContain('2022-01-01');
expect(historicalDataDates).not.toContain('2022-12-31');
expect(portfolioSnapshot.positions[0].fee).toEqual(new Big(4.46)); expect(portfolioSnapshot.positions[0].fee).toEqual(new Big(4.46));
expect( expect(
portfolioSnapshot.positions[0].feeInBaseCurrency.toNumber() portfolioSnapshot.positions[0].feeInBaseCurrency.toNumber()

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

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
loadExportFile, loadExportFile,
@ -15,7 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { ExportResponse } from '@ghostfolio/common/interfaces'; import { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
@ -119,6 +118,12 @@ describe('PortfolioCalculator', () => {
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const historicalDataDates = portfolioSnapshot.historicalData.map(
({ date }) => {
return date;
}
);
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
@ -226,6 +231,11 @@ describe('PortfolioCalculator', () => {
totalLiabilitiesWithCurrencyEffect: new Big('0') totalLiabilitiesWithCurrencyEffect: new Big('0')
}); });
expect(historicalDataDates).not.toContain('2021-01-01');
expect(historicalDataDates).toContain('2021-12-31');
expect(historicalDataDates).toContain('2022-01-01');
expect(historicalDataDates).not.toContain('2022-12-31');
expect(investments).toEqual([ expect(investments).toEqual([
{ date: '2021-12-12', investment: new Big('44558.42') } { date: '2021-12-12', investment: new Big('44558.42') }
]); ]);

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

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, symbolProfileDummyData,
@ -15,6 +14,7 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';

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

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
loadExportFile, loadExportFile,
@ -15,7 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { ExportResponse } from '@ghostfolio/common/interfaces'; import { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';

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

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
loadExportFile, loadExportFile,
@ -15,7 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { ExportResponse } from '@ghostfolio/common/interfaces'; import { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
@ -119,6 +118,12 @@ describe('PortfolioCalculator', () => {
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const historicalDataDates = portfolioSnapshot.historicalData.map(
({ date }) => {
return date;
}
);
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
@ -226,6 +231,11 @@ describe('PortfolioCalculator', () => {
totalLiabilitiesWithCurrencyEffect: new Big('0') totalLiabilitiesWithCurrencyEffect: new Big('0')
}); });
expect(historicalDataDates).not.toContain('2021-01-01');
expect(historicalDataDates).toContain('2021-12-31');
expect(historicalDataDates).toContain('2022-01-01');
expect(historicalDataDates).not.toContain('2022-12-31');
expect(investments).toEqual([ expect(investments).toEqual([
{ date: '2021-12-12', investment: new Big('44558.42') } { date: '2021-12-12', investment: new Big('44558.42') }
]); ]);

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

@ -0,0 +1,290 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.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 configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let orderService: OrderService;
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(
dataProviderService,
null,
null,
null
);
orderService = new OrderService(
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(orderService, 'getOrders').mockResolvedValue({
activities: [],
count: 0
});
const { activities } = await orderService.getOrdersForPortfolioCalculator(
{
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>({
averagePrice: new Big(1),
currency: 'USD',
dataSource: DataSource.YAHOO,
dividend: new Big(0),
dividendInBaseCurrency: new Big(0),
fee: new Big(0),
feeInBaseCurrency: new Big(0),
firstBuyDate: '2023-12-31',
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: null,
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'
),
transactionCount: 2,
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

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, symbolProfileDummyData,
@ -14,6 +13,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';

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

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, symbolProfileDummyData,
@ -15,6 +14,7 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';

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

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, symbolProfileDummyData,
@ -14,6 +13,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';

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

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, symbolProfileDummyData,
@ -14,6 +13,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {

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

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, symbolProfileDummyData,
@ -14,6 +13,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';

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

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
loadExportFile, loadExportFile,
@ -15,7 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { ExportResponse } from '@ghostfolio/common/interfaces'; import { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';

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

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
loadExportFile, loadExportFile,
@ -15,7 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { ExportResponse } from '@ghostfolio/common/interfaces'; import { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';

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

@ -1,4 +1,3 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, symbolProfileDummyData,
@ -14,6 +13,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';

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

@ -34,7 +34,11 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
let totalTimeWeightedInvestment = new Big(0); let totalTimeWeightedInvestment = new Big(0);
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) { for (const currentPosition of positions.filter(
({ includeInTotalAssetValue }) => {
return includeInTotalAssetValue;
}
)) {
if (currentPosition.feeInBaseCurrency) { if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency currentPosition.feeInBaseCurrency
@ -188,6 +192,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
}) })
); );
const isCash = orders[0]?.SymbolProfile?.assetSubClass === 'CASH';
if (orders.length <= 0) { if (orders.length <= 0) {
return { return {
currentValues: {}, currentValues: {},
@ -244,6 +250,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
// For BUY / SELL activities with a MANUAL data source where no historical market price is available, // 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. // the calculation should fall back to using the activity’s unit price.
unitPriceAtEndDate = latestActivity.unitPrice; unitPriceAtEndDate = latestActivity.unitPrice;
} else if (isCash) {
unitPriceAtEndDate = new Big(1);
} }
if ( if (
@ -295,7 +303,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
quantity: new Big(0), quantity: new Big(0),
SymbolProfile: { SymbolProfile: {
dataSource, dataSource,
symbol symbol,
assetSubClass: isCash ? 'CASH' : undefined
}, },
type: 'BUY', type: 'BUY',
unitPrice: unitPriceAtStartDate unitPrice: unitPriceAtStartDate
@ -308,7 +317,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
itemType: 'end', itemType: 'end',
SymbolProfile: { SymbolProfile: {
dataSource, dataSource,
symbol symbol,
assetSubClass: isCash ? 'CASH' : undefined
}, },
quantity: new Big(0), quantity: new Big(0),
type: 'BUY', type: 'BUY',
@ -348,7 +358,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
quantity: new Big(0), quantity: new Big(0),
SymbolProfile: { SymbolProfile: {
dataSource, dataSource,
symbol symbol,
assetSubClass: isCash ? 'CASH' : undefined
}, },
type: 'BUY', type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice, unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,

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

@ -1,4 +1,4 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/common/interfaces';
export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> { export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> {
date: string; date: string;
@ -6,7 +6,7 @@ export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> {
quantity: Big; quantity: Big;
SymbolProfile: Pick< SymbolProfile: Pick<
Activity['SymbolProfile'], Activity['SymbolProfile'],
'currency' | 'dataSource' | 'name' | 'symbol' | 'userId' 'assetSubClass' | 'currency' | 'dataSource' | 'name' | 'symbol' | 'userId'
>; >;
unitPrice: Big; unitPrice: Big;
} }

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

@ -1,7 +1,8 @@
import { DataSource, Tag } from '@prisma/client'; import { AssetSubClass, DataSource, Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
export interface TransactionPointSymbol { export interface TransactionPointSymbol {
assetSubClass: AssetSubClass;
averagePrice: Big; averagePrice: Big;
currency: string; currency: string;
dataSource: DataSource; dataSource: DataSource;

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

@ -1,7 +1,6 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
@ -40,6 +39,7 @@ import {
import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper';
import { import {
AccountsResponse, AccountsResponse,
Activity,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter, Filter,
HistoricalDataItem, HistoricalDataItem,
@ -88,7 +88,6 @@ import {
parseISO, parseISO,
set set
} from 'date-fns'; } from 'date-fns';
import { isEmpty } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
@ -523,10 +522,6 @@ export class PortfolioService {
return type === 'ACCOUNT'; return type === 'ACCOUNT';
}) ?? false; }) ?? false;
const isFilteredByCash = filters?.some(({ id, type }) => {
return id === AssetClass.LIQUIDITY && type === 'ASSET_CLASS';
});
const isFilteredByClosedHoldings = const isFilteredByClosedHoldings =
filters?.some(({ id, type }) => { filters?.some(({ id, type }) => {
return id === 'CLOSED' && type === 'HOLDING_TYPE'; return id === 'CLOSED' && type === 'HOLDING_TYPE';
@ -558,6 +553,9 @@ export class PortfolioService {
assetProfileIdentifiers assetProfileIdentifiers
); );
const cashSymbolProfiles = this.getCashSymbolProfiles(cashDetails);
symbolProfiles.push(...cashSymbolProfiles);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
for (const symbolProfile of symbolProfiles) { for (const symbolProfile of symbolProfiles) {
symbolProfileMap[symbolProfile.symbol] = symbolProfile; symbolProfileMap[symbolProfile.symbol] = symbolProfile;
@ -662,18 +660,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({ const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({
activities, activities,
filters, filters,
@ -776,35 +762,7 @@ export class PortfolioService {
}); });
if (activities.length === 0) { if (activities.length === 0) {
return { return undefined;
activities: [],
activitiesCount: 0,
averagePrice: undefined,
dataProviderInfo: undefined,
dividendInBaseCurrency: undefined,
dividendYieldPercent: undefined,
dividendYieldPercentWithCurrencyEffect: undefined,
feeInBaseCurrency: undefined,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
grossPerformancePercentWithCurrencyEffect: undefined,
grossPerformanceWithCurrencyEffect: undefined,
historicalData: [],
investmentInBaseCurrencyWithCurrencyEffect: undefined,
marketPrice: undefined,
marketPriceMax: undefined,
marketPriceMin: undefined,
netPerformance: undefined,
netPerformancePercent: undefined,
netPerformancePercentWithCurrencyEffect: undefined,
netPerformanceWithCurrencyEffect: undefined,
performances: undefined,
quantity: undefined,
SymbolProfile: undefined,
tags: [],
value: undefined
};
} }
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([ const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
@ -818,7 +776,6 @@ export class PortfolioService {
currency: userCurrency currency: userCurrency
}); });
const portfolioStart = portfolioCalculator.getStartDate();
const transactionPoints = portfolioCalculator.getTransactionPoints(); const transactionPoints = portfolioCalculator.getTransactionPoints();
const { positions } = await portfolioCalculator.getSnapshot(); const { positions } = await portfolioCalculator.getSnapshot();
@ -827,7 +784,10 @@ export class PortfolioService {
return position.dataSource === dataSource && position.symbol === symbol; return position.dataSource === dataSource && position.symbol === symbol;
}); });
if (holding) { if (!holding) {
return undefined;
}
const { const {
averagePrice, averagePrice,
currency, currency,
@ -868,13 +828,9 @@ export class PortfolioService {
const dividendYieldPercentWithCurrencyEffect = const dividendYieldPercentWithCurrencyEffect =
getAnnualizedPerformancePercent({ getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq( netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(0)
0
)
? new Big(0) ? new Big(0)
: dividendInBaseCurrency.div( : dividendInBaseCurrency.div(timeWeightedInvestmentWithCurrencyEffect)
timeWeightedInvestmentWithCurrencyEffect
)
}); });
const historicalData = await this.dataProviderService.getHistorical( const historicalData = await this.dataProviderService.getHistorical(
@ -928,9 +884,7 @@ export class PortfolioService {
date, date,
averagePrice: currentAveragePrice, averagePrice: currentAveragePrice,
marketPrice: marketPrice:
historicalDataArray.length > 0 historicalDataArray.length > 0 ? marketPrice : currentAveragePrice,
? marketPrice
: currentAveragePrice,
quantity: currentQuantity quantity: currentQuantity
}); });
@ -1007,98 +961,6 @@ export class PortfolioService {
userCurrency userCurrency
) )
}; };
} else {
const currentData = await this.dataProviderService.getQuotes({
user,
items: [{ symbol, dataSource: DataSource.YAHOO }]
});
const marketPrice = currentData[symbol]?.marketPrice;
let historicalData = await this.dataProviderService.getHistorical(
[{ symbol, dataSource: DataSource.YAHOO }],
'day',
portfolioStart,
new Date()
);
if (isEmpty(historicalData)) {
try {
historicalData = await this.dataProviderService.getHistoricalRaw({
assetProfileIdentifiers: [{ symbol, dataSource: DataSource.YAHOO }],
from: portfolioStart,
to: new Date()
});
} catch {
historicalData = {
[symbol]: {}
};
}
}
const historicalDataArray: HistoricalDataItem[] = [];
let marketPriceMax = marketPrice;
let marketPriceMaxDate = new Date();
let marketPriceMin = marketPrice;
for (const [date, { marketPrice }] of Object.entries(
historicalData[symbol]
)) {
historicalDataArray.push({
date,
value: marketPrice
});
if (marketPrice > marketPriceMax) {
marketPriceMax = marketPrice;
marketPriceMaxDate = parseISO(date);
}
marketPriceMin = Math.min(
marketPrice ?? Number.MAX_SAFE_INTEGER,
marketPriceMin
);
}
const performancePercent =
this.benchmarkService.calculateChangeInPercentage(
marketPriceMax,
marketPrice
);
return {
marketPrice,
marketPriceMax,
marketPriceMin,
SymbolProfile,
activities: [],
activitiesCount: 0,
averagePrice: 0,
dataProviderInfo: undefined,
dividendInBaseCurrency: 0,
dividendYieldPercent: 0,
dividendYieldPercentWithCurrencyEffect: 0,
feeInBaseCurrency: 0,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
grossPerformancePercentWithCurrencyEffect: undefined,
grossPerformanceWithCurrencyEffect: undefined,
historicalData: historicalDataArray,
investmentInBaseCurrencyWithCurrencyEffect: 0,
netPerformance: undefined,
netPerformancePercent: undefined,
netPerformancePercentWithCurrencyEffect: undefined,
netPerformanceWithCurrencyEffect: undefined,
performances: {
allTimeHigh: {
performancePercent,
date: marketPriceMaxDate
}
},
quantity: 0,
tags: [],
value: 0
};
}
} }
public async getPerformance({ public async getPerformance({
@ -1673,6 +1535,37 @@ export class PortfolioService {
return cashPositions; 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({ private getDividendsByGroup({
dividends, dividends,
groupBy groupBy
@ -1979,18 +1872,17 @@ export class PortfolioService {
netPerformanceWithCurrencyEffect netPerformanceWithCurrencyEffect
} = performance; } = performance;
const dividendInBaseCurrency =
await portfolioCalculator.getDividendInBaseCurrency();
const totalEmergencyFund = this.getTotalEmergencyFund({ const totalEmergencyFund = this.getTotalEmergencyFund({
emergencyFundHoldingsValueInBaseCurrency, emergencyFundHoldingsValueInBaseCurrency,
userSettings: user.settings?.settings as UserSettings userSettings: user.settings?.settings as UserSettings
}); });
const fees = await portfolioCalculator.getFeesInBaseCurrency(); const dateOfFirstActivity = portfolioCalculator.getStartDate();
const firstOrderDate = portfolioCalculator.getStartDate(); const dividendInBaseCurrency =
await portfolioCalculator.getDividendInBaseCurrency();
const fees = await portfolioCalculator.getFeesInBaseCurrency();
const interest = await portfolioCalculator.getInterestInBaseCurrency(); const interest = await portfolioCalculator.getInterestInBaseCurrency();
const liabilities = const liabilities =
@ -2048,7 +1940,7 @@ export class PortfolioService {
.minus(liabilities) .minus(liabilities)
.toNumber(); .toNumber();
const daysInMarket = differenceInDays(new Date(), firstOrderDate); const daysInMarket = differenceInDays(new Date(), dateOfFirstActivity);
const annualizedPerformancePercent = getAnnualizedPerformancePercent({ const annualizedPerformancePercent = getAnnualizedPerformancePercent({
daysInMarket, daysInMarket,
@ -2067,6 +1959,7 @@ export class PortfolioService {
annualizedPerformancePercent, annualizedPerformancePercent,
annualizedPerformancePercentWithCurrencyEffect, annualizedPerformancePercentWithCurrencyEffect,
cash, cash,
dateOfFirstActivity,
excludedAccountsAndActivities, excludedAccountsAndActivities,
netPerformance, netPerformance,
netPerformancePercentage, netPerformancePercentage,
@ -2282,7 +2175,7 @@ export class PortfolioService {
accounts[account?.id || UNKNOWN_KEY] = { accounts[account?.id || UNKNOWN_KEY] = {
balance: 0, balance: 0,
currency: account?.currency, currency: account?.currency,
name: account.name, name: account?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
}; };
} }
@ -2296,7 +2189,7 @@ export class PortfolioService {
platforms[account?.platformId || UNKNOWN_KEY] = { platforms[account?.platformId || UNKNOWN_KEY] = {
balance: 0, balance: 0,
currency: account?.currency, currency: account?.currency,
name: account.platform?.name, name: account?.platform?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
}; };
} }

2
apps/api/src/app/portfolio/rules.service.ts

@ -1,7 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { import {
PortfolioReportRule, PortfolioReportRule,
RuleSettings,
UserSettings UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';

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

@ -5,6 +5,7 @@ import {
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
PROPERTY_STRIPE_CONFIG PROPERTY_STRIPE_CONFIG
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { import {
CreateStripeCheckoutSessionResponse, CreateStripeCheckoutSessionResponse,
@ -14,7 +15,6 @@ import {
SubscriptionOfferKey, SubscriptionOfferKey,
UserWithSettings UserWithSettings
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Subscription } from '@prisma/client'; import { Subscription } from '@prisma/client';
@ -35,7 +35,7 @@ export class SubscriptionService {
this.stripe = new Stripe( this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'), this.configurationService.get('STRIPE_SECRET_KEY'),
{ {
apiVersion: '2025-08-27.basil' apiVersion: '2025-12-15.clover'
} }
); );
} }
@ -100,7 +100,8 @@ export class SubscriptionService {
); );
return { return {
sessionId: session.id sessionId: session.id,
sessionUrl: session.url
}; };
} }
@ -179,6 +180,8 @@ export class SubscriptionService {
offerKey = 'renewal-early-bird-2023'; offerKey = 'renewal-early-bird-2023';
} else if (isBefore(createdAt, parseDate('2024-01-01'))) { } else if (isBefore(createdAt, parseDate('2024-01-01'))) {
offerKey = 'renewal-early-bird-2024'; offerKey = 'renewal-early-bird-2024';
} else if (isBefore(createdAt, parseDate('2025-12-01'))) {
offerKey = 'renewal-early-bird-2025';
} }
const offer = await this.getSubscriptionOffer({ const offer = await this.getSubscriptionOffer({

8
apps/api/src/app/symbol/symbol.controller.ts

@ -1,8 +1,11 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { DataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import {
import { LookupResponse } from '@ghostfolio/common/interfaces'; DataProviderHistoricalResponse,
LookupResponse,
SymbolItem
} from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -22,7 +25,6 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isDate, isEmpty } from 'lodash'; import { isDate, isEmpty } from 'lodash';
import { SymbolItem } from './interfaces/symbol-item.interface';
import { SymbolService } from './symbol.service'; import { SymbolService } from './symbol.service';
@Controller('symbol') @Controller('symbol')

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

@ -1,21 +1,18 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
DataGatheringItem,
DataProviderHistoricalResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
DataProviderHistoricalResponse,
HistoricalDataItem, HistoricalDataItem,
LookupResponse LookupResponse,
SymbolItem
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
import { SymbolItem } from './interfaces/symbol-item.interface';
@Injectable() @Injectable()
export class SymbolService { export class SymbolService {
public constructor( public constructor(

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

@ -3,9 +3,15 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DeleteOwnUserDto,
UpdateOwnAccessTokenDto,
UpdateUserSettingDto
} from '@ghostfolio/common/dtos';
import { import {
AccessTokenResponse, AccessTokenResponse,
User, User,
UserItem,
UserSettings UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -31,10 +37,6 @@ import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { merge, size } from 'lodash'; import { merge, size } from 'lodash';
import { DeleteOwnUserDto } from './delete-own-user.dto';
import { UserItem } from './interfaces/user-item.interface';
import { UpdateOwnAccessTokenDto } from './update-own-access-token.dto';
import { UpdateUserSettingDto } from './update-user-setting.dto';
import { UserService } from './user.service'; import { UserService } from './user.service';
@Controller('user') @Controller('user')

15
apps/api/src/app/user/user.service.ts

@ -248,6 +248,11 @@ export class UserService {
}; };
} }
// Set default value for annual interest rate
if (!(user.settings.settings as UserSettings)?.annualInterestRate) {
(user.settings.settings as UserSettings).annualInterestRate = 5;
}
// Set default value for base currency // Set default value for base currency
if (!(user.settings.settings as UserSettings)?.baseCurrency) { if (!(user.settings.settings as UserSettings)?.baseCurrency) {
(user.settings.settings as UserSettings).baseCurrency = DEFAULT_CURRENCY; (user.settings.settings as UserSettings).baseCurrency = DEFAULT_CURRENCY;
@ -265,11 +270,21 @@ export class UserService {
PerformanceCalculationType.ROAI; PerformanceCalculationType.ROAI;
} }
// Set default value for projected total amount
if (!(user.settings.settings as UserSettings)?.projectedTotalAmount) {
(user.settings.settings as UserSettings).projectedTotalAmount = 0;
}
// Set default value for safe withdrawal rate // Set default value for safe withdrawal rate
if (!(user.settings.settings as UserSettings)?.safeWithdrawalRate) { if (!(user.settings.settings as UserSettings)?.safeWithdrawalRate) {
(user.settings.settings as UserSettings).safeWithdrawalRate = 0.04; (user.settings.settings as UserSettings).safeWithdrawalRate = 0.04;
} }
// Set default value for savings rate
if (!(user.settings.settings as UserSettings)?.savingsRate) {
(user.settings.settings as UserSettings).savingsRate = 0;
}
// Set default value for view mode // Set default value for view mode
if (!(user.settings.settings as UserSettings).viewMode) { if (!(user.settings.settings as UserSettings).viewMode) {
(user.settings.settings as UserSettings).viewMode = 'DEFAULT'; (user.settings.settings as UserSettings).viewMode = 'DEFAULT';

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

File diff suppressed because it is too large

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

@ -0,0 +1,11 @@
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
export class AssetProfileChangedEvent {
public constructor(
public readonly data: AssetProfileIdentifier & { currency: string }
) {}
public static getName(): string {
return 'assetProfile.changed';
}
}

61
apps/api/src/events/asset-profile-changed.listener.ts

@ -0,0 +1,61 @@
import { OrderService } from '@ghostfolio/api/app/order/order.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 { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { AssetProfileChangedEvent } from './asset-profile-changed.event';
@Injectable()
export class AssetProfileChangedListener {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService
) {}
@OnEvent(AssetProfileChangedEvent.getName())
public async handleAssetProfileChanged(event: AssetProfileChangedEvent) {
Logger.log(
`Asset profile of ${event.data.symbol} (${event.data.dataSource}) has changed`,
'AssetProfileChangedListener'
);
if (
this.configurationService.get(
'ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES'
) === false ||
event.data.currency === DEFAULT_CURRENCY
) {
return;
}
const existingCurrencies = this.exchangeRateDataService.getCurrencies();
if (!existingCurrencies.includes(event.data.currency)) {
Logger.log(
`New currency ${event.data.currency} has been detected`,
'AssetProfileChangedListener'
);
await this.exchangeRateDataService.initialize();
}
const { dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(event.data.currency);
if (dateOfFirstActivity) {
await this.dataGatheringService.gatherSymbol({
dataSource: this.dataProviderService.getDataSourceForExchangeRates(),
date: dateOfFirstActivity,
symbol: `${DEFAULT_CURRENCY}${event.data.currency}`
});
}
}
}

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

@ -1,11 +1,24 @@
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AssetProfileChangedListener } from './asset-profile-changed.listener';
import { PortfolioChangedListener } from './portfolio-changed.listener'; import { PortfolioChangedListener } from './portfolio-changed.listener';
@Module({ @Module({
imports: [RedisCacheModule], imports: [
providers: [PortfolioChangedListener] ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
OrderModule,
RedisCacheModule
],
providers: [AssetProfileChangedListener, PortfolioChangedListener]
}) })
export class EventsModule {} export class EventsModule {}

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

@ -1,4 +1,24 @@
import { redactAttributes } from './object.helper'; import { query, redactAttributes } 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', () => { describe('redactAttributes', () => {
it('should redact provided attributes', () => { it('should redact provided attributes', () => {

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

@ -1,4 +1,5 @@
import { Big } from 'big.js'; import { Big } from 'big.js';
import jsonpath from 'jsonpath';
import { cloneDeep, isArray, isObject } from 'lodash'; import { cloneDeep, isArray, isObject } from 'lodash';
export function hasNotDefinedValuesInObject(aObject: Object): boolean { export function hasNotDefinedValuesInObject(aObject: Object): boolean {
@ -31,6 +32,16 @@ export function nullifyValuesInObjects<T>(aObjects: T[], keys: string[]): T[] {
}); });
} }
export function query({
object,
pathExpression
}: {
object: object;
pathExpression: string;
}) {
return jsonpath.query(object, pathExpression);
}
export function redactAttributes({ export function redactAttributes({
isFirstRun = true, isFirstRun = true,
object, object,

7
apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts

@ -16,9 +16,10 @@ import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
@Injectable() @Injectable()
export class RedactValuesInResponseInterceptor<T> export class RedactValuesInResponseInterceptor<T> implements NestInterceptor<
implements NestInterceptor<T, any> T,
{ any
> {
public intercept( public intercept(
context: ExecutionContext, context: ExecutionContext,
next: CallHandler<T> next: CallHandler<T>

33
apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts

@ -11,9 +11,9 @@ import { DataSource } from '@prisma/client';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@Injectable() @Injectable()
export class TransformDataSourceInRequestInterceptor<T> export class TransformDataSourceInRequestInterceptor<
implements NestInterceptor<T, any> T
{ > implements NestInterceptor<T, any> {
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) {} ) {}
@ -27,9 +27,23 @@ export class TransformDataSourceInRequestInterceptor<T>
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (request.body?.activities) { if (request.body?.activities) {
const dataSourceGhostfolioDataProvider = this.configurationService.get(
'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER'
)?.[0];
request.body.activities = request.body.activities.map((activity) => { request.body.activities = request.body.activities.map((activity) => {
if (DataSource[activity.dataSource]) { if (DataSource[activity.dataSource]) {
if (
activity.dataSource === 'GHOSTFOLIO' &&
dataSourceGhostfolioDataProvider
) {
return {
...activity,
dataSource: dataSourceGhostfolioDataProvider
};
} else {
return activity; return activity;
}
} else { } else {
return { return {
...activity, ...activity,
@ -55,6 +69,19 @@ export class TransformDataSourceInRequestInterceptor<T>
}); });
} }
} }
} else {
if (request.body?.activities) {
request.body.activities = request.body.activities.map((activity) => {
if (DataSource[activity.dataSource]) {
return activity;
} else {
return {
...activity,
dataSource: decodeDataSource(activity.dataSource)
};
}
});
}
} }
return next.handle(); return next.handle();

60
apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts

@ -13,39 +13,59 @@ import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
@Injectable() @Injectable()
export class TransformDataSourceInResponseInterceptor<T> export class TransformDataSourceInResponseInterceptor<
implements NestInterceptor<T, any> T
{ > implements NestInterceptor<T, any> {
private encodedDataSourceMap: {
[dataSource: string]: string;
} = {};
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) {} ) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
this.encodedDataSourceMap = Object.keys(DataSource).reduce(
(encodedDataSourceMap, dataSource) => {
if (!['GHOSTFOLIO', 'MANUAL'].includes(dataSource)) {
encodedDataSourceMap[dataSource] = encodeDataSource(
DataSource[dataSource]
);
}
return encodedDataSourceMap;
},
{}
);
}
}
public intercept( public intercept(
_context: ExecutionContext, context: ExecutionContext,
next: CallHandler<T> next: CallHandler<T>
): Observable<any> { ): Observable<any> {
const isExportMode = context.getClass().name === 'ExportController';
return next.handle().pipe( return next.handle().pipe(
map((data: any) => { map((data: any) => {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
const valueMap = this.encodedDataSourceMap;
if (isExportMode) {
for (const dataSource of this.configurationService.get(
'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER'
)) {
valueMap[dataSource] = 'GHOSTFOLIO';
}
}
data = redactAttributes({ data = redactAttributes({
object: data,
options: [ options: [
{ {
attribute: 'dataSource', valueMap,
valueMap: Object.keys(DataSource).reduce( attribute: 'dataSource'
(valueMap, dataSource) => {
if (!['MANUAL'].includes(dataSource)) {
valueMap[dataSource] = encodeDataSource(
DataSource[dataSource]
);
}
return valueMap;
},
{}
)
} }
], ]
object: data
}); });
} }

4
apps/api/src/middlewares/html-template.middleware.ts

@ -79,6 +79,10 @@ const locales = {
'/en/blog/2025/09/hacktoberfest-2025': { '/en/blog/2025/09/hacktoberfest-2025': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2025.png', featureGraphicPath: 'assets/images/blog/hacktoberfest-2025.png',
title: `Hacktoberfest 2025 - ${title}` title: `Hacktoberfest 2025 - ${title}`
},
'/en/blog/2025/11/black-weeks-2025': {
featureGraphicPath: 'assets/images/blog/black-weeks-2025.jpg',
title: `Black Weeks 2025 - ${title}`
} }
}; };

3
apps/api/src/models/interfaces/rule.interface.ts

@ -1,5 +1,4 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { EvaluationResult } from './evaluation-result.interface'; import { EvaluationResult } from './evaluation-result.interface';

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

@ -1,10 +1,10 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { groupBy } from '@ghostfolio/common/helper'; import { groupBy } from '@ghostfolio/common/helper';
import { import {
PortfolioPosition, PortfolioPosition,
PortfolioReportRule, PortfolioReportRule,
RuleSettings,
UserSettings UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';

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

@ -1,8 +1,11 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces'; import {
PortfolioDetails,
RuleSettings,
UserSettings
} from '@ghostfolio/common/interfaces';
import { Account } from '@prisma/client'; import { Account } from '@prisma/client';

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

@ -1,8 +1,11 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces'; import {
PortfolioDetails,
RuleSettings,
UserSettings
} from '@ghostfolio/common/interfaces';
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> { export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
private accounts: PortfolioDetails['accounts']; private accounts: PortfolioDetails['accounts'];

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

@ -1,8 +1,11 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces'; import {
PortfolioPosition,
RuleSettings,
UserSettings
} from '@ghostfolio/common/interfaces';
export class AssetClassClusterRiskEquity extends Rule<Settings> { export class AssetClassClusterRiskEquity extends Rule<Settings> {
private holdings: PortfolioPosition[]; private holdings: PortfolioPosition[];

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

@ -1,8 +1,11 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces'; import {
PortfolioPosition,
RuleSettings,
UserSettings
} from '@ghostfolio/common/interfaces';
export class AssetClassClusterRiskFixedIncome extends Rule<Settings> { export class AssetClassClusterRiskFixedIncome extends Rule<Settings> {
private holdings: PortfolioPosition[]; private holdings: PortfolioPosition[];

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

@ -1,8 +1,11 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces'; import {
PortfolioPosition,
RuleSettings,
UserSettings
} from '@ghostfolio/common/interfaces';
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> { export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
private holdings: PortfolioPosition[]; private holdings: PortfolioPosition[];

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

@ -1,8 +1,11 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces'; import {
PortfolioPosition,
RuleSettings,
UserSettings
} from '@ghostfolio/common/interfaces';
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> { export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
private holdings: PortfolioPosition[]; private holdings: PortfolioPosition[];

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

@ -1,8 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { UserSettings } from '@ghostfolio/common/interfaces'; import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces';
export class EconomicMarketClusterRiskDevelopedMarkets extends Rule<Settings> { export class EconomicMarketClusterRiskDevelopedMarkets extends Rule<Settings> {
private currentValueInBaseCurrency: number; private currentValueInBaseCurrency: number;

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

@ -1,8 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { UserSettings } from '@ghostfolio/common/interfaces'; import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces';
export class EconomicMarketClusterRiskEmergingMarkets extends Rule<Settings> { export class EconomicMarketClusterRiskEmergingMarkets extends Rule<Settings> {
private currentValueInBaseCurrency: number; private currentValueInBaseCurrency: number;

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

@ -1,8 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { UserSettings } from '@ghostfolio/common/interfaces'; import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces';
export class EmergencyFundSetup extends Rule<Settings> { export class EmergencyFundSetup extends Rule<Settings> {
private emergencyFund: number; private emergencyFund: number;

3
apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts

@ -1,8 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { UserSettings } from '@ghostfolio/common/interfaces'; import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces';
export class FeeRatioInitialInvestment extends Rule<Settings> { export class FeeRatioInitialInvestment extends Rule<Settings> {
private fees: number; private fees: number;

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

@ -1,8 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { UserSettings } from '@ghostfolio/common/interfaces'; import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces';
export class BuyingPower extends Rule<Settings> { export class BuyingPower extends Rule<Settings> {
private buyingPower: number; private buyingPower: number;

2
apps/api/src/models/rules/regional-market-cluster-risk/interfaces/rule-settings.interface.ts

@ -1,4 +1,4 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/common/interfaces';
export interface Settings extends RuleSettings { export interface Settings extends RuleSettings {
baseCurrency: string; baseCurrency: string;

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

@ -40,9 +40,12 @@ export class ConfigurationService {
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: json({ DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: json({
default: [] default: []
}), }),
ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }),
ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }),
ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: 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 }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }), ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
@ -52,9 +55,32 @@ export class ConfigurationService {
GOOGLE_SHEETS_ID: str({ default: '' }), GOOGLE_SHEETS_ID: str({ default: '' }),
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }), GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
HOST: host({ default: DEFAULT_HOST }), HOST: host({ default: DEFAULT_HOST }),
JWT_SECRET_KEY: str({}), JWT_SECRET_KEY: str(),
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_CHART_ITEMS: num({ default: 365 }), MAX_CHART_ITEMS: num({ default: 365 }),
OIDC_AUTHORIZATION_URL: str({ default: '' }),
OIDC_CALLBACK_URL: str({ default: '' }),
OIDC_CLIENT_ID: str({
default: undefined,
requiredWhen: (env) => {
return env.ENABLE_FEATURE_AUTH_OIDC === true;
}
}),
OIDC_CLIENT_SECRET: str({
default: undefined,
requiredWhen: (env) => {
return env.ENABLE_FEATURE_AUTH_OIDC === true;
}
}),
OIDC_ISSUER: str({
default: undefined,
requiredWhen: (env) => {
return env.ENABLE_FEATURE_AUTH_OIDC === true;
}
}),
OIDC_SCOPE: json({ default: ['openid'] }),
OIDC_TOKEN_URL: str({ default: '' }),
OIDC_USER_INFO_URL: str({ default: '' }),
PORT: port({ default: DEFAULT_PORT }), PORT: port({ default: DEFAULT_PORT }),
PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: num({ PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY default: DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY
@ -76,7 +102,6 @@ export class ConfigurationService {
ROOT_URL: url({ ROOT_URL: url({
default: environment.rootUrl default: environment.rootUrl
}), }),
STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }),
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),
TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }), TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }),

4
apps/api/src/services/cron/cron.service.ts

@ -59,7 +59,9 @@ export class CronService {
public async runEverySundayAtTwelvePm() { public async runEverySundayAtTwelvePm() {
if (await this.isDataGatheringEnabled()) { if (await this.isDataGatheringEnabled()) {
const assetProfileIdentifiers = const assetProfileIdentifiers =
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers(); await this.dataGatheringService.getActiveAssetProfileIdentifiers({
maxAge: '60 days'
});
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {

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

@ -7,20 +7,18 @@ import {
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
DataProviderHistoricalResponse,
DataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
DataProviderHistoricalResponse,
DataProviderInfo, DataProviderInfo,
DataProviderResponse,
LookupResponse LookupResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import * as Alphavantage from 'alphavantage'; import Alphavantage from 'alphavantage';
import { format, isAfter, isBefore, parse } from 'date-fns'; import { format, isAfter, isBefore, parse } from 'date-fns';
import { AlphaVantageHistoricalResponse } from './interfaces/interfaces'; import { AlphaVantageHistoricalResponse } from './interfaces/interfaces';

38
apps/api/src/services/data-provider/coingecko/coingecko.service.ts

@ -7,14 +7,12 @@ import {
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
DataProviderHistoricalResponse,
DataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
DataProviderHistoricalResponse,
DataProviderInfo, DataProviderInfo,
DataProviderResponse,
LookupItem, LookupItem,
LookupResponse LookupResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -112,12 +110,14 @@ export class CoinGeckoService implements DataProviderInterface {
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [symbol: string]: { [date: string]: DataProviderHistoricalResponse };
}> { }> {
try { try {
const queryParams = new URLSearchParams({
from: getUnixTime(from).toString(),
to: getUnixTime(to).toString(),
vs_currency: DEFAULT_CURRENCY.toLowerCase()
});
const { error, prices, status } = await fetch( const { error, prices, status } = await fetch(
`${ `${this.apiUrl}/coins/${symbol}/market_chart/range?${queryParams.toString()}`,
this.apiUrl
}/coins/${symbol}/market_chart/range?vs_currency=${DEFAULT_CURRENCY.toLowerCase()}&from=${getUnixTime(
from
)}&to=${getUnixTime(to)}`,
{ {
headers: this.headers, headers: this.headers,
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
@ -174,10 +174,13 @@ export class CoinGeckoService implements DataProviderInterface {
} }
try { try {
const queryParams = new URLSearchParams({
ids: symbols.join(','),
vs_currencies: DEFAULT_CURRENCY.toLowerCase()
});
const quotes = await fetch( const quotes = await fetch(
`${this.apiUrl}/simple/price?ids=${symbols.join( `${this.apiUrl}/simple/price?${queryParams.toString()}`,
','
)}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`,
{ {
headers: this.headers, headers: this.headers,
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
@ -221,10 +224,17 @@ export class CoinGeckoService implements DataProviderInterface {
let items: LookupItem[] = []; let items: LookupItem[] = [];
try { try {
const { coins } = await fetch(`${this.apiUrl}/search?query=${query}`, { const queryParams = new URLSearchParams({
query
});
const { coins } = await fetch(
`${this.apiUrl}/search?${queryParams.toString()}`,
{
headers: this.headers, headers: this.headers,
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
}).then((res) => res.json()); }
).then((res) => res.json());
items = coins.map(({ id: symbol, name }) => { items = coins.map(({ id: symbol, name }) => {
return { return {

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

@ -317,7 +317,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
return { assetClass, assetSubClass }; return { assetClass, assetSubClass };
} }
private parseSector(aString: string): string { private parseSector(aString: string) {
let sector = UNKNOWN_KEY; let sector = UNKNOWN_KEY;
switch (aString) { switch (aString) {

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

@ -1,10 +1,6 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
DataProviderHistoricalResponse,
DataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
@ -23,6 +19,8 @@ import {
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
DataProviderHistoricalResponse,
DataProviderResponse,
LookupItem, LookupItem,
LookupResponse LookupResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';

51
apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts

@ -7,10 +7,6 @@ import {
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
DataProviderHistoricalResponse,
DataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
@ -18,7 +14,9 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { import {
DataProviderHistoricalResponse,
DataProviderInfo, DataProviderInfo,
DataProviderResponse,
LookupItem, LookupItem,
LookupResponse LookupResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -98,17 +96,19 @@ export class EodHistoricalDataService implements DataProviderInterface {
} }
try { try {
const queryParams = new URLSearchParams({
api_token: this.apiKey,
fmt: 'json',
from: format(from, DATE_FORMAT),
to: format(to, DATE_FORMAT)
});
const response: { const response: {
[date: string]: DataProviderHistoricalResponse; [date: string]: DataProviderHistoricalResponse;
} = {}; } = {};
const historicalResult = await fetch( const historicalResult = await fetch(
`${this.URL}/div/${symbol}?api_token=${ `${this.URL}/div/${symbol}?${queryParams.toString()}`,
this.apiKey
}&fmt=json&from=${format(from, DATE_FORMAT)}&to=${format(
to,
DATE_FORMAT
)}`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
@ -146,13 +146,16 @@ export class EodHistoricalDataService implements DataProviderInterface {
symbol = this.convertToEodSymbol(symbol); symbol = this.convertToEodSymbol(symbol);
try { try {
const queryParams = new URLSearchParams({
api_token: this.apiKey,
fmt: 'json',
from: format(from, DATE_FORMAT),
period: granularity,
to: format(to, DATE_FORMAT)
});
const response = await fetch( const response = await fetch(
`${this.URL}/eod/${symbol}?api_token=${ `${this.URL}/eod/${symbol}?${queryParams.toString()}`,
this.apiKey
}&fmt=json&from=${format(from, DATE_FORMAT)}&to=${format(
to,
DATE_FORMAT
)}&period=${granularity}`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
@ -210,10 +213,14 @@ export class EodHistoricalDataService implements DataProviderInterface {
}); });
try { try {
const queryParams = new URLSearchParams({
api_token: this.apiKey,
fmt: 'json',
s: eodHistoricalDataSymbols.join(',')
});
const realTimeResponse = await fetch( const realTimeResponse = await fetch(
`${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?api_token=${ `${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?${queryParams.toString()}`,
this.apiKey
}&fmt=json&s=${eodHistoricalDataSymbols.join(',')}`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
@ -415,8 +422,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
})[] = []; })[] = [];
try { try {
const queryParams = new URLSearchParams({
api_token: this.apiKey
});
const response = await fetch( const response = await fetch(
`${this.URL}/search/${query}?api_token=${this.apiKey}`, `${this.URL}/search/${query}?${queryParams.toString()}`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }

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

Loading…
Cancel
Save