Browse Source

Merge branch 'main' into feature/migrate-from-angular-material-design-2-to-3

pull/6163/head
Thomas Kaul 4 days ago
parent
commit
fd7db87486
  1. 14
      .config/prisma.ts
  2. 3
      .github/FUNDING.yml
  3. 4
      .github/workflows/extract-locales.yml
  4. 4
      .vscode/extensions.json
  5. 2
      .vscode/settings.json
  6. 587
      CHANGELOG.md
  7. 22
      Dockerfile
  8. 70
      README.md
  9. 60
      apps/api/src/app/access/access.controller.ts
  10. 19
      apps/api/src/app/access/access.service.ts
  11. 2
      apps/api/src/app/account-balance/account-balance.controller.ts
  12. 3
      apps/api/src/app/account-balance/account-balance.service.ts
  13. 16
      apps/api/src/app/account/account.controller.ts
  14. 42
      apps/api/src/app/admin/admin.controller.ts
  15. 108
      apps/api/src/app/admin/admin.service.ts
  16. 2
      apps/api/src/app/admin/queue/queue.service.ts
  17. 8
      apps/api/src/app/app.module.ts
  18. 4
      apps/api/src/app/asset/asset.controller.ts
  19. 61
      apps/api/src/app/auth/auth.controller.ts
  20. 82
      apps/api/src/app/auth/auth.module.ts
  21. 65
      apps/api/src/app/auth/auth.service.ts
  22. 3
      apps/api/src/app/auth/google.strategy.ts
  23. 21
      apps/api/src/app/auth/interfaces/interfaces.ts
  24. 114
      apps/api/src/app/auth/oidc-state.store.ts
  25. 69
      apps/api/src/app/auth/oidc.strategy.ts
  26. 11
      apps/api/src/app/auth/web-auth.service.ts
  27. 113
      apps/api/src/app/endpoints/ai/ai.service.ts
  28. 4
      apps/api/src/app/endpoints/assets/assets.controller.ts
  29. 4
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  30. 4
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  31. 10
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  32. 53
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  33. 3
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  34. 24
      apps/api/src/app/endpoints/market-data/update-bulk-market-data.dto.ts
  35. 22
      apps/api/src/app/endpoints/platforms/platforms.controller.ts
  36. 11
      apps/api/src/app/endpoints/platforms/platforms.module.ts
  37. 49
      apps/api/src/app/endpoints/public/public.controller.ts
  38. 5
      apps/api/src/app/endpoints/sitemap/sitemap.controller.ts
  39. 142
      apps/api/src/app/endpoints/sitemap/sitemap.service.ts
  40. 4
      apps/api/src/app/endpoints/tags/tags.controller.ts
  41. 2
      apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
  42. 4
      apps/api/src/app/exchange-rate/exchange-rate.controller.ts
  43. 10
      apps/api/src/app/export/export.controller.ts
  44. 21
      apps/api/src/app/export/export.service.ts
  45. 11
      apps/api/src/app/import/import-data.dto.ts
  46. 1
      apps/api/src/app/import/import.controller.ts
  47. 2
      apps/api/src/app/import/import.module.ts
  48. 197
      apps/api/src/app/import/import.service.ts
  49. 4
      apps/api/src/app/info/info.controller.ts
  50. 17
      apps/api/src/app/info/info.service.ts
  51. 13
      apps/api/src/app/order/order.controller.ts
  52. 195
      apps/api/src/app/order/order.service.ts
  53. 5
      apps/api/src/app/platform/platform.controller.ts
  54. 8
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  55. 7
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  56. 91
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  57. 205
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  58. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  59. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  60. 15
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  61. 150
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts
  62. 61
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  63. 6
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  64. 126
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  65. 61
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  66. 290
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  67. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  68. 6
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  69. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  70. 144
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts
  71. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  72. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts
  73. 50
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  74. 50
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  75. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  76. 13
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  77. 4
      apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts
  78. 4
      apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts
  79. 3
      apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts
  80. 81
      apps/api/src/app/portfolio/portfolio.controller.ts
  81. 904
      apps/api/src/app/portfolio/portfolio.service.ts
  82. 4
      apps/api/src/app/portfolio/rules.service.ts
  83. 2
      apps/api/src/app/redis-cache/redis-cache.service.ts
  84. 11
      apps/api/src/app/subscription/subscription.controller.ts
  85. 71
      apps/api/src/app/subscription/subscription.service.ts
  86. 10
      apps/api/src/app/symbol/symbol.controller.ts
  87. 17
      apps/api/src/app/symbol/symbol.service.ts
  88. 16
      apps/api/src/app/user/user.controller.ts
  89. 58
      apps/api/src/app/user/user.service.ts
  90. 1084
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  91. 1
      apps/api/src/assets/sitemap.xml
  92. 3
      apps/api/src/dependencies.ts
  93. 11
      apps/api/src/events/asset-profile-changed.event.ts
  94. 61
      apps/api/src/events/asset-profile-changed.listener.ts
  95. 17
      apps/api/src/events/events.module.ts
  96. 26
      apps/api/src/helper/object.helper.spec.ts
  97. 11
      apps/api/src/helper/object.helper.ts
  98. 2
      apps/api/src/helper/string.helper.ts
  99. 8
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  100. 35
      apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts

14
.config/prisma.ts

@ -0,0 +1,14 @@
import { defineConfig } from '@prisma/config';
import { config } from 'dotenv';
import { expand } from 'dotenv-expand';
import { join } from 'node:path';
expand(config({ quiet: true }));
export default defineConfig({
migrations: {
path: join(__dirname, '..', 'prisma', 'migrations'),
seed: `node ${join(__dirname, '..', 'prisma', 'seed.mts')}`
},
schema: join(__dirname, '..', 'prisma', 'schema.prisma')
});

3
.github/FUNDING.yml

@ -1 +1,2 @@
custom: ['https://www.buymeacoffee.com/ghostfolio']
buy_me_a_coffee: ghostfolio
github: ghostfolio

4
.github/workflows/extract-locales.yml

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

4
.vscode/extensions.json

@ -1,8 +1,8 @@
{
"recommendations": [
"angular.ng-template",
"esbenp.prettier-vscode",
"firsttris.vscode-jest-runner",
"nrwl.angular-console"
"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
}

587
CHANGELOG.md

@ -15,14 +15,599 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- 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
### Fixed
- Fixed the filtering by asset class in the endpoint `GET api/v1/portfolio/holdings`
## 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 the endpoint `GET /api/v1/admin/user/:id`
### Changed
- Improved the _Self-Hosting_ section content for the _Compare with..._ concept on the Frequently Asked Questions (FAQ) page
- Improved the _Self-Hosting_ section content for the _Markets_ concept on the Frequently Asked Questions (FAQ) page
- Changed the build executor of the client from `@nx/angular:webpack-browser` to `@nx/angular:browser-esbuild`
- Refactored the app component to standalone
- Improved the language localization for German (`de`)
- Upgraded `@ionic/angular` from version `8.7.3` to `8.7.8`
### Fixed
- Fixed the style of the safe withdrawal rate selector in the _FIRE_ section (experimental)
- Assigned the `ADMIN` role to the first user signing up via a social login provider if no administrator existed
- Improved the table headers’ alignment in the platform management of the admin control panel
- Improved the table headers’ alignment in the tag management of the admin control panel
## 2.214.0 - 2025-11-01
### Changed
- Improved the icon of the _View Holding_ menu item in the activities table
- Ensured atomic data replacement during historical market data gathering
- Removed _Internet Identity_ as a social login provider
- Refreshed the cryptocurrencies list
- Upgraded `countries-list` from version `3.1.1` to `3.2.0`
- Upgraded `ng-extract-i18n-merge` from version `3.0.0` to `3.1.0`
- Upgraded `twitter-api-v2` from version `1.23.0` to `1.27.0`
## 2.213.0 - 2025-10-30
### Added
- Extended the activities table menu with a _View Holding_ item
- Added the error logging to the symbol lookup in the _Trackinsight_ data enhancer
### Changed
- Improved the icon of the holdings tab on the home page
- Improved the icon of the holdings tab on the home page for the _Zen Mode_
- Improved the icon of the holdings tab in the account detail dialog
- Migrated the tags selector component in the holding detail dialog to form control
- Improved the language localization for German (`de`)
- Upgraded `nestjs` from version `11.1.3` to `11.1.8`
## 2.212.0 - 2025-10-29
### Added
- Added a close holding button to the holding detail dialog
- Added the _Sponsors_ section to the about page
- Extended the user detail dialog in the users section of the admin control panel
### Changed
- Refactored the generation of the holdings table in the _Copy AI prompt to clipboard for analysis_ action on the analysis page (experimental)
- Refactored the generation of the holdings table in the _Copy portfolio data to clipboard for AI prompt_ action on the analysis page (experimental)
- Improved the usability of the user detail dialog in the users section of the admin control panel
- Improved the language localization for German (`de`)
### Fixed
- Ensured the locale is available in the settings dialog to customize the rule thresholds of the _X-ray_ page
## 2.211.0 - 2025-10-25
### Added
- Extended the export functionality by the user account’s performance calculation type
- Added the _SelfhostedHub_ logo to the logo carousel on the landing page
- Added a user detail dialog to the users section of the admin control panel
### Changed
- Localized the number formatting in the static portfolio analysis rule: _Liquidity_ (Buying Power)
- Moved the _Prisma Configuration File_ from `prisma.config.ts` to `.config/prisma.ts`
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `6.17.1` to `6.18.0`
- Upgraded `tablemark` from version `3.1.0` to `4.1.0`
### Fixed
- Fixed the style in the footer row of the accounts table
- Fixed the rendering of names and symbols for custom assets in the import activities dialog
- Fixed an issue with the market price in base currency during the portfolio snapshot calculation
## 2.210.1 - 2025-10-22
### Added
- Added support for data gathering by date range in the asset profile details dialog of the admin control panel
### Changed
- Extracted the portfolio filter form of the assistant to a reusable component
- Formatted the holdings table in the _Copy AI prompt to clipboard for analysis_ action on the analysis page (experimental)
- Formatted the holdings table in the _Copy portfolio data to clipboard for AI prompt_ action on the analysis page (experimental)
- Reverted the explicit configuration of the _Redis_ address family in the job queue module
- Improved the language localization for German (`de`)
- Upgraded `ioredis` from version `5.6.1` to `5.8.2`
### Fixed
- Fixed the enter key press to submit the form of the login with access token dialog
- Fixed an issue in the database seeding process caused by unresolved environment variables in `DATABASE_URL`
## 2.209.0 - 2025-10-18
### Added
- Extended the glossary of the resources page by _Stealth Wealth_
- Extended the content of the pricing page
- Added a _Storybook_ story for the holdings table component
### Changed
- Disabled the zoom functionality in the _Progressive Web App_ (PWA)
- Improved the currency validation in the get asset profiles functionality of the data provider service
- Improved the currency validation in the search functionality of the data provider service
- Optimized the get quotes functionality by utilizing the asset profile resolutions in the _Financial Modeling Prep_ service
- Extracted the footer to a component
- Refactored the blog page component to standalone
- Improved the portfolio calculator unit tests to load the user currency from the exported file
- Improved the language localization for German (`de`)
### Fixed
- Fixed an issue in the `csv` file import where custom asset profiles failed due to validation errors
- Fixed an issue with the total buy and sell calculation in the summary related to activities in a custom currency
- Respected the include indices flag in the search functionality of the _Financial Modeling Prep_ service
- Fixed an issue where the scroll position was not restored when changing pages
- Fixed the word wrap in the menus of the activities table component
- Fixed the dark mode in the _As seen in_ section on the landing page
## 2.208.0 - 2025-10-11
### Added
- Added support for configuring the safe withdrawal rate in the _FIRE_ section (experimental)
### Changed
- Changed the _As seen in_ section on the landing page to an animated carousel
- Refactored `transactionCount` to `activitiesCount` in the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Refactored various components to use self-closing tags
- Removed the deprecated endpoint `GET api/v1/portfolio/position/:dataSource/:symbol`
- Removed the deprecated endpoint `PUT api/v1/portfolio/position/:dataSource/:symbol/tags`
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `6.16.1` to `6.17.1`
### Fixed
- Fixed the server startup message to properly display IPv6 addresses
- Enabled IPv6 connectivity for _Redis_ in the job queue module by setting the address family
- Fixed an issue where importing custom asset profiles failed due to validation errors
## 2.207.0 - 2025-10-08
### Added
- Added support to edit a granted access (experimental)
- Introduced tabs to the asset profile details dialog in the admin control panel
- Added support for a date range query parameter in the data gathering endpoint
- Added a _Storybook_ story for the activities table component
### Changed
- Improved the spacing around the buttons in the holding detail dialog
- Extended the _Storybook_ stories of the accounts table component by a loading state story
- Refactored the auth page to standalone
- Improved the language localization for German (`de`)
### Fixed
- Fixed the word wrap in the menu of the access table component
- Fixed the word wrap in the menu of the activities table component
- Fixed the word wrap in the menu of the asset profile details dialog in the admin control panel
## 2.206.0 - 2025-10-04
### Changed
- Localized the number formatting in the settings dialog to customize the rule thresholds of the _X-ray_ page
- Improved the usability of the assistant by preselecting the first search result
- Improved the usability of the _Cancel_ / _Close_ buttons in the create watchlist item dialog
- Refactored the `fireWealth` from `number` type to a structured object in the summary of the portfolio details endpoint
- Refactored the _Open Startup_ (`/open`) page to standalone
- Refactored the file drop directive to standalone
- Refactored the symbol pipe to standalone
### Fixed
- Handled an exception in the get asset profile functionality of the _Financial Modeling Prep_ service
- Added the missing `CommonModule` import in the import activities dialog
## 2.205.0 - 2025-10-01
### Changed
- Restricted the selection of the retirement date picker in the _FIRE_ calculator to a future date
- Improved the support for mutual funds in the _Financial Modeling Prep_ service (get asset profiles)
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `6.16.1` to `6.16.3`
## 2.204.0 - 2025-09-30
### Added
- Added the safe withdrawal rate to the user settings (experimental)
### Changed
- Improved the number formatting of the y-axis labels in the investment chart component
- Localized the number formatting of the y-axis labels in the line chart component
- Improved the wording of the 4% rule in the _FIRE_ section
- Improved the usability of the create asset profile dialog in the market data section of the admin control panel
- Improved the language localization for German (`de`)
### Fixed
- Improved the table headers’ alignment of the activities table
## 2.203.0 - 2025-09-27
### Added
- Added support for column sorting to the queue jobs table in the admin control panel
- Added a blog post: _Hacktoberfest 2025_
### Changed
- Removed the deprecated `ITEM` activity type
## 2.202.0 - 2025-09-26
### Added
- Added `settings` to the `Access` model
### Changed
- Extended the tags selector component to support form control
- Changed the deprecated `ITEM` activity type to `VALUABLE` in the create or update activity dialog
### Fixed
- Fixed an issue where the save button was not enabled after editing tags in the create or update activity dialog
- Fixed an issue in the investment calculation when selling all units of a holding
## 2.201.0 - 2025-09-24
### Added
- Added the symbol to the benchmark component
- Added the latest activities to the public page (experimental)
- Added pagination to the activities table of the activities import dialog
- Added an option to configure the account column of the activities table component
### Changed
- Hid the account column from the activities table of the account detail dialog to avoid redundant information
- Renamed the show access token dialog component to user account registration dialog component
- Refreshed the cryptocurrencies list
- Improved the language localization for German (`de`)
- Upgraded `countup.js` from version `2.8.2` to `2.9.0`
### Fixed
- Fixed an issue with `unitPriceInAssetProfileCurrency` in the value redaction interceptor for the impersonation mode
## 2.200.0 - 2025-09-17
### Changed
- Refactored the show access token dialog component to standalone
- Upgraded `prisma` from version `6.15.0` to `6.16.1`
### Fixed
- Removed a temporary element from the activities table component
## 2.199.0 - 2025-09-14
### Added
- Extended the content of the performance calculation method by dividends on the Frequently Asked Questions (FAQ) page
- Added a _Storybook_ story for the entity logo image component
### Changed
- Improved the search in the _Yahoo Finance_ service
- Moved the holdings table into the holdings section on the public page
- Migrated to the _Prisma Configuration File_ approach (`prisma.config.ts`)
- Refactored the login with access token dialog component to standalone
- Prefixed the `crypto`, `fs` and `path` imports with `node:`
- Upgraded `yahoo-finance2` from version `3.8.0` to `3.10.0`
### Fixed
- Fixed a pagination issue in the market data endpoint by adding `id` as a secondary sort criterion to ensure consistent ordering in the admin control panel
- Fixed a pagination issue in the user endpoint by adding `id` as a secondary sort criterion to ensure consistent ordering in the admin control panel
## 2.198.0 - 2025-09-11
### Changed
- Extended the variations of the interstitials for the subscription
- Renamed the job identifier column in the jobs queue view of the admin control panel
- Refactored the markets page to standalone
- Refactored the fear and greed index component to standalone
- Refactored the header component to standalone
- Refactored the investment chart component to standalone
- Refactored the rule component to standalone
- Refactored the rules component to standalone
- Refactored the subscription interstitial dialog component to standalone
- Removed the `IonIcon` import from the landing page
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `20.1.3` to `20.2.4`
- Upgraded `eslint` dependencies
- Upgraded `Nx` from version `21.3.9` to `21.5.1`
- Upgraded `storybook` from version `9.0.17` to `9.1.5`
### Fixed
- Fixed the holdings table on the public page
## 2.197.0 - 2025-09-07
### Added
- Enabled automatic data gathering for custom currencies added via the currency management in the admin control panel
- Added a _Storybook_ story for the world map chart component
### Changed
- Restructured the response of the portfolio report endpoint (_X-ray_)
- Modernized the templates with untagged template literals
- Moved the world map chart component to `@ghostfolio/ui`
- Refactored the create or update access dialog component to standalone
- Improved the language localization for German (`de`)
- Upgraded `envalid` from version `8.0.0` to `8.1.0`
- Upgraded `prisma` from version `6.14.0` to `6.15.0`
### Fixed
- Improved the handling of `0` buying power in the static portfolio analysis rule: _Liquidity_ (Buying Power)
- Fixed an issue related to the error handling in the data provider status component
## 2.196.0 - 2025-09-04
### Changed
- Localized the content of the about page
- Refactored the public page to standalone
- Refactored the dialog footer component
- Refactored the dialog header component
- Refactored the account detail dialog component to standalone
- Refactored the benchmark comparator component to standalone
- Refactored the portfolio summary component to standalone
- Refactored the world map chart component to standalone
- Enabled the trim option in the `extract-i18n` configuration
- Improved the language localization for German (`de`)
- Upgraded the _Stripe_ dependencies
- Upgraded `ngx-device-detector` from version `10.0.2` to `10.1.0`
- Upgraded `ngx-skeleton-loader` from version `11.2.1` to `11.3.0`
- Upgraded `yahoo-finance2` from version `3.6.4` to `3.8.0`
### Fixed
- Fixed an issue in the average price calculation for buy and sell activities of short positions
- Fixed the number of attempts in the queue jobs view of the admin control panel
## 2.195.0 - 2025-08-29
### Changed
@ -1804,7 +2389,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- 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
## 2.95.0 - 2024-07-12

22
Dockerfile

@ -13,20 +13,17 @@ RUN apt-get update && apt-get install -y --no-install-suggests \
# Only add basic files without the application itself to avoid rebuilding
# layers when files (package.json etc.) have not changed
COPY ./.config .config/
COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE
COPY ./package.json package.json
COPY ./package-lock.json package-lock.json
COPY ./prisma/schema.prisma prisma/schema.prisma
COPY ./prisma/schema.prisma prisma/
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 ./libs libs
COPY ./apps apps/
COPY ./libs libs/
COPY ./jest.config.ts jest.config.ts
COPY ./jest.preset.js jest.preset.js
COPY ./nx.json nx.json
@ -40,14 +37,15 @@ RUN npm run build:production
WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original
# package-lock.json needs to be used to ensure the same versions
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
COPY ./package-lock.json /ghostfolio/dist/apps/api/
RUN npm install
COPY prisma /ghostfolio/dist/apps/api/prisma
COPY .config /ghostfolio/dist/apps/api/.config/
COPY prisma /ghostfolio/dist/apps/api/prisma/
# Overwrite the generated package.json with the original one to ensure having
# all the scripts
COPY package.json /ghostfolio/dist/apps/api
COPY package.json /ghostfolio/dist/apps/api/
RUN npm run database:generate-typings
# Image to run, copy everything needed from builder
@ -60,8 +58,8 @@ RUN apt-get update && apt-get install -y --no-install-suggests \
openssl \
&& rm -rf /var/lib/apt/lists/*
COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps
COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps/
COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/
WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333}
USER node

70
README.md

@ -85,24 +85,39 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables
| Name | Type | Default Value | Description |
| ------------------------ | --------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
| Name | Type | Default Value | Description |
| ------------------------ | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. |
#### 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
@ -226,7 +241,7 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
| `accountId` | `string` (optional) | Id of the account |
| `comment` | `string` (optional) | Comment of the activity |
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` \| `YAHOO` |
| `dataSource` | `string` | `COINGECKO` \| `GHOSTFOLIO` [^1] \| `MANUAL` \| `YAHOO` |
| `date` | `string` | Date in the format `ISO-8601` |
| `fee` | `number` | Fee of the activity |
| `quantity` | `number` | Quantity of the activity |
@ -297,7 +312,18 @@ Ghostfolio is **100% free** and **open source**. We encourage and support an act
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you.
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
If you like to support this project, become a [**Sponsor**](https://github.com/sponsors/ghostfolio), get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
## Sponsors
<div align="center">
<p>
Browser testing via<br />
<a href="https://www.lambdatest.com?utm_medium=sponsor&utm_source=ghostfolio" target="_blank" title="LambdaTest - AI Powered Testing Tool">
<img alt="LambdaTest Logo" height="45" width="250" src="https://www.lambdatest.com/blue-logo.png" />
</a>
</p>
</div>
## Analytics
@ -305,6 +331,8 @@ If you like to support this project, get [**Ghostfolio Premium**](https://ghostf
## License
© 2021 - 2025 [Ghostfolio](https://ghostfol.io)
© 2021 - 2026 [Ghostfolio](https://ghostfol.io)
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
[^1]: Available with [**Ghostfolio Premium**](https://ghostfol.io/en/pricing).

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

@ -1,6 +1,7 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { Access } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -14,6 +15,7 @@ import {
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
@ -22,7 +24,6 @@ import { Access as AccessModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessService } from './access.service';
import { CreateAccessDto } from './create-access.dto';
@Controller('access')
export class AccessController {
@ -39,7 +40,7 @@ export class AccessController {
include: {
granteeUser: true
},
orderBy: { granteeUserId: 'asc' },
orderBy: [{ granteeUserId: 'desc' }, { createdAt: 'asc' }],
where: { userId: this.request.user.id }
});
@ -103,9 +104,12 @@ export class AccessController {
@HasPermission(permissions.deleteAccess)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
const access = await this.accessService.access({ id });
const originalAccess = await this.accessService.access({
id,
userId: this.request.user.id
});
if (!access || access.userId !== this.request.user.id) {
if (!originalAccess) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -116,4 +120,52 @@ export class AccessController {
id
});
}
@HasPermission(permissions.updateAccess)
@Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateAccess(
@Body() data: UpdateAccessDto,
@Param('id') id: string
): Promise<AccessModel> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalAccess = await this.accessService.access({
id,
userId: this.request.user.id
});
if (!originalAccess) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try {
return this.accessService.updateAccess({
data: {
alias: data.alias,
granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: { disconnect: true },
permissions: data.permissions
},
where: { id }
});
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
}

19
apps/api/src/app/access/access.service.ts

@ -20,14 +20,14 @@ export class AccessService {
}
public async accesses(params: {
cursor?: Prisma.AccessWhereUniqueInput;
include?: Prisma.AccessInclude;
orderBy?: Prisma.Enumerable<Prisma.AccessOrderByWithRelationInput>;
skip?: number;
take?: number;
cursor?: Prisma.AccessWhereUniqueInput;
where?: Prisma.AccessWhereInput;
orderBy?: Prisma.AccessOrderByWithRelationInput;
}): Promise<AccessWithGranteeUser[]> {
const { include, skip, take, cursor, where, orderBy } = params;
const { cursor, include, orderBy, skip, take, where } = params;
return this.prismaService.access.findMany({
cursor,
@ -52,4 +52,17 @@ export class AccessService {
where
});
}
public async updateAccess({
data,
where
}: {
data: Prisma.AccessUpdateInput;
where: Prisma.AccessWhereUniqueInput;
}): Promise<Access> {
return this.prismaService.access.update({
data,
where
});
}
}

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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -20,7 +21,6 @@ import { AccountBalance } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountBalanceService } from './account-balance.service';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Controller('account-balance')
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 {
AccountBalancesResponse,
@ -15,8 +16,6 @@ import { AccountBalance, Prisma } from '@prisma/client';
import { Big } from 'big.js';
import { format, parseISO } from 'date-fns';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Injectable()
export class AccountBalanceService {
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
CreateAccountDto,
TransferBalanceDto,
UpdateAccountDto
} from '@ghostfolio/common/dtos';
import {
AccountBalancesResponse,
AccountResponse,
AccountsResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type {
AccountWithValue,
RequestWithUser
} from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@ -38,9 +41,6 @@ import { Account as AccountModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
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')
export class AccountController {
@ -114,7 +114,7 @@ export class AccountController {
public async getAccountById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string
): Promise<AccountWithValue> {
): Promise<AccountResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);

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

@ -4,24 +4,30 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.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 { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS
} from '@ghostfolio/common/config';
import {
UpdateAssetProfileDto,
UpdatePropertyDto
} from '@ghostfolio/common/dtos';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
AdminData,
AdminMarketData,
AdminUsers,
AdminUserResponse,
AdminUsersResponse,
EnhancedSymbolProfile,
ScraperConfiguration
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type {
DateRange,
MarketDataPreset,
RequestWithUser
} from '@ghostfolio/common/types';
@ -49,7 +55,6 @@ import { isDate, parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
@Controller('admin')
export class AdminController {
@ -88,7 +93,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMax(): Promise<void> {
const assetProfileIdentifiers =
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
await this.dataGatheringService.getActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -115,7 +120,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileData(): Promise<void> {
const assetProfileIdentifiers =
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
await this.dataGatheringService.getActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -161,9 +166,21 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl)
public async gatherSymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
@Param('symbol') symbol: string,
@Query('range') dateRange: DateRange
): Promise<void> {
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
let date: Date;
if (dateRange) {
const { startDate } = getIntervalFromDateRange(dateRange);
date = startDate;
}
this.dataGatheringService.gatherSymbol({
dataSource,
date,
symbol
});
return;
}
@ -290,7 +307,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateProperty(
@Param('key') key: string,
@Body() data: PropertyDto
@Body() data: UpdatePropertyDto
) {
return this.adminService.putSetting(key, data.value);
}
@ -301,10 +318,17 @@ export class AdminController {
public async getUsers(
@Query('skip') skip?: number,
@Query('take') take?: number
): Promise<AdminUsers> {
): Promise<AdminUsersResponse> {
return this.adminService.getUsers({
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take
});
}
@Get('user/:id')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getUser(@Param('id') id: string): Promise<AdminUserResponse> {
return this.adminService.getUser(id);
}
}

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

@ -23,7 +23,8 @@ import {
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
AdminUsers,
AdminUserResponse,
AdminUsersResponse,
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter
@ -35,7 +36,8 @@ import {
BadRequestException,
HttpException,
Injectable,
Logger
Logger,
NotFoundException
} from '@nestjs/common';
import {
AssetClass,
@ -136,11 +138,13 @@ export class AdminService {
public async get(): Promise<AdminData> {
const dataSources = Object.values(DataSource);
const [settings, transactionCount, userCount] = await Promise.all([
this.propertyService.get(),
this.prismaService.order.count(),
this.countUsersWithAnalytics()
]);
const [enabledDataSources, settings, transactionCount, userCount] =
await Promise.all([
this.dataProviderService.getDataSources(),
this.propertyService.get(),
this.prismaService.order.count(),
this.countUsersWithAnalytics()
]);
const dataProviders = (
await Promise.all(
@ -152,14 +156,23 @@ export class AdminService {
}
});
if (assetProfileCount > 0 || dataSource === 'GHOSTFOLIO') {
const isEnabled = enabledDataSources.includes(dataSource);
if (
assetProfileCount > 0 ||
dataSource === 'GHOSTFOLIO' ||
isEnabled
) {
const dataProviderInfo = this.dataProviderService
.getDataProvider(dataSource)
.getDataProviderInfo();
return {
...dataProviderInfo,
assetProfileCount
assetProfileCount,
useForExchangeRates:
dataSource ===
this.dataProviderService.getDataSourceForExchangeRates()
};
}
@ -181,7 +194,7 @@ export class AdminService {
filters,
presetId,
sortColumn,
sortDirection,
sortDirection = 'asc',
skip,
take = Number.MAX_SAFE_INTEGER
}: {
@ -251,11 +264,13 @@ export class AdminService {
orderBy = [{ [sortColumn]: sortDirection }];
if (sortColumn === 'activitiesCount') {
orderBy = {
activities: {
_count: sortDirection
orderBy = [
{
activities: {
_count: sortDirection
}
}
};
];
}
}
@ -264,10 +279,10 @@ export class AdminService {
try {
const symbolProfileResult = await Promise.all([
extendedPrismaClient.symbolProfile.findMany({
orderBy,
skip,
take,
where,
orderBy: [...orderBy, { id: sortDirection }],
select: {
_count: {
select: {
@ -494,16 +509,31 @@ export class AdminService {
};
}
public async getUser(id: string): Promise<AdminUserResponse> {
const [user] = await this.getUsersWithAnalytics({
where: { id }
});
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}
public async getUsers({
skip,
take = Number.MAX_SAFE_INTEGER
}: {
skip?: number;
take?: number;
}): Promise<AdminUsers> {
}): Promise<AdminUsersResponse> {
const [count, users] = await Promise.all([
this.countUsersWithAnalytics(),
this.getUsersWithAnalytics({ skip, take })
this.getUsersWithAnalytics({
skip,
take
})
]);
return { count, users };
@ -801,34 +831,46 @@ export class AdminService {
private async getUsersWithAnalytics({
skip,
take
take,
where
}: {
skip?: number;
take?: number;
}): Promise<AdminUsers['users']> {
let orderBy: Prisma.UserOrderByWithRelationInput = {
createdAt: 'desc'
};
let where: Prisma.UserWhereInput;
where?: Prisma.UserWhereInput;
}): Promise<AdminUsersResponse['users']> {
let orderBy: Prisma.Enumerable<Prisma.UserOrderByWithRelationInput> = [
{ createdAt: 'desc' }
];
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
orderBy = {
analytics: {
lastRequestAt: 'desc'
orderBy = [
{
analytics: {
lastRequestAt: 'desc'
}
}
];
const noAnalyticsCondition: Prisma.UserWhereInput['NOT'] = {
analytics: null
};
where = {
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({
orderBy,
skip,
take,
where,
orderBy: [...orderBy, { id: 'desc' }],
select: {
_count: {
select: { accounts: true, activities: true }
@ -843,6 +885,7 @@ export class AdminService {
},
createdAt: true,
id: true,
provider: true,
role: true,
subscriptions: {
orderBy: {
@ -859,7 +902,7 @@ export class AdminService {
});
return usersWithAnalytics.map(
({ _count, analytics, createdAt, id, role, subscriptions }) => {
({ _count, analytics, createdAt, id, provider, role, subscriptions }) => {
const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1;
const engagement = analytics
@ -876,6 +919,7 @@ export class AdminService {
createdAt,
engagement,
id,
provider,
role,
subscription,
accountCount: _count.accounts || 0,

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

@ -71,7 +71,7 @@ export class QueueService {
.slice(0, limit)
.map(async (job) => {
return {
attemptsMade: job.attemptsMade + 1,
attemptsMade: job.attemptsMade,
data: job.data,
finishedOn: job.finishedOn,
id: job.id,

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

@ -21,7 +21,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static';
import { StatusCodes } from 'http-status-codes';
import { join } from 'path';
import { join } from 'node:path';
import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module';
@ -37,6 +37,7 @@ import { AssetsModule } from './endpoints/assets/assets.module';
import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { MarketDataModule } from './endpoints/market-data/market-data.module';
import { PlatformsModule } from './endpoints/platforms/platforms.module';
import { PublicModule } from './endpoints/public/public.module';
import { SitemapModule } from './endpoints/sitemap/sitemap.module';
import { TagsModule } from './endpoints/tags/tags.module';
@ -72,8 +73,8 @@ import { UserModule } from './user/user.module';
redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10),
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
password: process.env.REDIS_PASSWORD
password: process.env.REDIS_PASSWORD,
port: parseInt(process.env.REDIS_PORT ?? '6379', 10)
}
}),
CacheModule,
@ -95,6 +96,7 @@ import { UserModule } from './user/user.module';
MarketDataModule,
OrderModule,
PlatformModule,
PlatformsModule,
PortfolioModule,
PortfolioSnapshotQueueModule,
PrismaModule,

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

@ -1,7 +1,7 @@
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
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 type { AdminMarketDataDetails } from '@ghostfolio/common/interfaces';
import type { AssetResponse } from '@ghostfolio/common/interfaces';
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
import { DataSource } from '@prisma/client';
@ -17,7 +17,7 @@ export class AssetController {
public async getAsset(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> {
): Promise<AssetResponse> {
const { assetProfile, marketData } =
await this.adminService.getMarketDataBySymbol({ dataSource, symbol });

61
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { OAuthResponse } from '@ghostfolio/common/interfaces';
import {
AssertionCredentialJSON,
AttestationCredentialJSON,
OAuthResponse
} from '@ghostfolio/common/interfaces';
import {
Body,
@ -22,10 +26,6 @@ import { Request, Response } from 'express';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { AuthService } from './auth.service';
import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from './interfaces/simplewebauthn';
@Controller('auth')
export class AuthController {
@ -84,7 +84,6 @@ export class AuthController {
@Req() request: Request,
@Res() response: Response
) {
// Handles the Google OAuth2 callback
const jwt: string = (request.user as any).jwt;
if (jwt) {
@ -102,16 +101,11 @@ export class AuthController {
}
}
@Post('internet-identity')
public async internetIdentityLogin(
@Body() body: { principalId: string }
): Promise<OAuthResponse> {
try {
const authToken = await this.authService.validateInternetIdentityLogin(
body.principalId
);
return { authToken };
} catch {
@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
@ -119,6 +113,34 @@ export class AuthController {
}
}
@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')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async generateRegistrationOptions() {
@ -133,13 +155,6 @@ export class AuthController {
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')
public async verifyAuthentication(
@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 { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
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 { 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 type { StrategyOptions } from 'passport-openidconnect';
import { ApiKeyStrategy } from './api-key.strategy';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy';
import { JwtStrategy } from './jwt.strategy';
import { OidcStrategy } from './oidc.strategy';
@Module({
controllers: [AuthController],
@ -36,6 +39,83 @@ import { JwtStrategy } from './jwt.strategy';
AuthService,
GoogleStrategy,
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
]
})

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

@ -4,7 +4,6 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Provider } from '@prisma/client';
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
@ -18,66 +17,22 @@ export class AuthService {
) {}
public async validateAnonymousLogin(accessToken: string): Promise<string> {
return new Promise(async (resolve, reject) => {
try {
const hashedAccessToken = this.userService.createAccessToken({
password: accessToken,
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
});
const [user] = await this.userService.users({
where: { accessToken: hashedAccessToken }
});
if (user) {
const jwt = this.jwtService.sign({
id: user.id
});
resolve(jwt);
} else {
throw new Error();
}
} catch {
reject();
}
const hashedAccessToken = this.userService.createAccessToken({
password: accessToken,
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
});
}
public async validateInternetIdentityLogin(principalId: string) {
try {
const provider: Provider = 'INTERNET_IDENTITY';
let [user] = await this.userService.users({
where: { provider, thirdPartyId: principalId }
});
if (!user) {
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
if (!isUserSignupEnabled || true) {
throw new Error('Sign up forbidden');
}
// Create new user if not found
user = await this.userService.createUser({
data: {
provider,
thirdPartyId: principalId
}
});
}
const [user] = await this.userService.users({
where: { accessToken: hashedAccessToken }
});
if (user) {
return this.jwtService.sign({
id: user.id
});
} catch (error) {
throw new InternalServerErrorException(
'validateInternetIdentityLogin',
error.message
);
}
throw new Error();
}
public async validateOAuthLogin({
@ -112,7 +67,7 @@ export class AuthService {
} catch (error) {
throw new InternalServerErrorException(
'validateOAuthLogin',
error.message
error instanceof Error ? error.message : 'Unknown error'
);
}
}

3
apps/api/src/app/auth/google.strategy.ts

@ -3,6 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client';
import { DoneCallback } from 'passport';
import { Profile, Strategy } from 'passport-google-oauth20';
import { AuthService } from './auth.service';
@ -29,7 +30,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
_token: string,
_refreshToken: string,
profile: Profile,
done: Function
done: DoneCallback
) {
try {
const jwt = await this.authService.validateOAuthLogin({

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';
@ -6,6 +6,25 @@ export interface AuthDeviceDialogParams {
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 {
provider: Provider;
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 { UserService } from '@ghostfolio/api/app/user/user.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 {
@ -27,11 +31,6 @@ import {
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
import ms from 'ms';
import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from './interfaces/simplewebauthn';
@Injectable()
export class WebAuthService {
public constructor(

113
apps/api/src/app/endpoints/ai/ai.service.ts

@ -10,9 +10,31 @@ import type { AiPromptMode } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { generateText } from 'ai';
import type { ColumnDescriptor } from 'tablemark';
@Injectable()
export class AiService {
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: ({
key:
| 'ALLOCATION_PERCENTAGE'
| 'ASSET_CLASS'
| 'ASSET_SUB_CLASS'
| 'CURRENCY'
| 'NAME'
| 'SYMBOL';
} & ColumnDescriptor)[] = [
{ key: 'NAME', name: 'Name' },
{ key: 'SYMBOL', name: 'Symbol' },
{ key: 'CURRENCY', name: 'Currency' },
{ key: 'ASSET_CLASS', name: 'Asset Class' },
{ key: 'ASSET_SUB_CLASS', name: 'Asset Sub Class' },
{
align: 'right',
key: 'ALLOCATION_PERCENTAGE',
name: 'Allocation in Percentage'
}
];
public constructor(
private readonly portfolioService: PortfolioService,
private readonly propertyService: PropertyService
@ -58,34 +80,81 @@ export class AiService {
userId
});
const holdingsTable = [
'| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |',
'| --- | --- | --- | --- | --- | --- |',
...Object.values(holdings)
.sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage;
})
.map(
({
allocationInPercentage,
assetClass,
assetSubClass,
currency,
name,
symbol
}) => {
return `| ${name} | ${symbol} | ${currency} | ${assetClass} | ${assetSubClass} | ${(allocationInPercentage * 100).toFixed(3)}% |`;
}
)
];
const holdingsTableColumns: ColumnDescriptor[] =
AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ align, name }) => {
return { name, align: align ?? 'left' };
});
const holdingsTableRows = Object.values(holdings)
.sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage;
})
.map(
({
allocationInPercentage,
assetClass,
assetSubClass,
currency,
name: label,
symbol
}) => {
return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.reduce(
(row, { key, name }) => {
switch (key) {
case 'ALLOCATION_PERCENTAGE':
row[name] = `${(allocationInPercentage * 100).toFixed(3)}%`;
break;
case 'ASSET_CLASS':
row[name] = assetClass ?? '';
break;
case 'ASSET_SUB_CLASS':
row[name] = assetSubClass ?? '';
break;
case 'CURRENCY':
row[name] = currency;
break;
case 'NAME':
row[name] = label;
break;
case 'SYMBOL':
row[name] = symbol;
break;
default:
row[name] = '';
break;
}
return row;
},
{} as Record<string, string>
);
}
);
// Dynamic import to load ESM module from CommonJS context
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const dynamicImport = new Function('s', 'return import(s)') as (
s: string
) => Promise<typeof import('tablemark')>;
const { tablemark } = await dynamicImport('tablemark');
const holdingsTableString = tablemark(holdingsTableRows, {
columns: holdingsTableColumns
});
if (mode === 'portfolio') {
return holdingsTable.join('\n');
return holdingsTableString;
}
return [
`You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`,
...holdingsTable,
holdingsTableString,
'Structure your answer with these sections:',
'Overview: Briefly summarize the portfolio’s composition and allocation rationale.',
'Risk Assessment: Identify potential risks, including market volatility, concentration, and sectoral imbalances.',

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

@ -10,8 +10,8 @@ import {
VERSION_NEUTRAL
} from '@nestjs/common';
import { Response } from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
@Controller('assets')
export class AssetsController {

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

@ -8,7 +8,7 @@ import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import type {
AssetProfileIdentifier,
BenchmarkMarketDataDetails,
BenchmarkMarketDataDetailsResponse,
BenchmarkResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
@ -125,7 +125,7 @@ export class BenchmarksController {
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
): Promise<BenchmarkMarketDataDetails> {
): Promise<BenchmarkMarketDataDetailsResponse> {
const { endDate, startDate } = getIntervalFromDateRange(
dateRange,
new Date(startDateString)

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

@ -6,7 +6,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
BenchmarkMarketDataDetails,
BenchmarkMarketDataDetailsResponse,
Filter
} from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
@ -43,7 +43,7 @@ export class BenchmarksService {
startDate: Date;
user: UserWithSettings;
withExcludedAccounts?: boolean;
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetailsResponse> {
const marketData: { date: string; value: number }[] = [];
const userCurrency = user.settings.settings.baseCurrency;
const userId = user.id;

10
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -1,5 +1,6 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { AssetProfileInvalidError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-invalid.error';
import { parseDate } from '@ghostfolio/common/helper';
import {
DataProviderGhostfolioAssetProfileResponse,
@ -66,7 +67,14 @@ export class GhostfolioController {
});
return assetProfile;
} catch {
} catch (error) {
if (error instanceof AssetProfileInvalidError) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR

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

@ -8,7 +8,6 @@ import {
GetQuotesParams,
GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
@ -18,6 +17,7 @@ import {
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderHistoricalResponse,
DataProviderInfo,
DividendsResponse,
HistoricalResponse,
@ -40,10 +40,7 @@ export class GhostfolioService {
private readonly propertyService: PropertyService
) {}
public async getAssetProfile({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol
}: GetAssetProfileParams) {
public async getAssetProfile({ symbol }: GetAssetProfileParams) {
let result: DataProviderGhostfolioAssetProfileResponse = {};
try {
@ -51,16 +48,44 @@ export class GhostfolioService {
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
dataProviderService
.getAssetProfile({
requestTimeout,
symbol
})
.then((assetProfile) => {
this.dataProviderService
.getAssetProfiles([
{
symbol,
dataSource: dataProviderService.getName()
}
])
.then(async (assetProfiles) => {
const assetProfile = assetProfiles[symbol];
const dataSourceOrigin = DataSource.GHOSTFOLIO;
if (assetProfile) {
await this.prismaService.assetProfileResolution.upsert({
create: {
dataSourceOrigin,
currency: assetProfile.currency,
dataSourceTarget: assetProfile.dataSource,
symbolOrigin: symbol,
symbolTarget: assetProfile.symbol
},
update: {
requestCount: {
increment: 1
}
},
where: {
dataSourceOrigin_symbolOrigin: {
dataSourceOrigin,
symbolOrigin: symbol
}
}
});
}
result = {
...result,
...assetProfile,
dataSource: DataSource.GHOSTFOLIO
dataSource: dataSourceOrigin
};
return assetProfile;
@ -89,7 +114,7 @@ export class GhostfolioService {
try {
const promises: Promise<{
[date: string]: IDataProviderHistoricalResponse;
[date: string]: DataProviderHistoricalResponse;
}>[] = [];
for (const dataProviderService of this.getDataProviderServices()) {
@ -131,7 +156,7 @@ export class GhostfolioService {
try {
const promises: Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
}>[] = [];
for (const dataProviderService of this.getDataProviderServices()) {

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

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

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

@ -1,6 +1,8 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -18,6 +20,7 @@ import {
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Type as ActivityType } from '@prisma/client';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -27,15 +30,17 @@ export class PublicController {
private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@Get(':accessId/portfolio')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublicPortfolio(
@Param('accessId') accessId
@Param('accessId') accessId: string
): Promise<PublicPortfolioResponse> {
const access = await this.accessService.access({ id: accessId });
@ -76,6 +81,47 @@ export class PublicController {
})
]);
const { activities } = await this.orderService.getOrders({
sortColumn: 'date',
sortDirection: 'desc',
take: 10,
types: [ActivityType.BUY, ActivityType.SELL],
userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY,
userId: user.id,
withExcludedAccountsAndActivities: false
});
// Experimental
const latestActivities = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? []
: activities.map(
({
currency,
date,
fee,
quantity,
SymbolProfile,
type,
unitPrice,
value,
valueInBaseCurrency
}) => {
return {
currency,
date,
fee,
quantity,
SymbolProfile,
type,
unitPrice,
value,
valueInBaseCurrency
};
}
);
Object.values(markets ?? {}).forEach((market) => {
delete market.valueInBaseCurrency;
});
@ -83,6 +129,7 @@ export class PublicController {
const publicPortfolioResponse: PublicPortfolioResponse = {
createdAt,
hasDetails,
latestActivities,
markets,
alias: access.alias,
holdings: {},

5
apps/api/src/app/endpoints/sitemap/sitemap.controller.ts

@ -8,8 +8,8 @@ import {
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns';
import { Response } from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { SitemapService } from './sitemap.service';
@ -37,6 +37,7 @@ export class SitemapController {
response.setHeader('content-type', 'application/xml');
response.send(
interpolate(this.sitemapXml, {
blogPosts: this.sitemapService.getBlogPosts({ currentDate }),
personalFinanceTools: this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)

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

@ -17,6 +17,125 @@ export class SitemapService {
private readonly i18nService: I18nService
) {}
public getBlogPosts({ currentDate }: { currentDate: string }) {
const rootUrl = this.configurationService.get('ROOT_URL');
return [
{
languageCode: 'de',
routerLink: ['2021', '07', 'hallo-ghostfolio']
},
{
languageCode: 'en',
routerLink: ['2021', '07', 'hello-ghostfolio']
},
{
languageCode: 'en',
routerLink: ['2022', '01', 'ghostfolio-first-months-in-open-source']
},
{
languageCode: 'en',
routerLink: ['2022', '07', 'ghostfolio-meets-internet-identity']
},
{
languageCode: 'en',
routerLink: ['2022', '07', 'how-do-i-get-my-finances-in-order']
},
{
languageCode: 'en',
routerLink: ['2022', '08', '500-stars-on-github']
},
{
languageCode: 'en',
routerLink: ['2022', '10', 'hacktoberfest-2022']
},
{
languageCode: 'en',
routerLink: ['2022', '11', 'black-friday-2022']
},
{
languageCode: 'en',
routerLink: [
'2022',
'12',
'the-importance-of-tracking-your-personal-finances'
]
},
{
languageCode: 'de',
routerLink: ['2023', '01', 'ghostfolio-auf-sackgeld-vorgestellt']
},
{
languageCode: 'en',
routerLink: ['2023', '02', 'ghostfolio-meets-umbrel']
},
{
languageCode: 'en',
routerLink: ['2023', '03', 'ghostfolio-reaches-1000-stars-on-github']
},
{
languageCode: 'en',
routerLink: [
'2023',
'05',
'unlock-your-financial-potential-with-ghostfolio'
]
},
{
languageCode: 'en',
routerLink: ['2023', '07', 'exploring-the-path-to-fire']
},
{
languageCode: 'en',
routerLink: ['2023', '08', 'ghostfolio-joins-oss-friends']
},
{
languageCode: 'en',
routerLink: ['2023', '09', 'ghostfolio-2']
},
{
languageCode: 'en',
routerLink: ['2023', '09', 'hacktoberfest-2023']
},
{
languageCode: 'en',
routerLink: ['2023', '11', 'black-week-2023']
},
{
languageCode: 'en',
routerLink: ['2023', '11', 'hacktoberfest-2023-debriefing']
},
{
languageCode: 'en',
routerLink: ['2024', '09', 'hacktoberfest-2024']
},
{
languageCode: 'en',
routerLink: ['2024', '11', 'black-weeks-2024']
},
{
languageCode: 'en',
routerLink: ['2025', '09', 'hacktoberfest-2025']
},
{
languageCode: 'en',
routerLink: ['2025', '11', 'black-weeks-2025']
}
]
.map(({ languageCode, routerLink }) => {
return this.createRouteSitemapUrl({
currentDate,
languageCode,
rootUrl,
route: {
routerLink: [publicRoutes.blog.path, ...routerLink],
path: undefined
}
});
})
.join('\n');
}
public getPersonalFinanceTools({ currentDate }: { currentDate: string }) {
const rootUrl = this.configurationService.get('ROOT_URL');
@ -43,20 +162,21 @@ export class SitemapService {
});
return personalFinanceTools.map(({ alias, key }) => {
const location = [
rootUrl,
languageCode,
const routerLink = [
resourcesPath,
personalFinanceToolsPath,
`${productPath}-${alias ?? key}`
].join('/');
return [
' <url>',
` <loc>${location}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
' </url>'
].join('\n');
];
return this.createRouteSitemapUrl({
currentDate,
languageCode,
rootUrl,
route: {
routerLink,
path: undefined
}
});
});
}).join('\n');
}

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

@ -1,6 +1,7 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
@ -21,9 +22,6 @@ import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateTagDto } from './create-tag.dto';
import { UpdateTagDto } from './update-tag.dto';
@Controller('tags')
export class TagsController {
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { CreateWatchlistItemDto } from '@ghostfolio/common/dtos';
import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
@ -26,7 +27,6 @@ import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateWatchlistItemDto } from './create-watchlist-item.dto';
import { WatchlistService } from './watchlist.service';
@Controller('watchlist')

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

@ -1,5 +1,5 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { DataProviderHistoricalResponse } from '@ghostfolio/common/interfaces';
import {
Controller,
@ -25,7 +25,7 @@ export class ExchangeRateController {
public async getExchangeRate(
@Param('dateString') dateString: string,
@Param('symbol') symbol: string
): Promise<IDataProviderHistoricalResponse> {
): Promise<DataProviderHistoricalResponse> {
const date = parseISO(dateString);
const exchangeRate = await this.exchangeRateService.getExchangeRate({

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

@ -1,7 +1,8 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { Export } from '@ghostfolio/common/interfaces';
import { ExportResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -28,6 +29,7 @@ export class ExportController {
@Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async export(
@Query('accounts') filterByAccounts?: string,
@Query('activityIds') filterByActivityIds?: string,
@ -35,7 +37,7 @@ export class ExportController {
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<Export> {
): Promise<ExportResponse> {
const activityIds = filterByActivityIds?.split(',') ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
@ -48,8 +50,8 @@ export class ExportController {
return this.exportService.export({
activityIds,
filters,
userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id
userId: this.request.user.id,
userSettings: this.request.user.settings.settings
});
}
}

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

@ -3,7 +3,11 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { Filter, Export } from '@ghostfolio/common/interfaces';
import {
ExportResponse,
Filter,
UserSettings
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Platform, Prisma } from '@prisma/client';
@ -21,14 +25,14 @@ export class ExportService {
public async export({
activityIds,
filters,
userCurrency,
userId
userId,
userSettings
}: {
activityIds?: string[];
filters?: Filter[];
userCurrency: string;
userId: string;
}): Promise<Export> {
userSettings: UserSettings;
}): Promise<ExportResponse> {
const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => {
return type;
});
@ -36,11 +40,11 @@ export class ExportService {
let { activities } = await this.orderService.getOrders({
filters,
userCurrency,
userId,
includeDrafts: true,
sortColumn: 'date',
sortDirection: 'asc',
userCurrency: userSettings?.baseCurrency,
withExcludedAccountsAndActivities: true
});
@ -244,7 +248,10 @@ export class ExportService {
}
),
user: {
settings: { currency: userCurrency }
settings: {
currency: userSettings?.baseCurrency,
performanceCalculationType: userSettings?.performanceCalculationType
}
}
};
}

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 { 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 {
@IsArray()
@IsOptional()

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

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

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

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

@ -1,13 +1,8 @@
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 { PlatformService } from '@ghostfolio/api/app/platform/platform.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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import {
CreateAssetProfileDto,
CreateAccountDto,
CreateOrderDto
} from '@ghostfolio/common/dtos';
import {
getAssetProfileIdentifier,
parseDate
} 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 {
AccountWithPlatform,
AccountWithValue,
OrderWithAccount,
UserWithSettings
} from '@ghostfolio/common/types';
@ -32,15 +36,15 @@ import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns';
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';
@Injectable()
export class ImportService {
public constructor(
private readonly accountService: AccountService,
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
@ -55,45 +59,63 @@ export class ImportService {
public async getDividends({
dataSource,
symbol,
userCurrency,
userId
}: AssetProfileIdentifier & { userId: string }): Promise<Activity[]> {
}: AssetProfileIdentifier & {
userCurrency: string;
userId: string;
}): Promise<Activity[]> {
try {
const { activities, firstBuyDate, historicalData } =
await this.portfolioService.getHolding({
dataSource,
symbol,
userId,
impersonationId: undefined
});
const holding = await this.portfolioService.getHolding({
dataSource,
symbol,
userId,
impersonationId: undefined
});
const [[assetProfile], dividends] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]),
await this.dataProviderService.getDividends({
dataSource,
symbol,
from: parseDate(firstBuyDate),
granularity: 'day',
to: new Date()
})
]);
if (!holding) {
return [];
}
const accounts = activities
.filter(({ account }) => {
return !!account;
})
.map(({ account }) => {
return account;
});
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([
{
dataSource,
symbol
}
]),
await this.dataProviderService.getDividends({
dataSource,
symbol,
from: parseDate(firstBuyDate),
granularity: 'day',
to: new Date()
})
]);
const account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
return await Promise.all(
Object.entries(dividends).map(async ([dateString, { marketPrice }]) => {
Object.entries(dividends).map(([dateString, { marketPrice }]) => {
const quantity =
historicalData.find((historicalDataItem) => {
return historicalDataItem.date === dateString;
@ -270,7 +292,7 @@ export class ImportService {
// Asset profile belongs to a different user
if (existingAssetProfile) {
const symbol = uuidv4();
const symbol = randomUUID();
assetProfileSymbolMapping[assetProfile.symbol] = symbol;
assetProfile.symbol = symbol;
}
@ -373,6 +395,7 @@ export class ImportService {
const assetProfiles = await this.validateActivities({
activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport,
user
});
@ -488,7 +511,7 @@ export class ImportService {
accountId: validatedAccount?.id,
accountUserId: undefined,
createdAt: new Date(),
id: uuidv4(),
id: randomUUID(),
isDraft: isAfter(date, endOfToday()),
SymbolProfile: {
assetClass,
@ -538,6 +561,7 @@ export class ImportService {
connectOrCreate: {
create: {
dataSource,
name,
symbol,
currency: assetProfile.currency,
userId: dataSource === 'MANUAL' ? user.id : undefined
@ -686,11 +710,11 @@ export class ImportService {
);
}
private isUniqueAccount(accounts: AccountWithPlatform[]) {
private isUniqueAccount(accounts: AccountWithValue[]) {
const uniqueAccountIds = new Set<string>();
for (const account of accounts) {
uniqueAccountIds.add(account.id);
for (const { id } of accounts) {
uniqueAccountIds.add(id);
}
return uniqueAccountIds.size === 1;
@ -698,10 +722,12 @@ export class ImportService {
private async validateActivities({
activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport,
user
}: {
activitiesDto: Partial<CreateOrderDto>[];
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
maxActivitiesToImport: number;
user: UserWithSettings;
}) {
@ -718,12 +744,6 @@ export class ImportService {
index,
{ currency, dataSource, symbol, type }
] of activitiesDto.entries()) {
if (type === 'ITEM') {
throw new Error(
`activities.${index}.type ("${type}") is deprecated, please use "BUY" instead`
);
}
if (!dataSources.includes(dataSource)) {
throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid`
@ -746,20 +766,73 @@ export class ImportService {
}
if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) {
const assetProfile = {
currency,
...(
if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
// Skip asset profile validation for FEE, INTEREST, and LIABILITY
// as these activity types don't require asset profiles
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(profile) => {
return (
profile.dataSource === dataSource && profile.symbol === symbol
);
}
);
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] = {
currency,
dataSource,
symbol,
name: assetProfileInImport?.name
};
continue;
}
let assetProfile: Partial<SymbolProfile> = { currency };
try {
assetProfile = (
await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol }
])
)?.[symbol]
};
)?.[symbol];
} catch {}
if (!assetProfile?.name) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(profile) => {
return (
profile.dataSource === dataSource && profile.symbol === symbol
);
}
);
if (assetProfileInImport) {
// Merge all fields of custom asset profiles into the validation object
Object.assign(assetProfile, {
assetClass: assetProfileInImport.assetClass,
assetSubClass: assetProfileInImport.assetSubClass,
comment: assetProfileInImport.comment,
countries: assetProfileInImport.countries,
currency: assetProfileInImport.currency,
cusip: assetProfileInImport.cusip,
dataSource: assetProfileInImport.dataSource,
figi: assetProfileInImport.figi,
figiComposite: assetProfileInImport.figiComposite,
figiShareClass: assetProfileInImport.figiShareClass,
holdings: assetProfileInImport.holdings,
isActive: assetProfileInImport.isActive,
isin: assetProfileInImport.isin,
name: assetProfileInImport.name,
scraperConfiguration: assetProfileInImport.scraperConfiguration,
sectors: assetProfileInImport.sectors,
symbol: assetProfileInImport.symbol,
symbolMapping: assetProfileInImport.symbolMapping,
url: assetProfileInImport.url
});
}
}
if (
(dataSource !== 'MANUAL' && type === 'BUY') ||
type === 'DIVIDEND' ||
type === 'SELL'
) {
if (!['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
if (!assetProfile?.name) {
throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`

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

@ -1,5 +1,5 @@
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { InfoResponse } from '@ghostfolio/common/interfaces';
import { Controller, Get, UseInterceptors } from '@nestjs/common';
@ -11,7 +11,7 @@ export class InfoController {
@Get()
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getInfo(): Promise<InfoItem> {
public async getInfo(): Promise<InfoResponse> {
return this.infoService.get();
}
}

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

@ -51,6 +51,18 @@ export class InfoService {
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_SUBSCRIPTION')) {
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')) {
globalPermissions.push(permissions.enableStatistics);
}
@ -85,7 +93,6 @@ export class InfoService {
(await this.propertyService.getByKey<string[]>(
PROPERTY_COUNTRIES_OF_SUBSCRIBERS
)) ?? [];
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
}
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {

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

@ -11,6 +11,11 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import {
ActivitiesResponse,
ActivityResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
@ -35,10 +40,7 @@ import { Order as OrderModel, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateOrderDto } from './create-order.dto';
import { Activities, Activity } from './interfaces/activities.interface';
import { OrderService } from './order.service';
import { UpdateOrderDto } from './update-order.dto';
@Controller('order')
export class OrderController {
@ -113,7 +115,7 @@ export class OrderController {
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('take') take?: number
): Promise<Activities> {
): Promise<ActivitiesResponse> {
let endDate: Date;
let startDate: Date;
@ -157,13 +159,14 @@ export class OrderController {
public async getOrderById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string
): Promise<Activity> {
): Promise<ActivityResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities } = await this.orderService.getOrders({
userCurrency,
includeDrafts: true,
userId: impersonationUserId || this.request.user.id,
withExcludedAccountsAndActivities: true
});

195
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 { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
@ -14,6 +18,8 @@ import {
} from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
ActivitiesResponse,
Activity,
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter
@ -35,15 +41,15 @@ import { Big } from 'big.js';
import { isUUID } from 'class-validator';
import { endOfToday, isAfter } from 'date-fns';
import { groupBy, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { Activities } from './interfaces/activities.interface';
import { randomUUID } from 'node:crypto';
@Injectable()
export class OrderService {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService,
@ -129,7 +135,7 @@ export class OrderService {
const assetSubClass = data.assetSubClass;
const dataSource: DataSource = 'MANUAL';
let name: string;
let name = data.SymbolProfile.connectOrCreate.create.name;
let symbol: string;
if (
@ -142,8 +148,8 @@ export class OrderService {
symbol = data.SymbolProfile.connectOrCreate.create.symbol;
} else {
// Create custom asset profile
name = data.SymbolProfile.connectOrCreate.create.symbol;
symbol = uuidv4();
name = name ?? data.SymbolProfile.connectOrCreate.create.symbol;
symbol = randomUUID();
}
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
@ -226,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(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
@ -308,6 +323,131 @@ export class OrderService {
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) {
return this.prismaService.order.findFirst({
orderBy: {
@ -325,7 +465,7 @@ export class OrderService {
includeDrafts = false,
skip,
sortColumn,
sortDirection,
sortDirection = 'asc',
startDate,
take = Number.MAX_SAFE_INTEGER,
types,
@ -345,11 +485,11 @@ export class OrderService {
userCurrency: string;
userId: string;
withExcludedAccountsAndActivities?: boolean;
}): Promise<Activities> {
}): Promise<ActivitiesResponse> {
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
{ date: 'asc' },
{ id: 'asc' }
{ date: 'asc' }
];
const where: Prisma.OrderWhereInput = { userId };
if (endDate || startDate) {
@ -483,7 +623,7 @@ export class OrderService {
}
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }, { id: sortDirection }];
orderBy = [{ [sortColumn]: sortDirection }];
}
if (types) {
@ -506,7 +646,6 @@ export class OrderService {
const [orders, count] = await Promise.all([
this.orders({
orderBy,
skip,
take,
where,
@ -519,7 +658,8 @@ export class OrderService {
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true,
tags: true
}
},
orderBy: [...orderBy, { id: sortDirection }]
}),
this.prismaService.order.count({ where })
]);
@ -601,6 +741,15 @@ export class OrderService {
return { activities, count };
}
/**
* Retrieves all orders required for the portfolio calculator, including both standard asset orders
* and synthetic orders representing cash activities.
*
* @param filters - Optional filters to apply to the orders.
* @param userCurrency - The base currency of the user.
* @param userId - The ID of the user.
* @returns An object containing the combined list of activities and the total count.
*/
@LogPerformance
public async getOrdersForPortfolioCalculator({
filters,
@ -611,12 +760,30 @@ export class OrderService {
userCurrency: string;
userId: string;
}) {
return this.getOrders({
const nonCashOrders = await this.getOrders({
filters,
userCurrency,
userId,
withExcludedAccountsAndActivities: false // TODO
});
const cashDetails = await this.accountService.getCashDetails({
filters,
userId,
currency: userCurrency
});
const cashOrders = await this.getCashOrders({
cashDetails,
filters,
userCurrency,
userId
});
return {
activities: [...nonCashOrders.activities, ...cashOrders.activities],
count: nonCashOrders.count + cashOrders.count
};
}
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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos';
import { permissions } from '@ghostfolio/common/permissions';
import {
@ -17,16 +18,14 @@ import { AuthGuard } from '@nestjs/passport';
import { Platform } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreatePlatformDto } from './create-platform.dto';
import { PlatformService } from './platform.service';
import { UpdatePlatformDto } from './update-platform.dto';
@Controller('platform')
export class PlatformController {
public constructor(private readonly platformService: PlatformService) {}
@Get()
@HasPermission(permissions.readPlatforms)
@HasPermission(permissions.readPlatformsWithAccountCount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPlatforms() {
return this.platformService.getPlatformsWithAccountCount();

8
apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts

@ -1,4 +1,6 @@
import { readFileSync } from 'fs';
import { ExportResponse } from '@ghostfolio/common/interfaces';
import { readFileSync } from 'node:fs';
export const activityDummyData = {
accountId: undefined,
@ -37,6 +39,6 @@ export const userDummyData = {
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
};
export function loadActivityExportFile(filePath: string) {
return JSON.parse(readFileSync(filePath, 'utf8')).activities;
export function loadExportFile(filePath: string): ExportResponse {
return JSON.parse(readFileSync(filePath, 'utf8'));
}

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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { 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 { Injectable } from '@nestjs/common';

91
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 { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
@ -9,7 +8,7 @@ import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
@ -26,6 +25,7 @@ import {
resetHours
} from '@ghostfolio/common/helper';
import {
Activity,
AssetProfileIdentifier,
DataProviderInfo,
Filter,
@ -44,11 +44,15 @@ import { plainToClass } from 'class-transformer';
import {
differenceInDays,
eachDayOfInterval,
eachYearOfInterval,
endOfDay,
endOfYear,
format,
isAfter,
isBefore,
isWithinInterval,
min,
startOfYear,
subDays
} from 'date-fns';
import { isNumber, sortBy, sum, uniqBy } from 'lodash';
@ -193,19 +197,25 @@ export abstract class PortfolioCalculator {
}
const currencies: { [symbol: string]: string } = {};
const dataGatheringItems: IDataGatheringItem[] = [];
const dataGatheringItems: DataGatheringItem[] = [];
let firstIndex = transactionPoints.length;
let firstTransactionPoint: TransactionPoint = null;
let totalInterestWithCurrencyEffect = new Big(0);
let totalLiabilitiesWithCurrencyEffect = new Big(0);
for (const { currency, dataSource, symbol } of transactionPoints[
firstIndex - 1
].items) {
dataGatheringItems.push({
dataSource,
symbol
});
for (const {
assetSubClass,
currency,
dataSource,
symbol
} of transactionPoints[firstIndex - 1].items) {
// Gather data for all assets except CASH
if (assetSubClass !== 'CASH') {
dataGatheringItems.push({
dataSource,
symbol
});
}
currencies[symbol] = currency;
}
@ -336,7 +346,7 @@ export abstract class PortfolioCalculator {
).mul(
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
endDateString
]
] ?? 1
);
const {
@ -889,6 +899,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;
}
@ -911,6 +939,7 @@ export abstract class PortfolioCalculator {
} of this.activities) {
let currentTransactionPointItem: TransactionPointSymbol;
const assetSubClass = SymbolProfile.assetSubClass;
const currency = SymbolProfile.currency;
const dataSource = SymbolProfile.dataSource;
const factor = getFactor(type);
@ -922,29 +951,48 @@ export abstract class PortfolioCalculator {
if (oldAccumulatedSymbol) {
let investment = oldAccumulatedSymbol.investment;
const newQuantity = quantity
let newQuantity = quantity
.mul(factor)
.plus(oldAccumulatedSymbol.quantity);
if (type === 'BUY') {
investment = oldAccumulatedSymbol.investment.plus(
quantity.mul(unitPrice)
);
if (oldAccumulatedSymbol.investment.gte(0)) {
investment = oldAccumulatedSymbol.investment.plus(
quantity.mul(unitPrice)
);
} else {
investment = oldAccumulatedSymbol.investment.plus(
quantity.mul(oldAccumulatedSymbol.averagePrice)
);
}
} else if (type === 'SELL') {
investment = oldAccumulatedSymbol.investment.minus(
quantity.mul(oldAccumulatedSymbol.averagePrice)
);
if (oldAccumulatedSymbol.investment.gt(0)) {
investment = oldAccumulatedSymbol.investment.minus(
quantity.mul(oldAccumulatedSymbol.averagePrice)
);
} else {
investment = oldAccumulatedSymbol.investment.minus(
quantity.mul(unitPrice)
);
}
}
if (newQuantity.abs().lt(Number.EPSILON)) {
// Reset to zero if quantity is (almost) zero to avoid rounding issues
investment = new Big(0);
newQuantity = new Big(0);
}
currentTransactionPointItem = {
assetSubClass,
currency,
dataSource,
investment,
skipErrors,
symbol,
averagePrice: newQuantity.gt(0)
? investment.div(newQuantity)
: new Big(0),
averagePrice: newQuantity.eq(0)
? new Big(0)
: investment.div(newQuantity).abs(),
dividend: new Big(0),
fee: oldAccumulatedSymbol.fee.plus(fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
@ -955,6 +1003,7 @@ export abstract class PortfolioCalculator {
};
} else {
currentTransactionPointItem = {
assetSubClass,
currency,
dataSource,
fee,

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

@ -0,0 +1,205 @@
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and buy', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 136.6
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('595.6'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('139.75'),
currency: 'CHF',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('36.6'),
grossPerformancePercentage: new Big('0.07706261539956593567'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.07706261539956593567'
),
grossPerformanceWithCurrencyEffect: new Big('36.6'),
investment: new Big('559'),
investmentWithCurrencyEffect: new Big('559'),
netPerformance: new Big('33.4'),
netPerformancePercentage: new Big('0.07032490039195361342'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.06986689805847808234')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('33.4')
},
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('4'),
symbol: 'BALN.SW',
tags: [],
timeWeightedInvestment: new Big('474.93846153846153846154'),
timeWeightedInvestmentWithCurrencyEffect: new Big(
'474.93846153846153846154'
),
transactionCount: 2,
valueInBaseCurrency: new Big('595.6')
}
],
totalFeesWithCurrencyEffect: new Big('3.2'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('559'),
totalInvestmentWithCurrencyEffect: new Big('559'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 33.4,
netPerformanceInPercentage: 0.07032490039195362,
netPerformanceInPercentageWithCurrencyEffect: 0.07032490039195362,
netPerformanceWithCurrencyEffect: 33.4,
totalInvestmentValueWithCurrencyEffect: 559
})
);
expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('559') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: 559 },
{ date: '2021-12-01', investment: 0 }
]);
});
});
});

5
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 {
activityDummyData,
symbolProfileDummyData,
@ -14,13 +13,13 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -31,7 +30,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -41,7 +39,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

5
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 {
activityDummyData,
symbolProfileDummyData,
@ -14,13 +13,13 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -31,7 +30,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -41,7 +39,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

15
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 {
activityDummyData,
symbolProfileDummyData,
@ -14,13 +13,13 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -31,7 +30,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -41,7 +39,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
@ -112,6 +109,12 @@ describe('PortfolioCalculator', () => {
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const historicalDataDates = portfolioSnapshot.historicalData.map(
({ date }) => {
return date;
}
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
@ -173,8 +176,12 @@ describe('PortfolioCalculator', () => {
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.objectContaining({
date: '2021-12-18',
netPerformance: 23.05,
netPerformanceInPercentage: 0.08437042459736457,
netPerformanceInPercentageWithCurrencyEffect: 0.08437042459736457,

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

@ -0,0 +1,150 @@
import {
activityDummyData,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { 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 { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/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 exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
exportResponse = loadExportFile(
join(__dirname, '../../../../../../../test/import/ok/btceur.json')
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with BTCUSD buy (in EUR)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: 44558.42
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'EUR',
userId: userDummyData.id
});
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].feeInBaseCurrency.toNumber()
).toBeCloseTo(3.94, 1);
});
});
});

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

@ -1,8 +1,6 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -16,15 +14,14 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -35,7 +32,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -45,7 +41,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
@ -53,7 +48,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -63,7 +58,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
exportResponse = loadExportFile(
join(__dirname, '../../../../../../../test/import/ok/btceur.json')
);
});
@ -97,33 +92,38 @@ describe('PortfolioCalculator', () => {
it.only('with BTCUSD buy (in EUR)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: 44558.42
}));
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: 44558.42
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const historicalDataDates = portfolioSnapshot.historicalData.map(
({ date }) => {
return date;
}
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
@ -231,6 +231,11 @@ describe('PortfolioCalculator', () => {
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([
{ date: '2021-12-12', investment: new Big('44558.42') }
]);

6
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 {
activityDummyData,
symbolProfileDummyData,
@ -15,13 +14,13 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -32,7 +31,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -42,7 +40,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
@ -53,7 +50,6 @@ jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})

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

@ -0,0 +1,126 @@
import {
activityDummyData,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
exportResponse = loadExportFile(
join(__dirname, '../../../../../../../test/import/ok/btcusd-short.json')
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with BTCUSD short sell (in USD)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(portfolioSnapshot.positions[0].averagePrice).toEqual(
Big(45647.95)
);
});
});
});

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

@ -1,8 +1,6 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -16,15 +14,14 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -35,7 +32,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -45,7 +41,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
@ -53,7 +48,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -63,7 +58,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
exportResponse = loadExportFile(
join(__dirname, '../../../../../../../test/import/ok/btcusd.json')
);
});
@ -97,33 +92,38 @@ describe('PortfolioCalculator', () => {
it.only('with BTCUSD buy (in USD)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: 44558.42
}));
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: 44558.42
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const historicalDataDates = portfolioSnapshot.historicalData.map(
({ date }) => {
return date;
}
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
@ -231,6 +231,11 @@ describe('PortfolioCalculator', () => {
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([
{ 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 { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { DataSource } from '@prisma/client';
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
}
);
jest.spyOn(currentRateService, 'getValues').mockResolvedValue({
dataProviderInfos: [],
errors: [],
values: []
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const { historicalData } = await portfolioCalculator.computeSnapshot();
const historicalData20231231 = historicalData.find(({ date }) => {
return date === '2023-12-31';
});
const historicalData20240101 = historicalData.find(({ date }) => {
return date === '2024-01-01';
});
const historicalData20241231 = historicalData.find(({ date }) => {
return date === '2024-12-31';
});
/**
* Investment value with currency effect: 1000 USD * 0.85 = 850 CHF
* Total investment: 1000 USD * 0.91 = 910 CHF
* Value (current): 1000 USD * 0.91 = 910 CHF
* Value with currency effect: 1000 USD * 0.85 = 850 CHF
*/
expect(historicalData20231231).toMatchObject({
date: '2023-12-31',
investmentValueWithCurrencyEffect: 850,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 850,
totalAccountBalance: 0,
totalInvestment: 910,
totalInvestmentValueWithCurrencyEffect: 850,
value: 910,
valueWithCurrencyEffect: 850
});
/**
* Net performance with currency effect: (1000 * 0.86) - (1000 * 0.85) = 10 CHF
* Total investment: 1000 USD * 0.91 = 910 CHF
* Total investment value with currency effect: 1000 USD * 0.85 = 850 CHF
* Value (current): 1000 USD * 0.91 = 910 CHF
* Value with currency effect: 1000 USD * 0.86 = 860 CHF
*/
expect(historicalData20240101).toMatchObject({
date: '2024-01-01',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0.011764705882352941,
netPerformanceWithCurrencyEffect: 10,
netWorth: 860,
totalAccountBalance: 0,
totalInvestment: 910,
totalInvestmentValueWithCurrencyEffect: 850,
value: 910,
valueWithCurrencyEffect: 860
});
/**
* Investment value with currency effect: 1000 USD * 0.90 = 900 CHF
* Net performance: (1000 USD * 1.0) - (1000 USD * 1.0) = 0 CHF
* Net performance with currency effect: (1000 USD * 0.9) - (1000 USD * 0.85) = 50 CHF
* Total investment: 2000 USD * 0.91 = 1820 CHF
* Total investment value with currency effect: (1000 USD * 0.85) + (1000 USD * 0.90) = 1750 CHF
* Value (current): 2000 USD * 0.91 = 1820 CHF
* Value with currency effect: 2000 USD * 0.9 = 1800 CHF
*/
expect(historicalData20241231).toMatchObject<HistoricalDataItem>({
date: '2024-12-31',
investmentValueWithCurrencyEffect: 900,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0.058823529411764705,
netPerformanceWithCurrencyEffect: 50,
netWorth: 1800,
totalAccountBalance: 0,
totalInvestment: 1820,
totalInvestmentValueWithCurrencyEffect: 1750,
value: 1820,
valueWithCurrencyEffect: 1800
});
});
});
});

5
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 {
activityDummyData,
symbolProfileDummyData,
@ -14,13 +13,13 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -31,7 +30,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -41,7 +39,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

6
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 {
activityDummyData,
symbolProfileDummyData,
@ -15,13 +14,13 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -32,7 +31,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -42,7 +40,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
@ -53,7 +50,6 @@ jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})

5
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 {
activityDummyData,
symbolProfileDummyData,
@ -14,13 +13,13 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -31,7 +30,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -41,7 +39,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

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

@ -0,0 +1,144 @@
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get transaction point', () => {
it('with MSFT buy and sell with fractional quantities (multiples of 1/3)', () => {
jest.useFakeTimers().setSystemTime(parseDate('2024-04-01').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2024-03-08'),
feeInAssetProfileCurrency: 0,
quantity: 0.3333333333333333,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 408
},
{
...activityDummyData,
date: new Date('2024-03-13'),
quantity: 0.6666666666666666,
feeInAssetProfileCurrency: 0,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 400
},
{
...activityDummyData,
date: new Date('2024-03-14'),
quantity: 1,
feeInAssetProfileCurrency: 0,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'SELL',
unitPriceInAssetProfileCurrency: 411
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});
const transactionPoints = portfolioCalculator.getTransactionPoints();
const lastTransactionPoint =
transactionPoints[transactionPoints.length - 1];
const position = lastTransactionPoint.items.find(
(item) => item.symbol === 'MSFT'
);
expect(position.investment.toNumber()).toBe(0);
expect(position.quantity.toNumber()).toBe(0);
});
});
});

5
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 {
activityDummyData,
symbolProfileDummyData,
@ -14,13 +13,13 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -31,7 +30,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -41,7 +39,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

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

@ -15,7 +15,6 @@ import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -26,7 +25,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -36,7 +34,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

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

@ -1,8 +1,6 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -16,15 +14,14 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -35,7 +32,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -45,7 +41,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
@ -53,7 +48,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -63,7 +58,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
exportResponse = loadExportFile(
join(
__dirname,
'../../../../../../../test/import/ok/novn-buy-and-sell-partially.json'
@ -100,28 +95,27 @@ describe('PortfolioCalculator', () => {
it.only('with NOVN.SW buy and sell partially', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});

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

@ -1,8 +1,6 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -16,15 +14,14 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -35,7 +32,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -45,7 +41,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
@ -53,7 +48,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -63,7 +58,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
exportResponse = loadExportFile(
join(
__dirname,
'../../../../../../../test/import/ok/novn-buy-and-sell.json'
@ -100,28 +95,27 @@ describe('PortfolioCalculator', () => {
it.only('with NOVN.SW buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});

5
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 {
activityDummyData,
symbolProfileDummyData,
@ -14,13 +13,13 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -31,7 +30,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -41,7 +39,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

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

@ -188,6 +188,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
})
);
const isCash = orders[0]?.SymbolProfile?.assetSubClass === 'CASH';
if (orders.length <= 0) {
return {
currentValues: {},
@ -244,6 +246,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
// For BUY / SELL activities with a MANUAL data source where no historical market price is available,
// the calculation should fall back to using the activity’s unit price.
unitPriceAtEndDate = latestActivity.unitPrice;
} else if (isCash) {
unitPriceAtEndDate = new Big(1);
}
if (
@ -295,7 +299,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
type: 'BUY',
unitPrice: unitPriceAtStartDate
@ -308,7 +313,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
itemType: 'end',
SymbolProfile: {
dataSource,
symbol
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
quantity: new Big(0),
type: 'BUY',
@ -348,7 +354,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,

4
apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts

@ -1,8 +1,8 @@
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DateQuery } from './date-query.interface';
export interface GetValuesParams {
dataGatheringItems: IDataGatheringItem[];
dataGatheringItems: DataGatheringItem[];
dateQuery: DateQuery;
}

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'> {
date: string;
@ -6,7 +6,7 @@ export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> {
quantity: Big;
SymbolProfile: Pick<
Activity['SymbolProfile'],
'currency' | 'dataSource' | 'name' | 'symbol' | 'userId'
'assetSubClass' | 'currency' | 'dataSource' | 'name' | 'symbol' | 'userId'
>;
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';
export interface TransactionPointSymbol {
assetSubClass: AssetSubClass;
averagePrice: Big;
currency: string;
dataSource: DataSource;

81
apps/api/src/app/portfolio/portfolio.controller.ts

@ -19,10 +19,10 @@ import {
} from '@ghostfolio/common/config';
import {
PortfolioDetails,
PortfolioDividends,
PortfolioDividendsResponse,
PortfolioHoldingResponse,
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioInvestmentsResponse,
PortfolioPerformanceResponse,
PortfolioReportResponse
} from '@ghostfolio/common/interfaces';
@ -195,10 +195,9 @@ export class PortfolioController {
'excludedAccountsAndActivities',
'fees',
'filteredValueInBaseCurrency',
'fireWealth',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'interest',
'interestInBaseCurrency',
'items',
'liabilities',
'netPerformance',
@ -306,7 +305,7 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<PortfolioDividends> {
): Promise<PortfolioDividendsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -440,7 +439,7 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<PortfolioInvestments> {
): Promise<PortfolioInvestmentsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -611,36 +610,6 @@ export class PortfolioController {
return performanceInformation;
}
/**
* @deprecated
*/
@Get('position/:dataSource/:symbol')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPosition(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<PortfolioHoldingResponse> {
const holding = await this.portfolioService.getHolding({
dataSource,
impersonationId,
symbol,
userId: this.request.user.id
});
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return holding;
}
@Get('report')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getReport(
@ -655,8 +624,8 @@ export class PortfolioController {
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
for (const rule in report.xRay.rules) {
report.xRay.rules[rule] = null;
for (const category of report.xRay.categories) {
category.rules = null;
}
report.xRay.statistics = {
@ -700,40 +669,4 @@ export class PortfolioController {
userId: this.request.user.id
});
}
/**
* @deprecated
*/
@HasPermission(permissions.updateOrder)
@Put('position/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updatePositionTags(
@Body() data: UpdateHoldingTagsDto,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
const holding = await this.portfolioService.getHolding({
dataSource,
impersonationId,
symbol,
userId: this.request.user.id
});
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
await this.portfolioService.updateTags({
dataSource,
impersonationId,
symbol,
tags: data.tags,
userId: this.request.user.id
});
}
}

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

File diff suppressed because it is too large

4
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 {
PortfolioReportRule,
RuleSettings,
UserSettings
} from '@ghostfolio/common/interfaces';
@ -22,7 +22,6 @@ export class RulesService {
return {
evaluation,
value,
categoryName: rule.getCategoryName(),
configuration: rule.getConfiguration(),
isActive: true,
key: rule.getKey(),
@ -30,7 +29,6 @@ export class RulesService {
};
} else {
return {
categoryName: rule.getCategoryName(),
isActive: false,
key: rule.getKey(),
name: rule.getName()

2
apps/api/src/app/redis-cache/redis-cache.service.ts

@ -4,9 +4,9 @@ import { AssetProfileIdentifier, Filter } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { createHash } from 'crypto';
import Keyv from 'keyv';
import ms from 'ms';
import { createHash } from 'node:crypto';
@Injectable()
export class RedisCacheService {

11
apps/api/src/app/subscription/subscription.controller.ts

@ -5,7 +5,10 @@ import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_COUPONS
} from '@ghostfolio/common/config';
import { Coupon } from '@ghostfolio/common/interfaces';
import {
Coupon,
CreateStripeCheckoutSessionResponse
} from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -111,11 +114,11 @@ export class SubscriptionController {
@Post('stripe/checkout-session')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createCheckoutSession(
public createStripeCheckoutSession(
@Body() { couponId, priceId }: { couponId?: string; priceId: string }
) {
): Promise<CreateStripeCheckoutSessionResponse> {
try {
return this.subscriptionService.createCheckoutSession({
return this.subscriptionService.createStripeCheckoutSession({
couponId,
priceId,
user: this.request.user

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

@ -5,13 +5,16 @@ import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_STRIPE_CONFIG
} from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { parseDate } from '@ghostfolio/common/helper';
import { SubscriptionOffer } from '@ghostfolio/common/interfaces';
import {
CreateStripeCheckoutSessionResponse,
SubscriptionOffer
} from '@ghostfolio/common/interfaces';
import {
SubscriptionOfferKey,
UserWithSettings
} from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Injectable, Logger } from '@nestjs/common';
import { Subscription } from '@prisma/client';
@ -32,13 +35,13 @@ export class SubscriptionService {
this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'),
{
apiVersion: '2025-08-27.basil'
apiVersion: '2025-12-15.clover'
}
);
}
}
public async createCheckoutSession({
public async createStripeCheckoutSession({
couponId,
priceId,
user
@ -46,7 +49,7 @@ export class SubscriptionService {
couponId?: string;
priceId: string;
user: UserWithSettings;
}) {
}): Promise<CreateStripeCheckoutSessionResponse> {
const subscriptionOffers: {
[offer in SubscriptionOfferKey]: SubscriptionOffer;
} =
@ -58,33 +61,34 @@ export class SubscriptionService {
}
);
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get('ROOT_URL')}/${
user.settings.settings.language
}/account`,
client_reference_id: user.id,
line_items: [
{
price: priceId,
quantity: 1
}
],
locale:
(user.settings?.settings
?.language as Stripe.Checkout.SessionCreateParams.Locale) ??
DEFAULT_LANGUAGE_CODE,
metadata: subscriptionOffer
? { subscriptionOffer: JSON.stringify(subscriptionOffer) }
: {},
mode: 'payment',
payment_method_types: ['card'],
success_url: `${this.configurationService.get(
'ROOT_URL'
)}/api/v1/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
};
const stripeCheckoutSessionCreateParams: Stripe.Checkout.SessionCreateParams =
{
cancel_url: `${this.configurationService.get('ROOT_URL')}/${
user.settings.settings.language
}/account`,
client_reference_id: user.id,
line_items: [
{
price: priceId,
quantity: 1
}
],
locale:
(user.settings?.settings
?.language as Stripe.Checkout.SessionCreateParams.Locale) ??
DEFAULT_LANGUAGE_CODE,
metadata: subscriptionOffer
? { subscriptionOffer: JSON.stringify(subscriptionOffer) }
: {},
mode: 'payment',
payment_method_types: ['card'],
success_url: `${this.configurationService.get(
'ROOT_URL'
)}/api/v1/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
};
if (couponId) {
checkoutSessionCreateParams.discounts = [
stripeCheckoutSessionCreateParams.discounts = [
{
coupon: couponId
}
@ -92,11 +96,12 @@ export class SubscriptionService {
}
const session = await this.stripe.checkout.sessions.create(
checkoutSessionCreateParams
stripeCheckoutSessionCreateParams
);
return {
sessionId: session.id
sessionId: session.id,
sessionUrl: session.url
};
}
@ -175,6 +180,8 @@ export class SubscriptionService {
offerKey = 'renewal-early-bird-2023';
} else if (isBefore(createdAt, parseDate('2024-01-01'))) {
offerKey = 'renewal-early-bird-2024';
} else if (isBefore(createdAt, parseDate('2025-12-01'))) {
offerKey = 'renewal-early-bird-2025';
}
const offer = await this.getSubscriptionOffer({

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

@ -1,8 +1,11 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { LookupResponse } from '@ghostfolio/common/interfaces';
import {
DataProviderHistoricalResponse,
LookupResponse,
SymbolItem
} from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -22,7 +25,6 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isDate, isEmpty } from 'lodash';
import { SymbolItem } from './interfaces/symbol-item.interface';
import { SymbolService } from './symbol.service';
@Controller('symbol')
@ -97,7 +99,7 @@ export class SymbolController {
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string
): Promise<IDataProviderHistoricalResponse> {
): Promise<DataProviderHistoricalResponse> {
const date = parseISO(dateString);
if (!isDate(date)) {

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

@ -1,21 +1,18 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import {
IDataGatheringItem,
IDataProviderHistoricalResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
DataProviderHistoricalResponse,
HistoricalDataItem,
LookupResponse
LookupResponse,
SymbolItem
} from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { format, subDays } from 'date-fns';
import { SymbolItem } from './interfaces/symbol-item.interface';
@Injectable()
export class SymbolService {
public constructor(
@ -27,7 +24,7 @@ export class SymbolService {
dataGatheringItem,
includeHistoricalData
}: {
dataGatheringItem: IDataGatheringItem;
dataGatheringItem: DataGatheringItem;
includeHistoricalData?: number;
}): Promise<SymbolItem> {
const quotes = await this.dataProviderService.getQuotes({
@ -75,10 +72,10 @@ export class SymbolService {
dataSource,
date = new Date(),
symbol
}: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> {
}: DataGatheringItem): Promise<DataProviderHistoricalResponse> {
let historicalData: {
[symbol: string]: {
[date: string]: IDataProviderHistoricalResponse;
[date: string]: DataProviderHistoricalResponse;
};
} = {
[symbol]: {}

16
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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DeleteOwnUserDto,
UpdateOwnAccessTokenDto,
UpdateUserSettingDto
} from '@ghostfolio/common/dtos';
import {
AccessTokenResponse,
User,
UserItem,
UserSettings
} from '@ghostfolio/common/interfaces';
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 { 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';
@Controller('user')
@ -126,11 +128,7 @@ export class UserController {
);
}
const hasAdmin = await this.userService.hasAdmin();
const { accessToken, id, role } = await this.userService.createUser({
data: { role: hasAdmin ? 'USER' : 'ADMIN' }
});
const { accessToken, id, role } = await this.userService.createUser();
return {
accessToken,

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

@ -48,9 +48,9 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client';
import { createHmac } from 'crypto';
import { differenceInDays, subDays } from 'date-fns';
import { sortBy, without } from 'lodash';
import { createHmac } from 'node:crypto';
@Injectable()
export class UserService {
@ -184,6 +184,7 @@ export class UserService {
userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> {
const {
_count,
accessesGet,
accessToken,
accounts,
@ -199,6 +200,11 @@ export class UserService {
updatedAt
} = await this.prismaService.user.findUnique({
include: {
_count: {
select: {
activities: true
}
},
accessesGet: true,
accounts: {
include: { platform: true }
@ -210,6 +216,8 @@ export class UserService {
where: userWhereUniqueInput
});
const activitiesCount = _count?.activities ?? 0;
const user: UserWithSettings = {
accessesGet,
accessToken,
@ -240,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
if (!(user.settings.settings as UserSettings)?.baseCurrency) {
(user.settings.settings as UserSettings).baseCurrency = DEFAULT_CURRENCY;
@ -257,6 +270,21 @@ export class UserService {
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
if (!(user.settings.settings as UserSettings)?.safeWithdrawalRate) {
(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
if (!(user.settings.settings as UserSettings).viewMode) {
(user.settings.settings as UserSettings).viewMode = 'DEFAULT';
@ -404,13 +432,13 @@ export class UserService {
);
let frequency = 7;
if (daysSinceRegistration > 720) {
if (activitiesCount > 1000 || daysSinceRegistration > 720) {
frequency = 1;
} else if (daysSinceRegistration > 360) {
} else if (activitiesCount > 750 || daysSinceRegistration > 360) {
frequency = 2;
} else if (daysSinceRegistration > 180) {
} else if (activitiesCount > 500 || daysSinceRegistration > 180) {
frequency = 3;
} else if (daysSinceRegistration > 60) {
} else if (activitiesCount > 250 || daysSinceRegistration > 60) {
frequency = 4;
} else if (daysSinceRegistration > 30) {
frequency = 5;
@ -513,15 +541,23 @@ export class UserService {
});
}
public async createUser({
data
}: {
data: Prisma.UserCreateInput;
}): Promise<User> {
if (!data?.provider) {
public async createUser(
{
data
}: {
data: Prisma.UserCreateInput;
} = { data: {} }
): Promise<User> {
if (!data.provider) {
data.provider = 'ANONYMOUS';
}
if (!data.role) {
const hasAdmin = await this.hasAdmin();
data.role = hasAdmin ? 'USER' : 'ADMIN';
}
const user = await this.prismaService.user.create({
data: {
...data,

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

File diff suppressed because it is too large

1
apps/api/src/assets/sitemap.xml

@ -5,5 +5,6 @@
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
${publicRoutes}
${blogPosts}
${personalFinanceTools}
</urlset>

3
apps/api/src/dependencies.ts

@ -0,0 +1,3 @@
// Dependencies required by .config/prisma.ts in Docker container
import 'dotenv';
import 'dotenv-expand';

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 { 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 { AssetProfileChangedListener } from './asset-profile-changed.listener';
import { PortfolioChangedListener } from './portfolio-changed.listener';
@Module({
imports: [RedisCacheModule],
providers: [PortfolioChangedListener]
imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
OrderModule,
RedisCacheModule
],
providers: [AssetProfileChangedListener, PortfolioChangedListener]
})
export class EventsModule {}

26
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', () => {
it('should redact provided attributes', () => {
@ -1536,7 +1556,7 @@ describe('redactAttributes', () => {
fireWealth: null,
grossPerformance: null,
grossPerformanceWithCurrencyEffect: null,
interest: null,
interestInBaseCurrency: null,
items: null,
liabilities: null,
totalInvestment: null,
@ -3039,7 +3059,7 @@ describe('redactAttributes', () => {
fireWealth: null,
grossPerformance: null,
grossPerformanceWithCurrencyEffect: null,
interest: null,
interestInBaseCurrency: null,
items: null,
liabilities: null,
totalInvestment: null,

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

@ -1,4 +1,5 @@
import { Big } from 'big.js';
import jsonpath from 'jsonpath';
import { cloneDeep, isArray, isObject } from 'lodash';
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({
isFirstRun = true,
object,

2
apps/api/src/helper/string.helper.ts

@ -1,4 +1,4 @@
import { randomBytes } from 'crypto';
import { randomBytes } from 'node:crypto';
export function getRandomString(length: number) {
const bytes = randomBytes(length);

8
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';
@Injectable()
export class RedactValuesInResponseInterceptor<T>
implements NestInterceptor<T, any>
{
export class RedactValuesInResponseInterceptor<T> implements NestInterceptor<
T,
any
> {
public intercept(
context: ExecutionContext,
next: CallHandler<T>
@ -61,6 +62,7 @@ export class RedactValuesInResponseInterceptor<T>
'totalInterestInBaseCurrency',
'totalValueInBaseCurrency',
'unitPrice',
'unitPriceInAssetProfileCurrency',
'value',
'valueInBaseCurrency'
].map((attribute) => {

35
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';
@Injectable()
export class TransformDataSourceInRequestInterceptor<T>
implements NestInterceptor<T, any>
{
export class TransformDataSourceInRequestInterceptor<
T
> implements NestInterceptor<T, any> {
public constructor(
private readonly configurationService: ConfigurationService
) {}
@ -27,9 +27,23 @@ export class TransformDataSourceInRequestInterceptor<T>
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (request.body?.activities) {
const dataSourceGhostfolioDataProvider = this.configurationService.get(
'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER'
)?.[0];
request.body.activities = request.body.activities.map((activity) => {
if (DataSource[activity.dataSource]) {
return activity;
if (
activity.dataSource === 'GHOSTFOLIO' &&
dataSourceGhostfolioDataProvider
) {
return {
...activity,
dataSource: dataSourceGhostfolioDataProvider
};
} else {
return activity;
}
} else {
return {
...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();

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

Loading…
Cancel
Save