Browse Source

merge: Resolve merge conflicts with upstream main

### 為什麼改 (Why)

- **變更目標**:同步 Ghostfolio 官方最新 upstream/main 主線,並解除合流衝突。
- **動機與痛點**:使 PR 能在 GitHub 上呈現綠色可直接合併狀態 (Able to merge)。

### 改了什麼 (What)

- **合流衝突解除**:成功合併 `CHANGELOG.md` (放置於 3.44.0 區塊)、`config.ts` (`as const` 與 `zh-TW`) 及 `user-account-settings.html`。

### 驗證狀態 (Verification)

- [x] 衝突全數解除,0 Conflict Markers。
pull/7565/head
JUDRAGONII 2 weeks ago
parent
commit
f875b0e4b0
  1. 72
      .agents/skills/karpathy-guidelines/SKILL.md
  2. 1
      .claude/skills/karpathy-guidelines
  3. 14
      .git_commit_msg.txt
  4. 1
      .gitignore
  5. 1
      .npmrc
  6. 12
      .vscode/launch.json
  7. 784
      CHANGELOG.md
  8. 6
      DEVELOPMENT.md
  9. 109
      README.md
  10. 4
      apps/api/jest.config.ts
  11. 26
      apps/api/project.json
  12. 18
      apps/api/src/app/access/access.controller.ts
  13. 7
      apps/api/src/app/access/access.service.ts
  14. 3
      apps/api/src/app/account-balance/account-balance.module.ts
  15. 24
      apps/api/src/app/account-balance/account-balance.service.ts
  16. 123
      apps/api/src/app/account/account.controller.ts
  17. 6
      apps/api/src/app/account/account.module.ts
  18. 197
      apps/api/src/app/account/account.service.ts
  19. 4
      apps/api/src/app/account/interfaces/cash-details.interface.ts
  20. 21
      apps/api/src/app/activities/activities-filter.dto.ts
  21. 119
      apps/api/src/app/activities/activities.controller.ts
  22. 6
      apps/api/src/app/activities/activities.module.ts
  23. 360
      apps/api/src/app/activities/activities.service.ts
  24. 27
      apps/api/src/app/activities/get-activities.dto.ts
  25. 96
      apps/api/src/app/admin/admin.controller.ts
  26. 4
      apps/api/src/app/admin/admin.module.ts
  27. 658
      apps/api/src/app/admin/admin.service.ts
  28. 29
      apps/api/src/app/admin/pipes/property-key.pipe.ts
  29. 2
      apps/api/src/app/admin/queue/queue.service.ts
  30. 87
      apps/api/src/app/app.module.ts
  31. 14
      apps/api/src/app/asset/asset.controller.ts
  32. 4
      apps/api/src/app/asset/asset.module.ts
  33. 7
      apps/api/src/app/auth/api-key.strategy.ts
  34. 24
      apps/api/src/app/auth/auth.controller.ts
  35. 20
      apps/api/src/app/auth/auth.module.ts
  36. 4
      apps/api/src/app/auth/google.strategy.ts
  37. 7
      apps/api/src/app/auth/jwt.strategy.ts
  38. 9
      apps/api/src/app/auth/oidc.strategy.ts
  39. 75
      apps/api/src/app/auth/web-auth.service.ts
  40. 18
      apps/api/src/app/endpoints/ai/ai.controller.ts
  41. 2
      apps/api/src/app/endpoints/ai/ai.module.ts
  42. 236
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts
  43. 38
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts
  44. 587
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts
  45. 32
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  46. 2
      apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
  47. 15
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  48. 12
      apps/api/src/app/endpoints/benchmarks/get-benchmark-market-data.dto.ts
  49. 69
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  50. 4
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts
  51. 80
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  52. 98
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  53. 12
      apps/api/src/app/endpoints/market-data/market-data.module.ts
  54. 81
      apps/api/src/app/endpoints/public/public.controller.ts
  55. 2
      apps/api/src/app/endpoints/public/public.module.ts
  56. 7
      apps/api/src/app/endpoints/tags/tags.controller.ts
  57. 30
      apps/api/src/app/endpoints/watchlist/watchlist.service.ts
  58. 43
      apps/api/src/app/export/export.controller.ts
  59. 61
      apps/api/src/app/export/export.service.ts
  60. 14
      apps/api/src/app/export/get-export.dto.ts
  61. 4
      apps/api/src/app/health/health.controller.ts
  62. 4
      apps/api/src/app/health/health.service.ts
  63. 7
      apps/api/src/app/import/import-data.dto.ts
  64. 9
      apps/api/src/app/import/import.controller.ts
  65. 421
      apps/api/src/app/import/import.service.ts
  66. 2
      apps/api/src/app/info/info.module.ts
  67. 31
      apps/api/src/app/info/info.service.ts
  68. 9
      apps/api/src/app/logo/get-logo.dto.ts
  69. 3
      apps/api/src/app/logo/logo.controller.ts
  70. 2
      apps/api/src/app/logo/logo.module.ts
  71. 22
      apps/api/src/app/logo/logo.service.ts
  72. 2
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  73. 248
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  74. 31
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  75. 465
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  76. 31
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  77. 43
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  78. 19
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts
  79. 25
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  80. 31
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  81. 17
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  82. 23
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  83. 336
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  84. 21
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  85. 19
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  86. 383
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts
  87. 19
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  88. 43
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts
  89. 31
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  90. 7
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-activities.spec.ts
  91. 17
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  92. 531
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  93. 19
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  94. 140
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  95. 4
      apps/api/src/app/portfolio/current-rate.service.ts
  96. 7
      apps/api/src/app/portfolio/errors/portfolio-snapshot-computation.error.ts
  97. 12
      apps/api/src/app/portfolio/get-details.dto.ts
  98. 10
      apps/api/src/app/portfolio/get-dividends.dto.ts
  99. 14
      apps/api/src/app/portfolio/get-holdings.dto.ts
  100. 10
      apps/api/src/app/portfolio/get-investments.dto.ts

72
.agents/skills/karpathy-guidelines/SKILL.md

@ -0,0 +1,72 @@
---
name: karpathy-guidelines
description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.
license: MIT
---
# Karpathy Guidelines
Behavioral guidelines to reduce common LLM coding mistakes, derived from [Andrej Karpathy's observations](https://x.com/karpathy/status/2015883857489522876) on LLM coding pitfalls.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.

1
.claude/skills/karpathy-guidelines

@ -0,0 +1 @@
../../.agents/skills/karpathy-guidelines

14
.git_commit_msg.txt

@ -0,0 +1,14 @@
merge: Resolve merge conflicts with upstream main
### 為什麼改 (Why)
- **變更目標**:同步 Ghostfolio 官方最新 upstream/main 主線,並解除合流衝突。
- **動機與痛點**:使 PR 能在 GitHub 上呈現綠色可直接合併狀態 (Able to merge)。
### 改了什麼 (What)
- **合流衝突解除**:成功合併 `CHANGELOG.md` (放置於 3.44.0 區塊)、`config.ts` (`as const` 與 `zh-TW`) 及 `user-account-settings.html`。
### 驗證狀態 (Verification)
- [x] 衝突全數解除,0 Conflict Markers。

1
.gitignore

@ -32,6 +32,7 @@ npm-debug.log
.env.prod .env.prod
.github/instructions/nx.instructions.md .github/instructions/nx.instructions.md
.nx/cache .nx/cache
.nx/migrate-runs
.nx/polygraph .nx/polygraph
.nx/self-healing .nx/self-healing
.nx/workspace-data .nx/workspace-data

1
.npmrc

@ -0,0 +1 @@
min-release-age=7

12
.vscode/launch.json

@ -18,12 +18,20 @@
"autoAttachChildProcesses": true, "autoAttachChildProcesses": true,
"console": "integratedTerminal", "console": "integratedTerminal",
"cwd": "${workspaceFolder}/apps/api", "cwd": "${workspaceFolder}/apps/api",
"envFile": "${workspaceFolder}/.env", "env": {
"GHOSTFOLIO_ENV_FILE": "${workspaceFolder}/.env"
},
"name": "Debug API", "name": "Debug API",
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"], "outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
"program": "${workspaceFolder}/apps/api/src/main.ts", "program": "${workspaceFolder}/apps/api/src/main.ts",
"request": "launch", "request": "launch",
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"], "runtimeArgs": [
"--nolazy",
"-r",
"ts-node/register",
"-r",
"${workspaceFolder}/tools/load-env.ts"
],
"skipFiles": [ "skipFiles": [
"${workspaceFolder}/node_modules/**/*.js", "${workspaceFolder}/node_modules/**/*.js",
"<node_internals>/**/*.js" "<node_internals>/**/*.js"

784
CHANGELOG.md

@ -5,11 +5,789 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased ## 3.44.0 - 2026-08-07
### Added ### Added
- Added support for Traditional Chinese (`zh-TW`) locale - Added support for Traditional Chinese (`zh-TW`) locale
- Added a live preview of the date and number format to the user settings
- Added the country flag to the currency selector
- Added a _Storybook_ story for the currency selector component
- Added the platform logo to the account selectors in the transfer cash balance dialog
- Extended the entity logo component by a `hasPlaceholder` attribute to reserve the space of a missing logo
- Warmed up the portfolio snapshot calculation in the background during the biometric authentication
### Changed
- Improved the usability of the create watchlist item dialog by setting the initial focus to the search field
- Migrated the abstract _Material_ form field from a component to a directive
- Removed the redundant `balance` attribute of the account in favor of the account balances
### Fixed
- Fixed the values of the charts and tables in impersonation mode with an unrestricted access to show absolute values instead of percentages
- Fixed the savings rate of the investment timeline chart and the streaks on the analysis page in impersonation mode to be based on the impersonated user
- Fixed the savings rate of the _FIRE_ calculator in impersonation mode to not be based on the impersonating user
## 3.43.0 - 2026-08-06
### Added
- Added the platform logo to the platform selector in the create or update account dialog
- Added the platform logo to the account selector in the create or update activity dialog
- Extended the value component by an `isLoading` attribute to distinguish the loading state from redacted values
### Changed
- Guarded the system tags against deletion and renaming in the tag management of the admin control panel
- Improved the language localization for Spanish (`es`)
### Fixed
- Handled an exception in the country weightings parsing of the _Financial Modeling Prep_ service
## 3.42.0 - 2026-08-04
### Changed
- Improved the usability of the portfolio summary by collapsing the _Holdings_ and _Cash_ breakdowns by default
- Extended the support of the _Exclude from Analysis_ tag from accounts to activities
- Optimized the performance of the search in the assistant by reusing the cached portfolio snapshot
- Improved the validation of the import functionality when referencing an asset profile with the data source `MANUAL`
- Improved the validation of the endpoint to add a custom asset profile in the admin control panel
### Fixed
- Fixed the fuzzy search for the holdings in the assistant
## 3.41.0 - 2026-08-03
### Added
- Added support for the account platforms in the activities import
- Added the database model and endpoints to manage the stock splits of an asset profile (experimental)
### Changed
- Improved the usability of the admin control panel by eliminating the page reload on changing a setting
- Improved the usability of the admin control panel by eliminating the page reload on deleting an asset profile
- Improved the usability of the admin control panel by eliminating the page reload on flushing the cache
- Improved the usability of the admin control panel by eliminating the page reload on gathering historical market data
- Improved the language localization for German (`de`)
### Fixed
- Fixed the loading state in the user detail dialog of the admin control panel’s users section
- Fixed a race condition where the portfolio snapshot computation was completed before its result had been cached, causing a redundant recomputation
- Fixed an endless loop in the portfolio snapshot computation if the computed result could not be read from the cache
## 3.40.0 - 2026-08-02
### Changed
- Improved the style of the read-only tags in the tags selector component
- Improved the language localization for Chinese (`zh`)
- Upgraded `nestjs` from version `11.1.27` to `11.1.28`
### Fixed
- Fixed the handling of the _Exclude from Analysis_ tag in the activities table
- Fixed the persistence of an empty comment in the create or update account dialog
- Resolved a validation error caused by empty strings in the asset profile details dialog of the admin control panel
## 3.39.0 - 2026-08-01
### Changed
- Harmonized the data format of the export functionality
- Removed the deprecated `firstOrderDate` attribute from the `GET api/v2/portfolio/performance` endpoint response
- Removed the deprecated `isExcluded` attribute of the account in favor of the _Exclude from Analysis_ tag including a data migration
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `7.8.0` to `7.9.1`
### Fixed
- Fixed the scroll behavior of the page content behind an open dialog
- Fixed the export functionality to only include the accounts of the exported activities if a filter is applied
## 3.38.0 - 2026-07-31
### Added
- Added support for the date range filter in the export functionality
- Added support for the date range filter on the portfolio activities page
### Changed
- Improved the style of the tabs in the account detail dialog on mobile
- Improved the style of the tabs in the holding detail dialog on mobile
- Improved the style of the tabs in the asset profile dialog of the admin control panel on mobile
- Improved the style of the empty state in the _Fear & Greed Index_ component
- Added the activity count to the delete menu item of the activities table
- Added the activity count to the deletion confirmation dialog of the activities table
- Improved the style of the type filter in the activities table component (experimental)
- Improved the search functionality by trimming the query
- Improved the log output in the search functionality of the _Yahoo Finance_ service for unsupported queries
- Improved the performance of the property service by caching the properties in memory
- Improved the validation of the query parameters in the activities endpoints
- Improved the language localization for German (`de`)
### Fixed
- Fixed the calendar year date range in time zones with a negative _UTC_ offset
- Fixed the deletion of activities to respect the activity type filter on the activities page (experimental)
- Fixed the deletion of activities to respect the date range filter on the activities page
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Equity)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Fixed Income)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Currency Cluster Risks_ (Investment)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Currency Cluster Risks_ (Investment: Base Currency)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Economic Market Cluster Risks_ (Developed Markets)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Economic Market Cluster Risks_ (Emerging Markets)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Asia-Pacific)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Emerging Markets)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Europe)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Japan)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (North America)
## 3.37.0 - 2026-07-30
### Added
- Added an empty state to the _Fear & Greed Index_ component
- Added a _Storybook_ story for the _Fear & Greed Index_ component
### Changed
- Moved the tags to the overview tab of the account detail dialog (experimental)
- Moved the tags to the overview tab of the holding detail dialog
- Consolidated the markets pages into a single route where the _Fear & Greed Index_ is controlled by permission
- Refactored the line chart components to share the common chart configuration
- Improved the language localization for Spanish (`es`)
- Improved the language localization for Ukrainian (`uk`)
### Fixed
- Ignored future-dated account balances in the portfolio calculation
## 3.36.0 - 2026-07-29
### Added
- Added an overview tab to the account detail dialog
- Added the tags (read-only) to the account detail dialog (experimental)
### Changed
- Improved the portfolio summary tab on the home page
- Improved the language localization for German (`de`)
- Upgraded `@openrouter/ai-sdk-provider` from version `2.9.1` to `3.0.0`
- Upgraded `ai` from version `6.0.174` to `7.0.37`
### Fixed
- Fixed the time in market of the portfolio summary to be empty if there is no activity
- Fixed an issue with the delete button in the activities filter component
- Fixed the tags in the read-only mode of the tags selector component
## 3.35.0 - 2026-07-27
### Added
- Added a loading indicator to the access table to share the portfolio
### Changed
- Improved the portfolio summary by presenting the cash and the holdings as a breakdown of the total assets
- Improved the _FIRE_ calculator by including the cash which is not part of the emergency fund
- Improved the performance calculation and the value of the portfolio by excluding cash denominated in the base currency
- Extended the portfolio details endpoint to include the total assets and the total cash in the portfolio summary
- Deprecated `firstOrderDate` in favor of `dateOfFirstActivity` in the `GET api/v2/portfolio/performance` endpoint
- Improved the log output in the get asset profile functionality of the _Financial Modeling Prep_ service for delisted asset profiles
- Refreshed the cryptocurrencies list
- Upgraded `prettier` from version `3.8.4` to `3.9.6`
### Fixed
- Resolved an exception in the user service when getting a non-existent user
- Fixed the missing currency in the get quotes functionality of the _Financial Modeling Prep_ service for cryptocurrencies without an asset profile
## 3.34.0 - 2026-07-25
### Changed
- Included cash in the performance calculation of the portfolio
- Moved the support for tags in the account from experimental to general availability
- Improved the user experience of the users table in the admin control panel by eliminating the reload when opening and closing the user detail dialog
- Upgraded `countup.js` from version `2.10.0` to `2.10.1`
- Upgraded `dotenv` from version `17.2.3` to `17.4.2`
- Upgraded `dotenv-expand` from version `12.0.3` to `13.0.0`
- Upgraded `fuse.js` from version `7.3.0` to `7.5.0`
### Fixed
- Fixed the _Add activity_ link of the onboarding on the overview tab of the home page to open the create activity dialog
- Fixed the link of the no activities info component to open the create activity dialog
- Resolved an exception in the `POST api/v1/activities` endpoint when creating an activity with the update account balance option but without an account
## 3.33.0 - 2026-07-25
### Added
- Added the stack trace logging for `MaxListenersExceededWarning` occurrences
### Changed
- Moved the support to create custom tags from experimental to general availability
- Recomputed the portfolio snapshot calculation in the background on a portfolio change
- Improved the deduplication of the portfolio snapshot calculation jobs by considering the filters
- Refactored the deprecated animation providers (`provideAnimations()` and `provideNoopAnimations()`)
- Improved the language localization for German (`de`)
- Improved the language localization for Polish (`pl`)
### Fixed
- Fixed an issue with the localization in the _FIRE_ page
- Improved the spacing in the testimonial section on the landing page
## 3.32.0 - 2026-07-22
### Changed
- Upgraded `chartjs-chart-treemap` from version `3.1.0` to `4.2.0`
### Fixed
- Skipped opening the holding detail dialog for cash positions on the allocations page, the analysis page and the portfolio holdings page
- Resolved an exception in the `GET api/v1/portfolio/holding/:dataSource/:symbol` endpoint for cash positions
- Improved the error handling in the access endpoints (`POST` and `PUT`) to return `400 Bad Request` when granting access to a non-existent user
## 3.31.0 - 2026-07-20
### Changed
- Removed the deprecated `SymbolProfile` field from the activity interface
- Refactored the language redirect of the root path from the static file serving configuration to a dedicated middleware
- Upgraded `yahoo-finance2` from version `3.15.4` to `4.0.0`
### Fixed
- Fixed the `RangeNotSatisfiableError` for requests with a `Range` header to the root path caused by the empty `index.html` placeholder
- Fixed the unresolved template literal in the page title while the app is loading from the service worker cache
## 3.30.0 - 2026-07-19
### Added
- Added support for converting an asset profile to the `MANUAL` data source in the asset profile details dialog of the admin control panel
### Changed
- Extended the `extractNumberFromString()` function to support negative values
- Restricted the symbol data endpoint (`GET /api/v1/symbol/:dataSource/:symbol`) to authenticated users
- Removed the deprecated `auth` endpoint of the login with _Security Token_ (`GET`)
- Simplified the `getHistorical()` function response in the data provider interface
- Upgraded `bull-board` from version `8.0.1` to `8.1.2`
## 3.29.0 - 2026-07-18
### Added
- Added support for the _Fear & Greed Index_ (market mood) via the `GHOSTFOLIO` data provider in self-hosted environments
- Added a _Storybook_ story for the copy-to-clipboard functionality in the value component
### Changed
- Improved the copy-to-clipboard functionality in the value component by providing a visual confirmation
- Improved the language localization for German (`de`)
- Upgraded `stripe` from version `22.2.3` to `22.3.2`
### Fixed
- Fixed an issue with the delete button in the tags selector component
## 3.28.0 - 2026-07-17
### Changed
- Migrated the clone, create and edit activity dialogs to dedicated routes
- Improved the language localization in the historical market data table of the admin control panel
- Improved the language localization in the tag management of the admin control panel
### Fixed
- Fixed the missing validation of the tags when creating or updating an activity
- Fixed the missing validation of the tags when updating the tags of a holding
- Fixed an issue where the tags of an activity were lost if updating the activity failed
- Fixed an issue where the dividends, the interest and the liabilities of asset profiles without market data have been valued at zero in the portfolio calculation
- Fixed an issue where an error has been reported for asset profiles without market data which do not hold any units
- Fixed an issue with removing a linked account from a buy, sell or dividend activity
## 3.27.0 - 2026-07-15
### Changed
- Hardened the validation of the URL in the logo endpoint
- Set the change detection strategy to `OnPush` in the about pages
- Set the change detection strategy to `OnPush` in the accounts page
- Set the change detection strategy to `OnPush` in the demo page
- Set the change detection strategy to `OnPush` in the features page
- Set the change detection strategy to `OnPush` in the Frequently Asked Questions (FAQ) pages
- Set the change detection strategy to `OnPush` in the landing page
- Set the change detection strategy to `OnPush` in the markets page
- Set the change detection strategy to `OnPush` in the _Open Startup_ (`/open`) page
- Set the change detection strategy to `OnPush` in the pricing page
- Set the change detection strategy to `OnPush` in the public page
- Set the change detection strategy to `OnPush` in the registration page
- Set the change detection strategy to `OnPush` in the resources pages
### Fixed
- Fixed an issue where the symbol was not selected when cloning an activity
- Resolved a startup error in data gathering caused by uninitialized data provider mappings
- Improved the error handling in the `HtmlTemplateMiddleware`
- Improved the error handling in the get quotes functionality of the _Financial Modeling Prep_ service
## 3.26.0 - 2026-07-14
### Added
- Added the markets endpoint for the _Fear & Greed Index_ (market mood) to the `GHOSTFOLIO` data provider
### Changed
- Hardened the validation of the countries in the asset profile endpoints
- Hardened the validation of the holdings in the asset profile endpoints
- Hardened the validation of the scraper configuration in the asset profile endpoint
- Hardened the validation of the sectors in the asset profile endpoints
- Rounded the value of the _Fear & Greed Index_ (market mood) in the twitter bot service
- Set the change detection strategy to `OnPush` in the _X-ray_ page
- Deprecated `SymbolProfile` in favor of `assetProfile` in the activity interface
- Upgraded `countries-list` from version `3.3.0` to `3.4.0`
- Upgraded `Nx` from version `23.0.1` to `23.0.2`
## 3.25.0 - 2026-07-12
### Changed
- Changed the default value of the `DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS` environment variable from `RAPID_API` to `MANUAL`
- Improved the language localization for Dutch (`nl`)
- Upgraded `helmet` from version `7.0.0` to `8.2.0`
### Fixed
- Fixed the layout of the page tabs component by truncating long labels
- Fixed the display of assets without a currency in the search results of the assistant
- Fixed the display of assets without a currency in the symbol autocomplete component
### Todo
- **Breaking Change**: Set the environment variable `DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS=RAPID_API` to keep using _Rapid API_ as the data source of the _Fear & Greed Index_ (market mood)
## 3.24.0 - 2026-07-11
### Added
- Exposed the `DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS` environment variable to set the data source of the _Fear & Greed Index_ (market mood)
- Exposed the `ENABLE_FEATURE_RATE_LIMITING` environment variable to control rate limiting for authentication and sign-up endpoints
- Exposed the `TRUST_PROXY` environment variable to determine the client IP address when running behind a reverse proxy
### Changed
- Rounded the value of the _Fear & Greed Index_ (market mood)
- Improved the language localization for Korean (`ko`)
## 3.23.0 - 2026-07-10
### Changed
- Migrated the deprecated `@nx/webpack:webpack` executor to `@nx/webpack/plugin`
- Set the change detection strategy to `OnPush` in the about page
- Set the change detection strategy to `OnPush` in the admin control panel
- Set the change detection strategy to `OnPush` in the blog page components
- Set the change detection strategy to `OnPush` in the Frequently Asked Questions (FAQ) page
- Set the change detection strategy to `OnPush` in the home page
- Set the change detection strategy to `OnPush` in the markets overview
- Set the change detection strategy to `OnPush` in the resources page
- Set the change detection strategy to `OnPush` in the user account page
- Set the change detection strategy to `OnPush` in the _Zen Mode_
- Improved the language localization for Chinese (`zh`)
- Improved the language localization for German (`de`)
## 3.22.0 - 2026-07-08
### Added
- Added support for a copy-to-clipboard action in the alert dialog component
### Changed
- Improved the user account deletion flow in the user settings of the user account page
- Improved the date formatting of the first activity in the historical market data table of the admin control panel
- Set the change detection strategy to `OnPush` in the activities page
- Set the change detection strategy to `OnPush` in the allocations page
- Set the change detection strategy to `OnPush` in the analysis page
- Set the change detection strategy to `OnPush` in the portfolio holdings page
- Set the change detection strategy to `OnPush` in the activities page
- Set the change detection strategy to `OnPush` in the _FIRE_ page
- Set the change detection strategy to `OnPush` in the users section of the admin control panel
- Hardened the endpoint to update a property of the admin control panel by validating the `key` path parameter
- Renamed the `SymbolProfileOverrides` _Prisma_ data model to `AssetProfileOverrides` while keeping the database table name
- Improved the language localization for Dutch (`nl`)
- Improved the language localization for French (`fr`)
- Improved the language localization for German (`de`)
## 3.21.0 - 2026-07-05
### Added
- Added support for tags in the account (experimental)
- Exposed the `PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_REMOVE_ON_FAIL` environment variable to control the removal of failed jobs in the portfolio snapshot computation queue
### Changed
- Set the change detection strategy to `OnPush` in the alert dialog component
- Set the change detection strategy to `OnPush` in the confirmation dialog component
- Set the change detection strategy to `OnPush` in the prompt dialog component
- Set the change detection strategy to `OnPush` in the overview of the admin control panel
- Set the change detection strategy to `OnPush` in the portfolio page
- Deprecated the `isExcluded` attribute of the account in favor of the _Exclude from Analysis_ tag
- Improved the language localization in the users table of the admin control panel
- Improved the language localization for German (`de`)
- Upgraded `envalid` from version `8.1.1` to `8.2.0`
- Upgraded `stripe` from version `21.0.1` to `22.2.3`
### Fixed
- Fixed an issue with the custom tags of the user in the import functionality
- Fixed the creation of the _Stripe_ checkout session for languages not supported by _Stripe_ (`ca` and `uk`)
- Fixed the error handling in the endpoint to create a _Stripe_ checkout session
## 3.20.0 - 2026-07-04
### Changed
- Refactored the rounding logic in the holding detail dialog
- Refactored the rounding logic in the treemap chart component
- Restricted the modification of activity tags in the impersonation mode
- Hardened the endpoint of the public access for portfolio sharing by restricting it to public accesses
- Improved the parsing of integer query parameters (`skip` and `take`) in the `GET api/v1/admin/user` endpoint
- Improved the parsing of integer query parameters (`skip` and `take`) in the `GET api/v1/asset-profiles` endpoint
- Improved the parsing of the integer query parameter (`includeHistoricalData`) in the `GET api/v1/market-data/markets` endpoint
- Improved the parsing of the integer query parameter (`includeHistoricalData`) in the `GET api/v1/symbol/:dataSource/:symbol` endpoint
- Harmonized the filter parsing using `groupBy` across various services
- Improved the language localization by translating various tooltips across the application
- Improved the language localization for German (`de`)
- Improved the language localization for Ukrainian (`uk`)
- Upgraded `yahoo-finance2` from version `3.14.3` to `3.15.4`
### Fixed
- Resolved an issue in the treemap chart component when the holdings list is empty
- Fixed the handling of cash positions in the portfolio calculations when filtering by holding or tag
- Fixed the handling of cash positions in the portfolio details when filtering
- Fixed the market condition of the benchmarks in the twitter bot service when values round to zero
## 3.19.1 - 2026-07-03
### Added
- Added support for routing outgoing requests through a per-domain proxy via the `PROXY_ROUTES` setting in the `FetchService`
- Added `@prisma/config` as a development dependency used by the _Prisma Configuration File_
### Changed
- Harmonized the date picker styling across various components
- Updated the _Privacy Policy_
- Updated the _Terms of Service_
- Improved the parsing of integer query parameters (`skip` and `take`) in the `GET api/v1/activities` endpoint
- Improved the language localization for German (`de`)
- Improved the language localization for Japanese (`ja`)
- Upgraded `@ionic/angular` from version `8.8.5` to `8.8.12`
- Upgraded `nestjs` from version `11.1.21` to `11.1.27`
### Fixed
- Fixed an issue where values incorrectly rounded to negative zero in the value component
- Fixed the colorization of the change from all time high in the benchmark component when values round to zero
- Fixed the market condition of the benchmarks when values round to zero
- Fixed the validation of the data source field of an asset profile with market data
- Fixed a recurring issue where single-value fields were incorrectly validated as arrays in various endpoints
## 3.18.0 - 2026-06-28
### Added
- Added support for filtering in the public access for portfolio sharing (experimental)
- Set up the language localization for Japanese (`ja`)
### Changed
- Improved the alias display in the access table to share the portfolio
- Improved the language localization for German (`de`)
### Fixed
- Fixed a phantom `UNKNOWN` slice in the portfolio proportion chart component caused by floating-point rounding
- Fixed the base currency for the total value calculation in the public access for portfolio sharing
- Fixed an issue in the public access for portfolio sharing that exposed absolute values of the top holdings of ETFs
- Fixed the time zone handling in the `api` test suite for deterministic execution in `UTC`
## 3.17.0 - 2026-06-26
### Added
- Added `zod` as a root dependency to resolve peer dependency warnings
### Changed
- Improved the error message styling in the import activities dialog
- Improved the grantee display in the access table to share the portfolio
- Improved the country mapping for data providers
- Upgraded `bull-board` from version `7.2.1` to `8.0.1`
- Upgraded `Nx` from version `22.7.5` to `23.0.1`
- Upgraded `prettier` from version `3.8.3` to `3.8.4`
### Fixed
- Improved the table headers’ alignment in the queue jobs table of the admin control panel
## 3.16.0 - 2026-06-24
### Added
- Extended the user account settings with a copy-to-clipboard button for the user id
- Added pagination to the platform management of the admin control panel
- Added pagination to the tag management of the admin control panel
- Extended the asset profile details dialog of the admin control panel with a copy-to-clipboard button for the ISIN number
- Extended the asset profile details dialog of the admin control panel with a copy-to-clipboard button for the symbol
### Changed
- Improved the throughput of the market data gathering queue by applying the rate limit per data source
- Decreased the rate limiter duration of the market data gathering queue jobs from 4 to 3 seconds
- Removed the deprecated `SymbolProfile` field from the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `13.2.2` to `13.3`
### Fixed
- Fixed an issue with hourly market data updates not refreshing prices for asset profiles with `MANUAL` data source
- Fixed an issue with the log context formatting in the performance logging service
## 3.15.1 - 2026-06-23
### Changed
- Improved the dynamic numerical precision for various values in the account detail dialog on mobile
- Improved the dynamic numerical precision for various values in the holding detail dialog on mobile
- Upgraded `@internationalized/number` from version `3.6.6` to `3.6.7`
### Fixed
- Fixed an issue where symbols with special characters caused API request failures by URL encoding the symbol
- Fixed the disabled state of the delete action in the asset profiles actions menu of the historical market data table in the admin control panel
- Fixed the persistence of an empty `locale` string in the scraper configuration
- Fixed a transaction timeout that prevented gathering historical market data for symbols with a long history
- Fixed an exception in various portfolio endpoints when historical exchange rate data is missing
## 3.14.0 - 2026-06-22
### Added
- Exposed the `ENABLE_FEATURE_CRON` environment variable to control scheduled cron job execution
- Exposed the `PROCESSOR_GATHER_STATISTICS_CONCURRENCY` environment variable to control the concurrency of the statistics gathering queue processor
### Changed
- Consolidated the exchange rates to be gathered with hourly market data
- Improved the language localization for German (`de`)
- Upgraded `@openrouter/ai-sdk-provider` from version `2.9.0` to `2.9.1`
- Upgraded `undici` from version `7.24.4` to `8.5.0`
### Fixed
- Fixed an issue in the data provider service where asset profiles and historical data could be missing for symbols that exist in multiple data sources by keying the responses by the asset profile identifier
- Resolved an exception in the benchmarks service when the current market price is unavailable
## 3.13.0 - 2026-06-20
### Added
- Added an icon to indicate external links in the page tabs component
- Added the Korean (`ko`) language to the footer
- Added a data gathering frequency (`DAILY` or `HOURLY`) to the asset profile to control the market data gathering interval
### Changed
- Changed the _Fear & Greed Index_ (market mood) in the markets overview to use the stored market data instead of a live quote
- Moved the endpoint to get the asset profiles from `GET api/v1/admin/market-data` to `GET api/v1/asset-profiles`
- Moved the endpoint to get the asset profile details from `GET api/v1/market-data/:dataSource/:symbol` to `GET api/v1/asset-profiles/:dataSource/:symbol`
- Added the selected asset profile count to the delete menu item of the historical market data table in the admin control panel
- Added the selected asset profile count to the deletion confirmation dialog of the historical market data table in the admin control panel
- Improved the sorting to be case-insensitive in the platform management of the admin control panel
- Improved the sorting to be case-insensitive in the tag management of the admin control panel
- Improved the language localization for German (`de`)
- Upgraded `yahoo-finance2` from version `3.14.2` to `3.15.3`
### Fixed
- Fixed an issue with the localization of the country names
- Fixed an issue in the data provider service where quotes could be missing for symbols that exist in multiple data sources by keying the quotes response by the asset profile identifier
## 3.12.0 - 2026-06-17
### Changed
- Improved the styling of the checkboxes to consistently use the primary color in their states
- Improved the account name display in the accounts table
- Improved the name display in the activities table
- Improved the last activity display in the users table of the admin control panel
- Improved the registration display in the users table of the admin control panel
- Improved the user id display in the users table of the admin control panel
- Deprecated `SymbolProfile` in favor of `assetProfile` in the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Improved the language localization for German (`de`)
- Upgraded `svgmap` from version `2.19.3` to `2.21.0`
### Fixed
- Fixed a chart error on interaction by registering the annotation plugin early
- Fixed an issue on the allocations page where clicking an account in the _By Account_ chart did not open the detail dialog
- Restricted the maximum height of the import activities dialog
- Fixed the dark mode styling of the safe withdrawal rate selector in the _FIRE_ section (experimental)
## 3.11.0 - 2026-06-14
### Added
- Added support for a click handler in the page tabs component
### Changed
- Improved the styling of the tabs across various dialogs
- Improved the styling of the page tabs component on desktop
- Enabled the _Bull Dashboard_ tab in the admin control panel (experimental)
- Migrated the settings dialog to customize the rule thresholds of the _X-ray_ page from `ngModel` to form control
- Improved the language localization for Spanish (`es`)
- Upgraded `bull-board` from version `7.1.5` to `7.2.1`
- Upgraded `date-fns` from version `4.1.0` to `4.4.0`
### Fixed
- Improved the loading state when customizing the rule thresholds on the _X-ray_ page
## 3.10.0 - 2026-06-13
### Changed
- Improved the dynamic numerical precision for various values in the account detail dialog on mobile
- Improved the dynamic numerical precision for various values in the holding detail dialog on mobile
- Improved the account name display in the activities table
- Optimized the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol` by improving the processing of the historical market data
### Fixed
- Fixed an issue in the import dividends dialog
- Fixed an issue where certain symbols were incorrectly identified as currencies in various data providers
- Fixed the last request date in the users table of the admin control panel
## 3.9.0 - 2026-06-12
### Added
- Extended the _Public API_ with the endpoint to update the asset profile data (`PATCH api/v1/asset-profiles/:dataSource/:symbol`) (experimental)
- Added support for a dedicated _OpenRouter_ model for the `web_fetch` tool in the `FetchService`
### Changed
- Prefilled the form in the account balance management with the current cash balance
- Disabled the selection of future dates in the account balance management
- Grouped commodities and cryptocurrencies into the unknown bucket of the allocations by continent, country, currency, market and sector charts on the allocations page
- Moved the support for specific calendar year date ranges (`2025`, `2024`, `2023`, etc.) in the assistant from experimental to general availability
- Migrated various components from `NgStyle` to style bindings
- Improved the language localization for Korean (`ko`)
### Fixed
- Grouped activities without an account into the unknown bucket of the allocations by account and platform charts on the allocations page
## 3.8.0 - 2026-06-07
### Added
- Added an automatic refresh every 30 seconds to the users table in the admin control panel
### Changed
- Harmonized the sector names across the data providers
- Localized the country names
- Localized the sector names
- Centralized the asset profile override logic for manual adjustments
- Improved the styling in the user detail dialog of the admin control panel’s users section
- Prevented the deletion of asset profiles that are currently in use
- Ensured market data is correctly removed when an asset profile with no remaining activities is deleted
- Refactored the backend logging to use the instance-based `Logger`
- Improved the language localization for German (`de`)
- Improved the language localization for Ukrainian (`uk`)
### Fixed
- Prevented the floating action button from overlapping the paginator on mobile
- Fixed an issue where the asset profile override (asset class and asset sub class) was not applied to the data enhancers when gathering asset profiles
- Fixed a layout issue in the asset profile dialog of the admin control panel by truncating long titles
## 3.7.0 - 2026-06-02
### Added
- Added support for routing selected requests through the _OpenRouter_ `web_fetch` tool in the `FetchService`
### Changed
- Extended the countries mapping in the data enhancer for asset profile data via _Trackinsight_
- Removed the deprecated attributes (`assetClass`, `assetClassLabel`, `assetSubClass`, `assetSubClassLabel`, `countries`, `currency`, `dataSource`, `holdings`, `name`, `sectors`, `symbol` and `url`) from the holdings of the portfolio details endpoint response
- Upgraded `Nx` from version `22.7.2` to `22.7.5`
### Fixed
- Resolved an issue in the impersonation mode where the values did not match the owner’s currency
- Fixed the environment variable expansion in the `.env` file when debugging via _Visual Studio Code_
## 3.6.0 - 2026-05-28
### Added
- Added `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variable support to outbound HTTP requests
- Added the `FetchService` to centralize outbound HTTP requests
### Changed
- Extracted the floating action buttons (FAB) to a reusable component
- Upgraded `nestjs` from version `11.1.19` to `11.1.21`
- Upgraded `yahoo-finance2` from version `3.14.0` to `3.14.2`
## 3.5.0 - 2026-05-24
### Added
- Configured the `min-release-age` in `.npmrc`
### Changed
- Removed the deprecated attributes (`assetClass`, `countries`, `currency`, `dataSource`, `name`, `sectors`, `symbol` and `url`) from the holdings of the public portfolio endpoint response
- Removed the deprecated `api/v1/order` endpoints
- Upgraded `@keyv/redis` from version `4.4.0` to `5.1.6`
### Fixed
- Fixed a layout regression that caused a double scrollbar on pages without tabs
- Resolved an issue with missing cash positions caused by an incorrect data source
## 3.4.0 - 2026-05-21
### Added
- Added the icon column to the benchmark component
>>>>>>> upstream/main
- Added support for the `DIRECT_URL` environment variable to enable direct database connections - Added support for the `DIRECT_URL` environment variable to enable direct database connections
### Changed ### Changed
@ -18,7 +796,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved the pagination in the activities table of the holding detail dialog - Improved the pagination in the activities table of the holding detail dialog
- Randomized the placeholder in the assistant - Randomized the placeholder in the assistant
- Filtered out sectors with zero weight for ETF and mutual fund assets in the _Yahoo Finance_ data enhancer
- Enabled the _Bull Dashboard_ in the admin control panel without requiring an environment variable (experimental) - Enabled the _Bull Dashboard_ in the admin control panel without requiring an environment variable (experimental)
- Improved the verification of the _Stripe_ checkout session when creating a subscription
- Relaxed the URL validation in the asset profile DTOs to accept both `HTTP` and `HTTPS` protocols
- Relaxed the URL validation in the platform DTOs to accept both `HTTP` and `HTTPS` protocols
- Extracted the page tabs to a reusable component - Extracted the page tabs to a reusable component
- Improved the language localization for German (`de`) - Improved the language localization for German (`de`)
- Improved the language localization for Spanish (`es`) - Improved the language localization for Spanish (`es`)

6
DEVELOPMENT.md

@ -84,6 +84,12 @@ https://ghostfol.io/development/storybook
1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@angular.*/"` 1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@angular.*/"`
### NestJS
#### Upgrade (minor versions)
1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@nestjs.*/"`
### Nx ### Nx
#### Upgrade #### Upgrade

109
README.md

@ -77,6 +77,8 @@ The frontend is built with [Angular](https://angular.dev) and uses [Angular Mate
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64`, `linux/arm/v7` and `linux/arm64`. We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64`, `linux/arm/v7` and `linux/arm64`.
Find answers to commonly asked questions about self-hosting Ghostfolio in our [Frequently Asked Questions (FAQ)](https://ghostfol.io/en/faq/self-hosting) section.
<div align="center"> <div align="center">
[<img src="./apps/client/src/assets/images/button-buy-me-a-coffee.png" width="150" alt="Buy me a coffee button"/>](https://www.buymeacoffee.com/ghostfolio) [<img src="./apps/client/src/assets/images/button-buy-me-a-coffee.png" width="150" alt="Buy me a coffee button"/>](https://www.buymeacoffee.com/ghostfolio)
@ -85,29 +87,30 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables ### Supported Environment Variables
| Name | Type | Default Value | Description | | Name | Type | Default Value | Description |
| --------------------------- | --------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | | `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | | `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | | `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` | | `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` |
| `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) | | `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) |
| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | | `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | | `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) | | `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"]` | | `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 | | `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | | `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | | `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | | `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | | `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running | | `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | | `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running | | `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | | `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | | `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. |
| `TRUST_PROXY` | `string` (optional) | | The [trust proxy](https://expressjs.com/en/guide/behind-proxies.html) setting of _Express.js_ to determine the client IP address for rate limiting, e.g. `1` if the Ghostfolio application runs behind a single reverse proxy |
#### OpenID Connect OIDC (Experimental)
#### OpenID Connect OIDC (experimental)
| Name | Type | Default Value | Description | | Name | Type | Default Value | Description |
| -------------------------- | --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------- | | -------------------------- | --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------- |
@ -187,8 +190,6 @@ Set the header for each request as follows:
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ "accessToken": "<INSERT_SECURITY_TOKEN_OF_ACCOUNT>" }`) You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ "accessToken": "<INSERT_SECURITY_TOKEN_OF_ACCOUNT>" }`)
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
### Health Check (experimental) ### Health Check (experimental)
#### Request #### Request
@ -302,6 +303,58 @@ Grant access of type _Public_ in the _Access_ tab of _My Ghostfolio_.
} }
``` ```
### Update Asset Profile Data (experimental)
#### Prerequisites
[Bearer Token](#authorization-bearer-token) for authorization with admin role
#### Request
`PATCH http://localhost:3333/api/v1/asset-profiles/<INSERT_DATA_SOURCE>/<INSERT_SYMBOL>`
#### Body
```
{
"countries": [
{
"code": "US",
"weight": 1
}
],
"sectors": [
{
"name": "Technology",
"weight": 1
}
]
}
```
| Field | Type | Description |
| ----------- | ------------------ | ---------------------------------------------------------------------- |
| `countries` | `array` (optional) | Countries with `code` (`ISO 3166-1 alpha-2`) and `weight` (`0` to `1`) |
| `holdings` | `array` (optional) | Holdings with `name` and `weight` (`0` to `1`) |
| `sectors` | `array` (optional) | Sectors with `name` and `weight` (`0` to `1`) |
#### Response
##### Success
`200 OK`
##### Error
`404 Not Found`
```
{
"error": "Not Found",
"message": "Could not find the asset profile for MSFT (YAHOO)"
}
```
## Community Projects ## Community Projects
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
@ -310,7 +363,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
## Contributing ## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public, including you. Ghostfolio is **100% free** and **open source**. We support an active and healthy community and welcome contributions from everyone, including you.
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you. Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you.
@ -326,10 +379,6 @@ If you like to support this project, get [**Ghostfolio Premium**](https://ghostf
</a> </a>
</div> </div>
## Analytics
![Alt](https://repobeats.axiom.co/api/embed/281a80b2d0c4af1162866c24c803f1f18e5ed60e.svg 'Repobeats analytics image')
## License ## License
© 2021 - 2026 [Ghostfolio](https://ghostfol.io) © 2021 - 2026 [Ghostfolio](https://ghostfol.io)

4
apps/api/jest.config.ts

@ -1,4 +1,8 @@
/* eslint-disable */ /* eslint-disable */
// Run tests in UTC for deterministic date-based calculations
process.env.TZ = 'UTC';
export default { export default {
displayName: 'api', displayName: 'api',

26
apps/api/project.json

@ -7,32 +7,10 @@
"generators": {}, "generators": {},
"targets": { "targets": {
"build": { "build": {
"executor": "@nx/webpack:webpack",
"options": {
"compiler": "tsc",
"deleteOutputPath": false,
"main": "apps/api/src/main.ts",
"outputPath": "dist/apps/api",
"sourceMap": true,
"target": "node",
"tsConfig": "apps/api/tsconfig.app.json",
"webpackConfig": "apps/api/webpack.config.js"
},
"configurations": { "configurations": {
"production": { "production": {}
"generatePackageJson": true,
"optimization": true,
"extractLicenses": true,
"inspect": false,
"fileReplacements": [
{
"replace": "apps/api/src/environments/environment.ts",
"with": "apps/api/src/environments/environment.prod.ts"
}
]
}
}, },
"outputs": ["{options.outputPath}"] "outputs": ["{workspaceRoot}/dist/apps/api"]
}, },
"copy-assets": { "copy-assets": {
"executor": "nx:run-commands", "executor": "nx:run-commands",

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

@ -3,7 +3,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { Access } from '@ghostfolio/common/interfaces'; import { Access, AccessSettings } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -46,13 +46,14 @@ export class AccessController {
}); });
return accessesWithGranteeUser.map( return accessesWithGranteeUser.map(
({ alias, granteeUser, id, permissions }) => { ({ alias, granteeUser, id, permissions, settings }) => {
if (granteeUser) { if (granteeUser) {
return { return {
alias, alias,
id, id,
permissions, permissions,
grantee: granteeUser?.id, grantee: granteeUser?.id,
settings: settings as AccessSettings,
type: 'PRIVATE' type: 'PRIVATE'
}; };
} }
@ -62,6 +63,7 @@ export class AccessController {
id, id,
permissions, permissions,
grantee: 'Public', grantee: 'Public',
settings: settings as AccessSettings,
type: 'PUBLIC' type: 'PUBLIC'
}; };
} }
@ -76,7 +78,7 @@ export class AccessController {
): Promise<AccessModel> { ): Promise<AccessModel> {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Basic this.request.user.subscription?.type === SubscriptionType.Basic
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -85,12 +87,13 @@ export class AccessController {
} }
try { try {
return this.accessService.createAccess({ return await this.accessService.createAccess({
alias: data.alias || undefined, alias: data.alias || undefined,
granteeUser: data.granteeUserId granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: undefined, : undefined,
permissions: data.permissions, permissions: data.permissions,
settings: this.accessService.buildSettings(data.filters),
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}); });
} catch { } catch {
@ -131,7 +134,7 @@ export class AccessController {
): Promise<AccessModel> { ): Promise<AccessModel> {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Basic this.request.user.subscription?.type === SubscriptionType.Basic
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -152,13 +155,14 @@ export class AccessController {
} }
try { try {
return this.accessService.updateAccess({ return await this.accessService.updateAccess({
data: { data: {
alias: data.alias, alias: data.alias,
granteeUser: data.granteeUserId granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: { disconnect: true }, : { disconnect: true },
permissions: data.permissions permissions: data.permissions,
settings: this.accessService.buildSettings(data.filters)
}, },
where: { id } where: { id }
}); });

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

@ -1,4 +1,5 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AccessSettings, Filter } from '@ghostfolio/common/interfaces';
import { AccessWithGranteeUser } from '@ghostfolio/common/types'; import { AccessWithGranteeUser } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -39,6 +40,12 @@ export class AccessService {
}); });
} }
public buildSettings(filters?: Filter[]) {
const settings: AccessSettings = filters?.length ? { filters } : {};
return settings as Prisma.InputJsonValue;
}
public async createAccess(data: Prisma.AccessCreateInput): Promise<Access> { public async createAccess(data: Prisma.AccessCreateInput): Promise<Access> {
return this.prismaService.access.create({ return this.prismaService.access.create({
data data

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

@ -1,6 +1,7 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -10,7 +11,7 @@ import { AccountBalanceService } from './account-balance.service';
@Module({ @Module({
controllers: [AccountBalanceController], controllers: [AccountBalanceController],
exports: [AccountBalanceService], exports: [AccountBalanceService],
imports: [ExchangeRateDataModule, PrismaModule], imports: [ExchangeRateDataModule, PrismaModule, TagModule],
providers: [AccountBalanceService, AccountService] providers: [AccountBalanceService, AccountService]
}) })
export class AccountBalanceModule {} export class AccountBalanceModule {}

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

@ -1,4 +1,8 @@
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import {
isAccountBalanceInFuture,
WHERE_ACCOUNT_NOT_EXCLUDED
} from '@ghostfolio/api/helper/account.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -14,7 +18,8 @@ import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { AccountBalance, Prisma } from '@prisma/client'; import { AccountBalance, Prisma } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format, parseISO } from 'date-fns'; import { endOfToday, format, parseISO } from 'date-fns';
import { groupBy } from 'lodash';
@Injectable() @Injectable()
export class AccountBalanceService { export class AccountBalanceService {
@ -112,8 +117,13 @@ export class AccountBalanceService {
const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } = const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } =
{}; {};
const lastBalancesByAccount: { [accountId: string]: Big } = {}; const lastBalancesByAccount: { [accountId: string]: Big } = {};
const endOfTodayDate = endOfToday();
for (const { accountId, date, valueInBaseCurrency } of balances) { for (const { accountId, date, valueInBaseCurrency } of balances) {
if (isAccountBalanceInFuture({ date, endOfTodayDate })) {
continue;
}
const formattedDate = format(date, DATE_FORMAT); const formattedDate = format(date, DATE_FORMAT);
lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency); lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency);
@ -144,16 +154,16 @@ export class AccountBalanceService {
}): Promise<AccountBalancesResponse> { }): Promise<AccountBalancesResponse> {
const where: Prisma.AccountBalanceWhereInput = { userId }; const where: Prisma.AccountBalanceWhereInput = { userId };
const accountFilter = filters?.find(({ type }) => { const { ACCOUNT: [filterByAccount] = [] } = groupBy(filters, ({ type }) => {
return type === 'ACCOUNT'; return type;
}); });
if (accountFilter) { if (filterByAccount) {
where.accountId = accountFilter.id; where.accountId = filterByAccount.id;
} }
if (withExcludedAccounts === false) { if (withExcludedAccounts === false) {
where.account = { isExcluded: false }; where.account = WHERE_ACCOUNT_NOT_EXCLUDED;
} }
const balances = await this.prismaService.accountBalance.findMany({ const balances = await this.prismaService.accountBalance.findMany({
@ -176,7 +186,7 @@ export class AccountBalanceService {
accountId: balance.account.id, accountId: balance.account.id,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
balance.value, balance.value,
balance.account.currency, balance.account.currency ?? userCurrency,
userCurrency userCurrency
) )
}; };

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

@ -1,5 +1,6 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
@ -50,7 +51,8 @@ export class AccountController {
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {} ) {}
@Delete(':id') @Delete(':id')
@ -137,11 +139,14 @@ export class AccountController {
): Promise<AccountBalancesResponse> { ): Promise<AccountBalancesResponse> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userId = impersonationUserId || this.request.user.id;
const { settings } = await this.userService.user({ id: userId });
return this.accountBalanceService.getAccountBalances({ return this.accountBalanceService.getAccountBalances({
userId,
filters: [{ id, type: 'ACCOUNT' }], filters: [{ id, type: 'ACCOUNT' }],
userCurrency: this.request.user.settings.settings.baseCurrency, userCurrency: settings.settings.baseCurrency
userId: impersonationUserId || this.request.user.id
}); });
} }
@ -151,28 +156,34 @@ export class AccountController {
public async createAccount( public async createAccount(
@Body() data: CreateAccountDto @Body() data: CreateAccountDto
): Promise<AccountModel> { ): Promise<AccountModel> {
if (data.platformId) { const { balance, tags: tagIds, ...accountData } = data;
const platformId = data.platformId;
delete data.platformId; if (accountData.platformId) {
const platformId = accountData.platformId;
delete accountData.platformId;
return this.accountService.createAccount( return this.accountService.createAccount({
{ balance,
...data, tagIds,
data: {
...accountData,
platform: { connect: { id: platformId } }, platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id userId: this.request.user.id
); });
} else { } else {
delete data.platformId; delete accountData.platformId;
return this.accountService.createAccount( return this.accountService.createAccount({
{ balance,
...data, tagIds,
data: {
...accountData,
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id userId: this.request.user.id
); });
} }
} }
@ -248,48 +259,50 @@ export class AccountController {
); );
} }
if (data.platformId) { const { balance, tags: tagIds, ...accountData } = data;
const platformId = data.platformId;
delete data.platformId; if (accountData.platformId) {
const platformId = accountData.platformId;
return this.accountService.updateAccount( delete accountData.platformId;
{
data: { return this.accountService.updateAccount({
...data, balance,
platform: { connect: { id: platformId } }, tagIds,
user: { connect: { id: this.request.user.id } } data: {
}, ...accountData,
where: { platform: { connect: { id: platformId } },
id_userId: { user: { connect: { id: this.request.user.id } }
id,
userId: this.request.user.id
}
}
}, },
this.request.user.id userId: this.request.user.id,
); where: {
id_userId: {
id,
userId: this.request.user.id
}
}
});
} else { } else {
// platformId is null, remove it // platformId is null, remove it
delete data.platformId; delete accountData.platformId;
return this.accountService.updateAccount( return this.accountService.updateAccount({
{ balance,
data: { tagIds,
...data, data: {
platform: originalAccount.platformId ...accountData,
? { disconnect: true } platform: originalAccount.platformId
: undefined, ? { disconnect: true }
user: { connect: { id: this.request.user.id } } : undefined,
}, user: { connect: { id: this.request.user.id } }
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
}, },
this.request.user.id userId: this.request.user.id,
); where: {
id_userId: {
id,
userId: this.request.user.id
}
}
});
} }
} }
} }

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

@ -1,11 +1,13 @@
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -23,7 +25,9 @@ import { AccountService } from './account.service';
ImpersonationModule, ImpersonationModule,
PortfolioModule, PortfolioModule,
PrismaModule, PrismaModule,
RedactValuesInResponseModule RedactValuesInResponseModule,
TagModule,
UserModule
], ],
providers: [AccountService] providers: [AccountService]
}) })

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

@ -1,9 +1,16 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import {
getWhereAccountBalanceNotInFuture,
isAccountBalanceInFuture,
WHERE_ACCOUNT_NOT_EXCLUDED
} from '@ghostfolio/api/helper/account.helper';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { AccountWithBalance } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
@ -13,11 +20,12 @@ import {
Order, Order,
Platform, Platform,
Prisma, Prisma,
SymbolProfile SymbolProfile,
Tag
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format } from 'date-fns'; import { endOfToday, format } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy, isNil } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface'; import { CashDetails } from './interfaces/cash-details.interface';
@ -27,17 +35,35 @@ export class AccountService {
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService,
private readonly tagService: TagService
) {} ) {}
public async account({ public async account({
id_userId id_userId
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> { }: Prisma.AccountWhereUniqueInput): Promise<AccountWithBalance | null> {
const [account] = await this.accounts({ const account = await this.prismaService.account.findUnique({
where: id_userId include: {
balances: {
orderBy: { date: 'desc' },
take: 1,
// Ignore account balances in the future
where: getWhereAccountBalanceNotInFuture()
}
},
where: { id_userId }
}); });
return account; if (!account) {
return null;
}
const { balances, ...accountData } = account;
return {
...accountData,
balance: balances[0]?.value ?? 0
};
} }
public async accountWithActivities( public async accountWithActivities(
@ -62,21 +88,40 @@ export class AccountService {
where?: Prisma.AccountWhereInput; where?: Prisma.AccountWhereInput;
orderBy?: Prisma.AccountOrderByWithRelationInput; orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise< }): Promise<
(Account & { (AccountWithBalance & {
activities?: (Order & { SymbolProfile?: SymbolProfile })[]; activities?: (Order & { SymbolProfile?: SymbolProfile })[];
balances?: AccountBalance[]; balances?: AccountBalance[];
platform?: Platform; platform?: Platform;
tags?: Tag[];
})[] })[]
> { > {
const { include = {}, skip, take, cursor, where, orderBy } = params; const { include = {}, skip, take, cursor, where, orderBy } = params;
const isBalancesIncluded = !!include.balances; const isBalancesIncluded = !!include.balances;
const isTagsIncluded = !!include.tags;
include.balances = { include.balances = {
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
...(isBalancesIncluded ? {} : { take: 1 }) // If the balances are included, they are returned as-is (including the
// ones in the future) because the client renders the full history. The
// balance is derived below and skips the account balances in the future.
...(isBalancesIncluded
? {}
: {
take: 1,
// Ignore account balances in the future
where: getWhereAccountBalanceNotInFuture()
})
}; };
if (isTagsIncluded) {
include.tags = {
include: {
tag: true
}
};
}
const accounts = await this.prismaService.account.findMany({ const accounts = await this.prismaService.account.findMany({
cursor, cursor,
include, include,
@ -86,31 +131,72 @@ export class AccountService {
where where
}); });
const endOfTodayDate = endOfToday();
return accounts.map((account) => { return accounts.map((account) => {
account = { ...account, balance: account.balances[0]?.value ?? 0 }; const result = {
...account,
balance:
// The balances are ordered by date descending, hence the first account
// balance which is not in the future reflects the current balance
account.balances.find(({ date }) => {
return !isAccountBalanceInFuture({ date, endOfTodayDate });
})?.value ?? 0,
tags: isTagsIncluded
? (account.tags as unknown as { tag: Tag }[]).map(({ tag }) => {
return tag;
})
: undefined
};
if (!isBalancesIncluded) { if (!isBalancesIncluded) {
delete account.balances; delete result.balances;
} }
return account; if (!isTagsIncluded) {
delete result.tags;
}
return result;
}); });
} }
public async createAccount( public async createAccount({
data: Prisma.AccountCreateInput, balance,
aUserId: string data,
): Promise<Account> { tagIds,
userId
}: {
balance?: number;
data: Prisma.AccountCreateInput;
tagIds?: string[];
userId: string;
}): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId });
const account = await this.prismaService.account.create({ const account = await this.prismaService.account.create({
data data: {
...data,
tags: tagIds
? {
create: tagIds.map((tagId) => {
return {
tag: { connect: { id: tagId } }
};
})
}
: undefined
}
}); });
await this.accountBalanceService.createOrUpdateAccountBalance({ if (!isNil(balance)) {
accountId: account.id, await this.accountBalanceService.createOrUpdateAccountBalance({
balance: data.balance, balance,
date: format(new Date(), DATE_FORMAT), userId,
userId: aUserId accountId: account.id,
}); date: format(new Date(), DATE_FORMAT)
});
}
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
@ -139,11 +225,12 @@ export class AccountService {
return account; return account;
} }
public async getAccounts(aUserId: string): Promise<Account[]> { public async getAccounts(aUserId: string): Promise<AccountWithBalance[]> {
const accounts = await this.accounts({ const accounts = await this.accounts({
include: { include: {
activities: true, activities: true,
platform: true platform: true,
tags: true
}, },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
where: { userId: aUserId } where: { userId: aUserId }
@ -184,14 +271,14 @@ export class AccountService {
}; };
if (withExcludedAccounts === false) { if (withExcludedAccounts === false) {
where.isExcluded = false; where.AND = [WHERE_ACCOUNT_NOT_EXCLUDED];
} }
const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => { const { ACCOUNT: filtersByAccount = [] } = groupBy(filters, ({ type }) => {
return type; return type;
}); });
if (filtersByAccount?.length > 0) { if (filtersByAccount.length > 0) {
where.id = { where.id = {
in: filtersByAccount.map(({ id }) => { in: filtersByAccount.map(({ id }) => {
return id; return id;
@ -217,27 +304,47 @@ export class AccountService {
}; };
} }
public async updateAccount( public async updateAccount({
params: { balance,
where: Prisma.AccountWhereUniqueInput; data,
data: Prisma.AccountUpdateInput; tagIds,
}, userId,
aUserId: string where
): Promise<Account> { }: {
const { data, where } = params; balance?: number;
data: Prisma.AccountUpdateInput;
await this.accountBalanceService.createOrUpdateAccountBalance({ tagIds?: string[];
accountId: data.id as string, userId: string;
balance: data.balance as number, where: Prisma.AccountWhereUniqueInput;
date: format(new Date(), DATE_FORMAT), }): Promise<Account> {
userId: aUserId await this.tagService.validateTagIds({ tagIds, userId });
});
const account = await this.prismaService.account.update({ const account = await this.prismaService.account.update({
data, data: {
...data,
tags: tagIds
? {
create: tagIds.map((tagId) => {
return {
tag: { connect: { id: tagId } }
};
}),
deleteMany: {}
}
: undefined
},
where where
}); });
if (!isNil(balance)) {
await this.accountBalanceService.createOrUpdateAccountBalance({
balance,
userId,
accountId: account.id,
date: format(new Date(), DATE_FORMAT)
});
}
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ new PortfolioChangedEvent({

4
apps/api/src/app/account/interfaces/cash-details.interface.ts

@ -1,6 +1,6 @@
import { Account } from '@prisma/client'; import { AccountWithBalance } from '@ghostfolio/common/types';
export interface CashDetails { export interface CashDetails {
accounts: Account[]; accounts: AccountWithBalance[];
balanceInBaseCurrency: number; balanceInBaseCurrency: number;
} }

21
apps/api/src/app/activities/activities-filter.dto.ts

@ -0,0 +1,21 @@
import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { FilterDto } from '@ghostfolio/api/dtos/filter.dto';
import { DateRange } from '@ghostfolio/common/types';
import { Type as ActivityType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsEnum, IsOptional, Matches } from 'class-validator';
import { isString } from 'lodash';
export class ActivitiesFilterDto extends FilterDto {
@IsEnum(ActivityType, { each: true })
@IsOptional()
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
activityTypes?: ActivityType[];
@IsOptional()
@Matches(DATE_RANGE_PATTERN)
range?: DateRange;
}

119
apps/api/src/app/activities/activities.controller.ts

@ -18,7 +18,7 @@ import {
ActivityResponse ActivityResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -37,17 +37,15 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Order, Prisma, Type as ActivityType } from '@prisma/client'; import { Order } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ActivitiesFilterDto } from './activities-filter.dto';
import { ActivitiesService } from './activities.service'; import { ActivitiesService } from './activities.service';
import { GetActivitiesDto } from './get-activities.dto';
@Controller([ @Controller('activities')
'activities',
/** @deprecated */
'order'
])
export class ActivitiesController { export class ActivitiesController {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
@ -63,22 +61,47 @@ export class ActivitiesController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteActivities( public async deleteActivities(
@Query('accounts') filterByAccounts?: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query()
@Query('dataSource') filterByDataSource?: string, {
@Query('symbol') filterBySymbol?: string, accounts,
@Query('tags') filterByTags?: string activityTypes,
assetClasses,
dataSource,
range,
symbol,
tags
}: ActivitiesFilterDto
): Promise<number> { ): Promise<number> {
if (impersonationId) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let endDate: Date;
let startDate: Date;
if (range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
return this.activitiesService.deleteActivities({ return this.activitiesService.deleteActivities({
endDate,
filters, filters,
startDate,
types: activityTypes,
userId: this.request.user.id userId: this.request.user.id
}); });
} }
@ -111,51 +134,54 @@ export class ActivitiesController {
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllActivities( public async getAllActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query()
@Query('activityTypes') filterByTypes?: string, {
@Query('assetClasses') filterByAssetClasses?: string, accounts,
@Query('dataSource') filterByDataSource?: string, activityTypes,
@Query('range') dateRange?: DateRange, assetClasses,
@Query('skip') skip?: number, dataSource,
@Query('sortColumn') sortColumn?: string, range,
@Query('sortDirection') sortDirection?: Prisma.SortOrder, skip,
@Query('symbol') filterBySymbol?: string, sortColumn,
@Query('tags') filterByTags?: string, sortDirection,
@Query('take') take?: number symbol,
tags,
take
}: GetActivitiesDto
): Promise<ActivitiesResponse> { ): Promise<ActivitiesResponse> {
let endDate: Date; let endDate: Date;
let startDate: Date; let startDate: Date;
if (dateRange) { if (range) {
({ endDate, startDate } = getIntervalFromDateRange({ dateRange })); ({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
} }
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const types = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const userCurrency = this.request.user.settings.settings.baseCurrency; const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities, count } = await this.activitiesService.getActivities({ const { activities, count } = await this.activitiesService.getActivities({
endDate, endDate,
filters, filters,
skip,
sortColumn, sortColumn,
sortDirection, sortDirection,
startDate, startDate,
types, take,
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
skip: isNaN(skip) ? undefined : skip, types: activityTypes,
take: isNaN(take) ? undefined : take,
userId: impersonationUserId || this.request.user.id, userId: impersonationUserId || this.request.user.id,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });
@ -318,11 +344,13 @@ export class ActivitiesController {
data: { data: {
...data, ...data,
date, date,
account: { account: accountId
connect: { ? {
id_userId: { id: accountId, userId: this.request.user.id } connect: {
} id_userId: { id: accountId, userId: this.request.user.id }
}, }
}
: { disconnect: true },
SymbolProfile: { SymbolProfile: {
connect: { connect: {
dataSource_symbol: { dataSource_symbol: {
@ -341,6 +369,7 @@ export class ActivitiesController {
}), }),
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
userId: this.request.user.id,
where: { where: {
id id
} }

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

@ -6,12 +6,15 @@ import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redac
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -23,15 +26,18 @@ import { ActivitiesService } from './activities.service';
exports: [ActivitiesService], exports: [ActivitiesService],
imports: [ imports: [
ApiModule, ApiModule,
BenchmarkModule,
CacheModule, CacheModule,
DataGatheringQueueModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule,
PrismaModule, PrismaModule,
RedactValuesInResponseModule, RedactValuesInResponseModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule, SymbolProfileModule,
TagModule,
TransformDataSourceInRequestModule, TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule TransformDataSourceInResponseModule
], ],

360
apps/api/src/app/activities/activities.service.ts

@ -3,25 +3,36 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event'; import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import {
isAccountBalanceInFuture,
WHERE_ACCOUNT_NOT_EXCLUDED
} from '@ghostfolio/api/helper/account.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
ghostfolioPrefix, NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import {
canDeleteAssetProfile,
getAssetProfileIdentifier,
isValidCustomAssetProfileSymbol
} from '@ghostfolio/common/helper';
import { import {
ActivitiesResponse, ActivitiesResponse,
Activity, Activity,
AssetProfileIdentifier, AssetProfileIdentifier,
EnhancedSymbolProfile, EnhancedAssetProfile,
Filter Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
@ -38,7 +49,6 @@ import {
Type as ActivityType Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isUUID } from 'class-validator';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { groupBy, uniqBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@ -48,20 +58,67 @@ export class ActivitiesService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService,
private readonly tagService: TagService
) {} ) {}
public areCashActivitiesExcludedByFilters(filters: Filter[] = []) {
const {
ASSET_CLASS: filtersByAssetClass = [],
DATA_SOURCE: [filterByDataSource] = [],
SYMBOL: [filterBySymbol] = [],
TAG: filtersByTag = []
} = groupBy(filters, ({ type }) => {
return type;
});
const isFilteredByAssetClassOtherThanLiquidity =
filtersByAssetClass.length > 0 &&
!filtersByAssetClass.some(({ id }) => {
return id === AssetClass.LIQUIDITY;
});
const isFilteredByAssetProfile = !!(filterByDataSource || filterBySymbol);
const isFilteredByTag = filtersByTag.length > 0;
const isFilteredByUnsupportedType = filters.some(({ type }) => {
return ![
'ACCOUNT',
'ASSET_CLASS',
'DATA_SOURCE',
'SYMBOL',
'TAG'
].includes(type);
});
return (
isFilteredByAssetClassOtherThanLiquidity ||
isFilteredByAssetProfile ||
isFilteredByTag ||
isFilteredByUnsupportedType
);
}
public async assignTags({ public async assignTags({
dataSource, dataSource,
symbol, symbol,
tags, tags,
userId userId
}: { tags: Tag[]; userId: string } & AssetProfileIdentifier) { }: { tags: Tag[]; userId: string } & AssetProfileIdentifier) {
await this.tagService.validateTagIds({
userId,
tagIds: tags.map(({ id }) => {
return id;
})
});
const activities = await this.prismaService.order.findMany({ const activities = await this.prismaService.order.findMany({
where: { where: {
userId, userId,
@ -108,6 +165,15 @@ export class ActivitiesService {
userId: string; userId: string;
} }
): Promise<Order> { ): Promise<Order> {
const tags = data.tags ?? [];
await this.tagService.validateTagIds({
tagIds: tags.map(({ id }) => {
return id;
}),
userId: data.userId
});
let account: Prisma.AccountCreateNestedOneWithoutActivitiesInput; let account: Prisma.AccountCreateNestedOneWithoutActivitiesInput;
if (data.accountId) { if (data.accountId) {
@ -122,12 +188,11 @@ export class ActivitiesService {
} }
const accountId = data.accountId; const accountId = data.accountId;
const tags = data.tags ?? [];
const updateAccountBalance = data.updateAccountBalance ?? false; const updateAccountBalance = data.updateAccountBalance ?? false;
const userId = data.userId; const userId = data.userId;
if ( if (
['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) || NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) ||
(data.SymbolProfile.connectOrCreate.create.dataSource === 'MANUAL' && (data.SymbolProfile.connectOrCreate.create.dataSource === 'MANUAL' &&
data.type === 'BUY') data.type === 'BUY')
) { ) {
@ -139,10 +204,9 @@ export class ActivitiesService {
let symbol: string; let symbol: string;
if ( if (
data.SymbolProfile.connectOrCreate.create.symbol.startsWith( isValidCustomAssetProfileSymbol(
`${ghostfolioPrefix}_` data.SymbolProfile.connectOrCreate.create.symbol
) || )
isUUID(data.SymbolProfile.connectOrCreate.create.symbol)
) { ) {
// Connect custom asset profile (clone) // Connect custom asset profile (clone)
symbol = data.SymbolProfile.connectOrCreate.create.symbol; symbol = data.SymbolProfile.connectOrCreate.create.symbol;
@ -197,7 +261,7 @@ export class ActivitiesService {
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
const isDraft = ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) const isDraft = NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type)
? false ? false
: isAfter(data.date as Date, endOfToday()); : isAfter(data.date as Date, endOfToday());
@ -213,7 +277,7 @@ export class ActivitiesService {
include: { SymbolProfile: true } include: { SymbolProfile: true }
}); });
if (updateAccountBalance === true) { if (accountId && updateAccountBalance === true) {
let amount = new Big(data.unitPrice).mul(data.quantity); let amount = new Big(data.unitPrice).mul(data.quantity);
if (['BUY', 'FEE'].includes(data.type)) { if (['BUY', 'FEE'].includes(data.type)) {
@ -262,7 +326,26 @@ export class ActivitiesService {
activity.symbolProfileId activity.symbolProfileId
]); ]);
if (symbolProfile.activitiesCount === 0) { const benchmarkAssetProfiles =
await this.benchmarkService.getBenchmarkAssetProfiles();
const isBenchmark = benchmarkAssetProfiles.some(({ id }) => {
return id === symbolProfile.id;
});
if (
canDeleteAssetProfile({
isBenchmark,
activitiesCount: symbolProfile.activitiesCount,
symbol: symbolProfile.symbol,
watchedByCount: symbolProfile.watchedByCount
})
) {
await this.marketDataService.deleteMany({
dataSource: symbolProfile.dataSource,
symbol: symbolProfile.symbol
});
await this.symbolProfileService.deleteById(activity.symbolProfileId); await this.symbolProfileService.deleteById(activity.symbolProfileId);
} }
@ -277,14 +360,23 @@ export class ActivitiesService {
} }
public async deleteActivities({ public async deleteActivities({
endDate,
filters, filters,
startDate,
types,
userId userId
}: { }: {
endDate?: Date;
filters?: Filter[]; filters?: Filter[];
startDate?: Date;
types?: ActivityType[];
userId: string; userId: string;
}): Promise<number> { }): Promise<number> {
const { activities } = await this.getActivities({ const { activities } = await this.getActivities({
endDate,
filters, filters,
startDate,
types,
userId, userId,
includeDrafts: true, includeDrafts: true,
userCurrency: undefined, userCurrency: undefined,
@ -308,8 +400,31 @@ export class ActivitiesService {
}) })
); );
for (const { activitiesCount, id } of symbolProfiles) { const benchmarkAssetProfiles =
if (activitiesCount === 0) { await this.benchmarkService.getBenchmarkAssetProfiles();
for (const {
activitiesCount,
dataSource,
id,
symbol,
watchedByCount
} of symbolProfiles) {
const isBenchmark = benchmarkAssetProfiles.some(
(benchmarkAssetProfile) => {
return benchmarkAssetProfile.id === id;
}
);
if (
canDeleteAssetProfile({
activitiesCount,
isBenchmark,
symbol,
watchedByCount
})
) {
await this.marketDataService.deleteMany({ dataSource, symbol });
await this.symbolProfileService.deleteById(id); await this.symbolProfileService.deleteById(id);
} }
} }
@ -344,17 +459,7 @@ export class ActivitiesService {
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}): Promise<ActivitiesResponse> { }): Promise<ActivitiesResponse> {
const filtersByAssetClass = filters.filter(({ type }) => { if (this.areCashActivitiesExcludedByFilters(filters)) {
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 { return {
activities: [], activities: [],
count: 0 count: 0
@ -362,6 +467,7 @@ export class ActivitiesService {
} }
const activities: Activity[] = []; const activities: Activity[] = [];
const endOfTodayDate = endOfToday();
for (const account of cashDetails.accounts) { for (const account of cashDetails.accounts) {
const { balances } = await this.accountBalanceService.getAccountBalances({ const { balances } = await this.accountBalanceService.getAccountBalances({
@ -374,21 +480,20 @@ export class ActivitiesService {
let currentBalanceInBaseCurrency = 0; let currentBalanceInBaseCurrency = 0;
for (const balanceItem of balances) { for (const balanceItem of balances) {
if (
isAccountBalanceInFuture({
endOfTodayDate,
date: balanceItem.date
})
) {
continue;
}
const syntheticActivityTemplate: Activity = { const syntheticActivityTemplate: Activity = {
userId, userId,
accountId: account.id, accountId: account.id,
accountUserId: account.userId, accountUserId: account.userId,
comment: account.name, assetProfile: {
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, activitiesCount: 0,
assetClass: AssetClass.LIQUIDITY, assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH, assetSubClass: AssetSubClass.CASH,
@ -405,6 +510,16 @@ export class ActivitiesService {
symbol: account.currency, symbol: account.currency,
updatedAt: new Date(balanceItem.date) updatedAt: new Date(balanceItem.date)
}, },
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,
symbolProfileId: account.currency, symbolProfileId: account.currency,
type: ActivityType.BUY, type: ActivityType.BUY,
unitPrice: 1, unitPrice: 1,
@ -492,41 +607,29 @@ export class ActivitiesService {
{ date: 'asc' } { date: 'asc' }
]; ];
const where: Prisma.OrderWhereInput = { userId }; const andConditions: Prisma.OrderWhereInput[] = [];
const where: Prisma.OrderWhereInput = { userId, AND: andConditions };
if (endDate || startDate) { if (endDate) {
where.AND = []; andConditions.push({ date: { lte: endDate } });
}
if (endDate) {
where.AND.push({ date: { lte: endDate } });
}
if (startDate) { if (startDate) {
where.AND.push({ date: { gt: startDate } }); andConditions.push({ date: { gt: startDate } });
}
} }
const { const {
ACCOUNT: filtersByAccount, ACCOUNT: filtersByAccount = [],
ASSET_CLASS: filtersByAssetClass, ASSET_CLASS: filtersByAssetClass = [],
TAG: filtersByTag DATA_SOURCE: [filterByDataSource] = [],
SEARCH_QUERY: [filterBySearchQuery] = [],
SYMBOL: [filterBySymbol] = [],
TAG: filtersByTag = []
} = groupBy(filters, ({ type }) => { } = groupBy(filters, ({ type }) => {
return type; return type;
}); });
const filterByDataSource = filters?.find(({ type }) => { if (filtersByAccount.length > 0) {
return type === 'DATA_SOURCE';
})?.id;
const filterBySymbol = filters?.find(({ type }) => {
return type === 'SYMBOL';
})?.id;
const searchQuery = filters?.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
if (filtersByAccount?.length > 0) {
where.accountId = { where.accountId = {
in: filtersByAccount.map(({ id }) => { in: filtersByAccount.map(({ id }) => {
return id; return id;
@ -538,7 +641,7 @@ export class ActivitiesService {
where.isDraft = false; where.isDraft = false;
} }
if (filtersByAssetClass?.length > 0) { if (filtersByAssetClass.length > 0) {
where.SymbolProfile = { where.SymbolProfile = {
OR: [ OR: [
{ {
@ -550,14 +653,14 @@ export class ActivitiesService {
}, },
{ {
OR: [ OR: [
{ SymbolProfileOverrides: { is: null } }, { assetProfileOverrides: { is: null } },
{ SymbolProfileOverrides: { assetClass: null } } { assetProfileOverrides: { assetClass: null } }
] ]
} }
] ]
}, },
{ {
SymbolProfileOverrides: { assetProfileOverrides: {
OR: filtersByAssetClass.map(({ id }) => { OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] }; return { assetClass: AssetClass[id] };
}) })
@ -574,8 +677,8 @@ export class ActivitiesService {
where.SymbolProfile, where.SymbolProfile,
{ {
AND: [ AND: [
{ dataSource: filterByDataSource as DataSource }, { dataSource: filterByDataSource.id as DataSource },
{ symbol: filterBySymbol } { symbol: filterBySymbol.id }
] ]
} }
] ]
@ -583,19 +686,19 @@ export class ActivitiesService {
} else { } else {
where.SymbolProfile = { where.SymbolProfile = {
AND: [ AND: [
{ dataSource: filterByDataSource as DataSource }, { dataSource: filterByDataSource.id as DataSource },
{ symbol: filterBySymbol } { symbol: filterBySymbol.id }
] ]
}; };
} }
} }
if (searchQuery) { if (filterBySearchQuery) {
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [ const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [
{ id: { mode: 'insensitive', startsWith: searchQuery } }, { id: { mode: 'insensitive', startsWith: filterBySearchQuery.id } },
{ isin: { mode: 'insensitive', startsWith: searchQuery } }, { isin: { mode: 'insensitive', startsWith: filterBySearchQuery.id } },
{ name: { mode: 'insensitive', startsWith: searchQuery } }, { name: { mode: 'insensitive', startsWith: filterBySearchQuery.id } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } } { symbol: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }
]; ];
if (where.SymbolProfile) { if (where.SymbolProfile) {
@ -614,14 +717,31 @@ export class ActivitiesService {
} }
} }
if (filtersByTag?.length > 0) { if (filtersByTag.length > 0) {
where.tags = { andConditions.push({
some: { OR: [
OR: filtersByTag.map(({ id }) => { {
return { id }; tags: {
}) some: {
} OR: filtersByTag.map(({ id }) => {
}; return { id };
})
}
}
},
{
account: {
tags: {
some: {
OR: filtersByTag.map(({ id }) => {
return { tagId: id };
})
}
}
}
}
]
});
} }
if (sortColumn) { if (sortColumn) {
@ -633,13 +753,9 @@ export class ActivitiesService {
} }
if (withExcludedAccountsAndActivities === false) { if (withExcludedAccountsAndActivities === false) {
where.OR = [ where.OR = [{ account: null }, { account: WHERE_ACCOUNT_NOT_EXCLUDED }];
{ account: null },
{ account: { NOT: { isExcluded: true } } }
];
where.tags = { where.tags = {
...where.tags,
none: { none: {
id: TAG_ID_EXCLUDE_FROM_ANALYSIS id: TAG_ID_EXCLUDE_FROM_ANALYSIS
} }
@ -654,7 +770,12 @@ export class ActivitiesService {
include: { include: {
account: { account: {
include: { include: {
platform: true platform: true,
tags: {
include: {
tag: true
}
}
} }
}, },
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
@ -666,6 +787,16 @@ export class ActivitiesService {
this.prismaService.order.count({ where }) this.prismaService.order.count({ where })
]); ]);
for (const order of orders) {
if (order.account) {
order.account.tags = (
order.account.tags as unknown as { tag: Tag }[]
).map(({ tag }) => {
return tag;
});
}
}
const assetProfileIdentifiers = uniqBy( const assetProfileIdentifiers = uniqBy(
orders.map(({ SymbolProfile }) => { orders.map(({ SymbolProfile }) => {
return { return {
@ -697,10 +828,10 @@ export class ActivitiesService {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
const [ const [
feeInAssetProfileCurrency, feeInAssetProfileCurrency = 0,
feeInBaseCurrency, feeInBaseCurrency = 0,
unitPriceInAssetProfileCurrency, unitPriceInAssetProfileCurrency = 0,
valueInBaseCurrency valueInBaseCurrency = 0
] = await Promise.all([ ] = await Promise.all([
this.exchangeRateDataService.toCurrencyAtDate( this.exchangeRateDataService.toCurrencyAtDate(
order.fee, order.fee,
@ -730,12 +861,12 @@ export class ActivitiesService {
return { return {
...order, ...order,
assetProfile,
feeInAssetProfileCurrency, feeInAssetProfileCurrency,
feeInBaseCurrency, feeInBaseCurrency,
unitPriceInAssetProfileCurrency, unitPriceInAssetProfileCurrency,
value, value,
valueInBaseCurrency, valueInBaseCurrency
SymbolProfile: assetProfile
}; };
}) })
); );
@ -770,7 +901,7 @@ export class ActivitiesService {
withExcludedAccountsAndActivities: false // TODO withExcludedAccountsAndActivities: false // TODO
}); });
if (withCash) { if (withCash && !this.areCashActivitiesExcludedByFilters(filters)) {
const cashDetails = await this.accountService.getCashDetails({ const cashDetails = await this.accountService.getCashDetails({
filters, filters,
userId, userId,
@ -792,10 +923,10 @@ export class ActivitiesService {
} }
public async getStatisticsByCurrency( public async getStatisticsByCurrency(
currency: EnhancedSymbolProfile['currency'] currency: EnhancedAssetProfile['currency']
): Promise<{ ): Promise<{
activitiesCount: EnhancedSymbolProfile['activitiesCount']; activitiesCount: EnhancedAssetProfile['activitiesCount'];
dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; dateOfFirstActivity: EnhancedAssetProfile['dateOfFirstActivity'];
}> { }> {
const { _count, _min } = await this.prismaService.order.aggregate({ const { _count, _min } = await this.prismaService.order.aggregate({
_count: true, _count: true,
@ -821,6 +952,7 @@ export class ActivitiesService {
public async updateActivity({ public async updateActivity({
data, data,
userId,
where where
}: { }: {
data: Prisma.OrderUpdateInput & { data: Prisma.OrderUpdateInput & {
@ -831,25 +963,29 @@ export class ActivitiesService {
tags?: { id: string }[]; tags?: { id: string }[];
type?: ActivityType; type?: ActivityType;
}; };
userId: string;
where: Prisma.OrderWhereUniqueInput; where: Prisma.OrderWhereUniqueInput;
}): Promise<Order> { }): Promise<Order> {
const tags = data.tags ?? [];
await this.tagService.validateTagIds({
userId,
tagIds: tags.map(({ id }) => {
return id;
})
});
if (!data.comment) { if (!data.comment) {
data.comment = null; data.comment = null;
} }
const tags = data.tags ?? [];
let isDraft = false; let isDraft = false;
if ( if (
['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) || NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) ||
(data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' && (data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' &&
data.type === 'BUY') data.type === 'BUY')
) { ) {
if (data.account?.connect?.id_userId?.id === null) {
data.account = { disconnect: true };
}
delete data.SymbolProfile.connect; delete data.SymbolProfile.connect;
delete data.SymbolProfile.update.name; delete data.SymbolProfile.update.name;
} else { } else {
@ -878,19 +1014,13 @@ export class ActivitiesService {
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;
// Remove existing tags
await this.prismaService.order.update({
where,
data: { tags: { set: [] } }
});
const activity = await this.prismaService.order.update({ const activity = await this.prismaService.order.update({
where, where,
data: { data: {
...data, ...data,
isDraft, isDraft,
tags: { tags: {
connect: tags set: tags
} }
} }
}); });

27
apps/api/src/app/activities/get-activities.dto.ts

@ -0,0 +1,27 @@
import { Prisma } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Min } from 'class-validator';
import { ActivitiesFilterDto } from './activities-filter.dto';
export class GetActivitiesDto extends ActivitiesFilterDto {
@IsInt()
@IsOptional()
@Min(0)
@Type(() => Number)
skip?: number;
@IsIn(Object.values(Prisma.OrderScalarFieldEnum))
@IsOptional()
sortColumn?: keyof typeof Prisma.OrderScalarFieldEnum;
@IsIn(['asc', 'desc'] as Prisma.SortOrder[])
@IsOptional()
sortDirection?: Prisma.SortOrder;
@IsInt()
@IsOptional()
@Min(0)
@Type(() => Number)
take?: number;
}

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

@ -1,10 +1,11 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { DemoService } from '@ghostfolio/api/services/demo/demo.service'; import { DemoService } from '@ghostfolio/api/services/demo/demo.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
@ -16,19 +17,21 @@ import {
UpdateAssetProfileDto, UpdateAssetProfileDto,
UpdatePropertyDto UpdatePropertyDto
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import {
canDeleteAssetProfile,
getAssetProfileIdentifier
} from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData,
AdminUserResponse, AdminUserResponse,
AdminUsersResponse, AdminUsersResponse,
EnhancedSymbolProfile, EnhancedAssetProfile,
ScraperConfiguration ScraperConfiguration
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { import type {
DateRange, DateRange,
MarketDataPreset, PropertyKey,
RequestWithUser RequestWithUser
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
@ -41,6 +44,7 @@ import {
Inject, Inject,
Logger, Logger,
Param, Param,
ParseIntPipe,
Patch, Patch,
Post, Post,
Put, Put,
@ -55,16 +59,20 @@ import { isDate, parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { PropertyKeyPipe } from './pipes/property-key.pipe';
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
private readonly logger = new Logger(AdminController.name);
public constructor( public constructor(
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly apiService: ApiService, private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly demoService: DemoService, private readonly demoService: DemoService,
private readonly manualService: ManualService, private readonly manualService: ManualService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@Get() @Get()
@ -84,8 +92,8 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post('gather') @Post('gather')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gather7Days(): Promise<void> { public async gatherRecentMarketData(): Promise<void> {
this.dataGatheringService.gather7Days(); this.dataGatheringService.gatherRecentMarketData();
} }
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@ -209,35 +217,6 @@ export class AdminController {
}); });
} }
@Get('market-data')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('presetId') presetId?: MarketDataPreset,
@Query('query') filterBySearchQuery?: string,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('take') take?: number
): Promise<AdminMarketData> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses,
filterByDataSource,
filterBySearchQuery
});
return this.adminService.getMarketData({
filters,
presetId,
sortColumn,
sortDirection,
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take
});
}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol/test') @Post('market-data/:dataSource/:symbol/test')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -260,7 +239,7 @@ export class AdminController {
`Could not parse the market price for ${symbol} (${dataSource})` `Could not parse the market price for ${symbol} (${dataSource})`
); );
} catch (error) { } catch (error) {
Logger.error(error, 'AdminController'); this.logger.error(error);
throw new HttpException(error.message, StatusCodes.BAD_REQUEST); throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
} }
@ -288,6 +267,33 @@ export class AdminController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<void> { ): Promise<void> {
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource, symbol }
]);
if (assetProfile) {
const benchmarkAssetProfiles =
await this.benchmarkService.getBenchmarkAssetProfiles();
const isBenchmark = benchmarkAssetProfiles.some(({ id }) => {
return id === assetProfile.id;
});
if (
!canDeleteAssetProfile({
isBenchmark,
activitiesCount: assetProfile.activitiesCount,
symbol: assetProfile.symbol,
watchedByCount: assetProfile.watchedByCount
})
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
return this.adminService.deleteProfileData({ dataSource, symbol }); return this.adminService.deleteProfileData({ dataSource, symbol });
} }
@ -298,7 +304,7 @@ export class AdminController {
@Body() assetProfile: UpdateAssetProfileDto, @Body() assetProfile: UpdateAssetProfileDto,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<EnhancedSymbolProfile> { ): Promise<EnhancedAssetProfile> {
return this.adminService.patchAssetProfileData( return this.adminService.patchAssetProfileData(
{ dataSource, symbol }, { dataSource, symbol },
assetProfile assetProfile
@ -309,7 +315,7 @@ export class AdminController {
@Put('settings/:key') @Put('settings/:key')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateProperty( public async updateProperty(
@Param('key') key: string, @Param('key', PropertyKeyPipe) key: PropertyKey,
@Body() data: UpdatePropertyDto @Body() data: UpdatePropertyDto
) { ) {
return this.adminService.putSetting(key, data.value); return this.adminService.putSetting(key, data.value);
@ -319,12 +325,12 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getUsers( public async getUsers(
@Query('skip') skip?: number, @Query('skip', new ParseIntPipe({ optional: true })) skip?: number,
@Query('take') take?: number @Query('take', new ParseIntPipe({ optional: true })) take?: number
): Promise<AdminUsersResponse> { ): Promise<AdminUsersResponse> {
return this.adminService.getUsers({ return this.adminService.getUsers({
skip: isNaN(skip) ? undefined : skip, skip,
take: isNaN(take) ? undefined : take take
}); });
} }

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

@ -1,6 +1,4 @@
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -20,8 +18,6 @@ import { QueueModule } from './queue/queue.module';
@Module({ @Module({
imports: [ imports: [
ActivitiesModule,
ApiModule,
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataGatheringQueueModule, DataGatheringQueueModule,

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

@ -1,6 +1,4 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -9,28 +7,24 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
ghostfolioPrefix,
PROPERTY_CURRENCIES, PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
applyAssetProfileOverrides,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getCurrencyFromSymbol, getCurrencyFromSymbol,
isCurrency hasGhostfolioPrefix
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
AdminUserResponse, AdminUserResponse,
AdminUsersResponse, AdminUsersResponse,
AssetProfileIdentifier, AssetProfileIdentifier
EnhancedSymbolProfile,
Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { PropertyKey } from '@ghostfolio/common/types';
import { MarketDataPreset } from '@ghostfolio/common/types';
import { import {
BadRequestException, BadRequestException,
@ -48,13 +42,11 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { groupBy } from 'lodash'; import { randomUUID } from 'node:crypto';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
@ -73,6 +65,12 @@ export class AdminService {
> { > {
try { try {
if (dataSource === 'MANUAL') { if (dataSource === 'MANUAL') {
if (!hasGhostfolioPrefix(symbol)) {
throw new BadRequestException(
`symbol ("${symbol}") must start with the prefix "${ghostfolioPrefix}_" for the data source ("${dataSource}")`
);
}
return this.symbolProfileService.add({ return this.symbolProfileService.add({
currency, currency,
dataSource, dataSource,
@ -84,14 +82,17 @@ export class AdminService {
{ dataSource, symbol } { dataSource, symbol }
]); ]);
if (!assetProfiles[symbol]?.currency) { const assetProfile =
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })];
if (!assetProfile?.currency) {
throw new BadRequestException( throw new BadRequestException(
`Asset profile not found for ${symbol} (${dataSource})` `Asset profile not found for ${symbol} (${dataSource})`
); );
} }
return this.symbolProfileService.add( return this.symbolProfileService.add(
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput assetProfile as Prisma.SymbolProfileCreateInput
); );
} catch (error) { } catch (error) {
if ( if (
@ -114,8 +115,11 @@ export class AdminService {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
const currency = getCurrencyFromSymbol(symbol); const currency = getCurrencyFromSymbol(symbol);
const customCurrencies =
await this.propertyService.getByKey<string[]>(PROPERTY_CURRENCIES); const customCurrencies = await this.propertyService.getByKey<string[]>(
PROPERTY_CURRENCIES,
{ skipCache: true }
);
if (customCurrencies.includes(currency)) { if (customCurrencies.includes(currency)) {
const updatedCustomCurrencies = customCurrencies.filter( const updatedCustomCurrencies = customCurrencies.filter(
@ -188,332 +192,24 @@ export class AdminService {
}; };
} }
public async getMarketData({ public async getUser(id: string): Promise<AdminUserResponse> {
filters, const [user] = await this.getUsersWithAnalytics({
presetId, where: { id }
sortColumn,
sortDirection = 'asc',
skip,
take = Number.MAX_SAFE_INTEGER
}: {
filters?: Filter[];
presetId?: MarketDataPreset;
skip?: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
take?: number;
}): Promise<AdminMarketData> {
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
[{ symbol: 'asc' }];
const where: Prisma.SymbolProfileWhereInput = {};
if (presetId === 'BENCHMARKS') {
const benchmarkAssetProfiles =
await this.benchmarkService.getBenchmarkAssetProfiles();
where.id = {
in: benchmarkAssetProfiles.map(({ id }) => {
return id;
})
};
} else if (presetId === 'CURRENCIES') {
return this.getMarketDataForCurrencies();
} else if (
presetId === 'ETF_WITHOUT_COUNTRIES' ||
presetId === 'ETF_WITHOUT_SECTORS'
) {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
} else if (presetId === 'NO_ACTIVITIES') {
where.activities = {
none: {}
};
}
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
const {
ASSET_SUB_CLASS: filtersByAssetSubClass,
DATA_SOURCE: filtersByDataSource
} = groupBy(filters, ({ type }) => {
return type;
});
const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
if (filtersByAssetSubClass) {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
}
if (filtersByDataSource) {
where.dataSource = DataSource[filtersByDataSource[0].id];
}
if (searchQuery) {
where.OR = [
{ id: { mode: 'insensitive', startsWith: searchQuery } },
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
];
}
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }];
if (sortColumn === 'activitiesCount') {
orderBy = [
{
activities: {
_count: sortDirection
}
}
];
}
}
const extendedPrismaClient = this.getExtendedPrismaClient();
const symbolProfileResult = await Promise.all([
extendedPrismaClient.symbolProfile.findMany({
skip,
take,
where,
orderBy: [...orderBy, { id: sortDirection }],
select: {
_count: {
select: {
activities: true,
watchedBy: true
}
},
activities: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
assetClass: true,
assetSubClass: true,
comment: true,
countries: true,
currency: true,
dataSource: true,
id: true,
isActive: true,
isUsedByUsersWithSubscription: true,
name: true,
scraperConfiguration: true,
sectors: true,
symbol: true,
SymbolProfileOverrides: true
}
}),
this.prismaService.symbolProfile.count({ where })
]);
const assetProfiles = symbolProfileResult[0];
let count = symbolProfileResult[1];
const lastMarketPrices = await this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: assetProfiles.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: assetProfiles.map(({ symbol }) => {
return symbol;
})
}
}
}); });
const lastMarketPriceMap = new Map<string, number>(); if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
let marketData: AdminMarketDataItem[] = await Promise.all(
assetProfiles.map(
async ({
_count,
activities,
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
id,
isActive,
isUsedByUsersWithSubscription,
name,
sectors,
symbol,
SymbolProfileOverrides
}) => {
let countriesCount = countries ? Object.keys(countries).length : 0;
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
let sectorsCount = sectors ? Object.keys(sectors).length : 0;
if (SymbolProfileOverrides) {
assetClass = SymbolProfileOverrides.assetClass ?? assetClass;
assetSubClass =
SymbolProfileOverrides.assetSubClass ?? assetSubClass;
if (
(SymbolProfileOverrides.countries as unknown as Prisma.JsonArray)
?.length > 0
) {
countriesCount = (
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
).length;
}
name = SymbolProfileOverrides.name ?? name;
if (
(SymbolProfileOverrides.sectors as unknown as Sector[])?.length >
0
) {
sectorsCount = (
SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray
).length;
}
}
return {
assetClass,
assetSubClass,
comment,
countriesCount,
currency,
dataSource,
id,
isActive,
lastMarketPrice,
marketDataItemCount,
name,
sectorsCount,
symbol,
activitiesCount: _count.activities,
date: activities?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription,
watchedByCount: _count.watchedBy
};
}
)
);
if (presetId) {
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
marketData = marketData.filter(({ countriesCount }) => {
return countriesCount === 0;
});
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
marketData = marketData.filter(({ sectorsCount }) => {
return sectorsCount === 0;
});
}
count = marketData.length;
}
return {
count,
marketData
};
}
public async getMarketDataBySymbol({
dataSource,
symbol
}: AssetProfileIdentifier): Promise<AdminMarketDataDetails> {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
const isCurrencyAssetProfile = isCurrency(getCurrencyFromSymbol(symbol));
if (isCurrencyAssetProfile) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.activitiesService.getStatisticsByCurrency(currency));
} }
const [[assetProfile], marketData] = await Promise.all([ if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
this.symbolProfileService.getSymbolProfiles([ user.subscriptions = await this.prismaService.subscription.findMany({
{
dataSource,
symbol
}
]),
this.marketDataService.marketDataItems({
orderBy: { orderBy: {
date: 'asc' expiresAt: 'desc'
}, },
where: { where: {
dataSource, userId: id
symbol
} }
}) });
]);
if (assetProfile) {
assetProfile.dataProviderInfo = this.dataProviderService
.getDataProvider(assetProfile.dataSource)
.getDataProviderInfo();
}
return {
marketData,
assetProfile: assetProfile ?? {
activitiesCount,
currency,
dataSource,
dateOfFirstActivity,
symbol,
assetClass: isCurrencyAssetProfile ? AssetClass.LIQUIDITY : undefined,
assetSubClass: isCurrencyAssetProfile ? AssetSubClass.CASH : undefined,
isActive: true
}
};
}
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; return user;
@ -545,6 +241,7 @@ export class AdminService {
comment, comment,
countries, countries,
currency, currency,
dataGatheringFrequency,
dataSource: newDataSource, dataSource: newDataSource,
holdings, holdings,
isActive, isActive,
@ -556,16 +253,25 @@ export class AdminService {
url url
}: Prisma.SymbolProfileUpdateInput }: Prisma.SymbolProfileUpdateInput
) { ) {
const isConversionToManualDataSource =
newDataSource === DataSource.MANUAL && dataSource !== DataSource.MANUAL;
if (isConversionToManualDataSource && !newSymbol) {
newSymbol = randomUUID();
}
if ( if (
newSymbol &&
newDataSource && newDataSource &&
(newSymbol !== symbol || newDataSource !== dataSource) newSymbol &&
(newDataSource !== dataSource || newSymbol !== symbol)
) { ) {
const newAssetProfileIdentifier: AssetProfileIdentifier = {
dataSource: newDataSource as DataSource,
symbol: newSymbol as string
};
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{ newAssetProfileIdentifier
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
]); ]);
if (assetProfile) { if (assetProfile) {
@ -575,47 +281,85 @@ export class AdminService {
); );
} }
try { const operations: Prisma.PrismaPromise<unknown>[] = [
Promise.all([ this.symbolProfileService.updateAssetProfileIdentifier(
await this.symbolProfileService.updateAssetProfileIdentifier( {
{ dataSource,
dataSource, symbol
symbol },
}, newAssetProfileIdentifier
{ ),
dataSource: DataSource[newDataSource.toString()], this.marketDataService.updateAssetProfileIdentifier(
symbol: newSymbol as string {
} dataSource,
symbol
},
newAssetProfileIdentifier
)
];
if (isConversionToManualDataSource) {
const currentAssetProfile =
await this.prismaService.symbolProfile.findUnique({
include: { assetProfileOverrides: true },
where: { dataSource_symbol: { dataSource, symbol } }
});
if (!currentAssetProfile) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const currentAssetProfileWithOverrides = applyAssetProfileOverrides(
currentAssetProfile,
currentAssetProfile.assetProfileOverrides
);
operations.push(
// The overrides are applied on every read, so delete them and
// persist the merged values in the asset profile instead
this.symbolProfileService.deleteAssetProfileOverrides(
newAssetProfileIdentifier
), ),
await this.marketDataService.updateAssetProfileIdentifier( this.symbolProfileService.updateSymbolProfile(
newAssetProfileIdentifier,
{ {
dataSource, assetClass: currentAssetProfileWithOverrides.assetClass,
symbol assetSubClass: currentAssetProfileWithOverrides.assetSubClass,
}, countries:
{ currentAssetProfileWithOverrides.countries ?? undefined,
dataSource: DataSource[newDataSource.toString()], holdings: currentAssetProfileWithOverrides.holdings ?? undefined,
symbol: newSymbol as string name: currentAssetProfileWithOverrides.name,
sectors: currentAssetProfileWithOverrides.sectors ?? undefined,
url: currentAssetProfileWithOverrides.url
} }
) )
]); );
}
return this.symbolProfileService.getSymbolProfiles([ try {
{ await this.prismaService.$transaction(operations);
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
])?.[0];
} catch { } catch {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST), getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST StatusCodes.BAD_REQUEST
); );
} }
const [updatedAssetProfile] =
await this.symbolProfileService.getSymbolProfiles([
newAssetProfileIdentifier
]);
return updatedAssetProfile;
} else { } else {
const symbolProfileOverrides = { const assetProfileOverrides = {
assetClass: assetClass as AssetClass, assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass, assetSubClass: assetSubClass as AssetSubClass,
countries: countries as Prisma.JsonArray, countries: countries as Prisma.JsonArray,
holdings: holdings as Prisma.JsonArray,
name: name as string, name: name as string,
sectors: sectors as Prisma.JsonArray, sectors: sectors as Prisma.JsonArray,
url: url as string url: url as string
@ -624,22 +368,16 @@ export class AdminService {
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = { const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = {
comment, comment,
currency, currency,
dataGatheringFrequency,
dataSource, dataSource,
holdings,
isActive, isActive,
scraperConfiguration, scraperConfiguration,
symbol, symbol,
symbolMapping, symbolMapping,
...(dataSource === 'MANUAL' ...this.symbolProfileService.getAssetProfileUpdateInput(
? { assetClass, assetSubClass, countries, name, sectors, url } { dataSource, symbol },
: { assetProfileOverrides
SymbolProfileOverrides: { )
upsert: {
create: symbolProfileOverrides,
update: symbolProfileOverrides
}
}
})
}; };
await this.symbolProfileService.updateSymbolProfile( await this.symbolProfileService.updateSymbolProfile(
@ -650,22 +388,30 @@ export class AdminService {
updatedSymbolProfile updatedSymbolProfile
); );
return this.symbolProfileService.getSymbolProfiles([ const [updatedAssetProfile] =
{ await this.symbolProfileService.getSymbolProfiles([
dataSource: dataSource as DataSource, {
symbol: symbol as string dataSource: dataSource as DataSource,
} symbol: symbol as string
])?.[0]; }
]);
return updatedAssetProfile;
} }
} }
public async putSetting(key: string, value: string) { public async putSetting(key: PropertyKey, value: string) {
let response: Property; let response: Property;
if (value) { if (value) {
response = await this.propertyService.put({ key, value }); response = await this.propertyService.put({
key,
value
});
} else { } else {
response = await this.propertyService.delete({ key }); response = await this.propertyService.delete({
key
});
} }
if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') { if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') {
@ -693,138 +439,6 @@ export class AdminService {
}); });
} }
private getExtendedPrismaClient() {
const symbolProfileExtension = Prisma.defineExtension((client) => {
return client.$extends({
result: {
symbolProfile: {
isUsedByUsersWithSubscription: {
compute: async ({ id }) => {
const { _count } =
await this.prismaService.symbolProfile.findUnique({
select: {
_count: {
select: {
activities: {
where: {
user: {
subscriptions: {
some: {
expiresAt: {
gt: new Date()
}
}
}
}
}
}
}
}
},
where: {
id
}
});
return _count.activities > 0;
}
}
}
}
});
});
return this.prismaService.$extends(symbolProfileExtension);
}
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
const currencyPairs = this.exchangeRateDataService.getCurrencyPairs();
const [lastMarketPrices, marketDataItems] = await Promise.all([
this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: currencyPairs.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: currencyPairs.map(({ symbol }) => {
return symbol;
})
}
}
}),
this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
})
]);
const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
const marketDataPromise: Promise<AdminMarketDataItem>[] = currencyPairs.map(
async ({ dataSource, symbol }) => {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.activitiesService.getStatisticsByCurrency(currency));
}
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
activitiesCount,
currency,
dataSource,
lastMarketPrice,
marketDataItemCount,
symbol,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countriesCount: 0,
date: dateOfFirstActivity,
id: undefined,
isActive: true,
name: symbol,
sectorsCount: 0,
watchedByCount: 0
};
}
);
const marketData = await Promise.all(marketDataPromise);
return { marketData, count: marketData.length };
}
private async getUsersWithAnalytics({ private async getUsersWithAnalytics({
skip, skip,
take, take,
@ -876,7 +490,7 @@ export class AdminService {
activityCount: true, activityCount: true,
country: true, country: true,
dataProviderGhostfolioDailyRequests: true, dataProviderGhostfolioDailyRequests: true,
updatedAt: true lastRequestAt: true
} }
}, },
createdAt: true, createdAt: true,
@ -922,7 +536,7 @@ export class AdminService {
activityCount: _count.activities || 0, activityCount: _count.activities || 0,
country: analytics?.country, country: analytics?.country,
dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0, dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0,
lastActivity: analytics?.updatedAt lastActivity: analytics?.lastRequestAt
}; };
} }
); );

29
apps/api/src/app/admin/pipes/property-key.pipe.ts

@ -0,0 +1,29 @@
import * as config from '@ghostfolio/common/config';
import type { PropertyKey } from '@ghostfolio/common/types';
import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
@Injectable()
export class PropertyKeyPipe implements PipeTransform<string, PropertyKey> {
private readonly allowedKeys: Set<string>;
public constructor() {
this.allowedKeys = new Set<string>(
Object.entries(config)
.filter(([key]) => {
return key.startsWith('PROPERTY_');
})
.map(([, value]) => {
return value as string;
})
);
}
public transform(value: string): PropertyKey {
if (!this.allowedKeys.has(value)) {
throw new BadRequestException(`Invalid property key: ${value}`);
}
return value as PropertyKey;
}
}

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

@ -56,7 +56,7 @@ export class QueueService {
} }
public async getJobs({ public async getJobs({
limit = 1000, limit = 5000,
status = QUEUE_JOB_STATUS_LIST status = QUEUE_JOB_STATUS_LIST
}: { }: {
limit?: number; limit?: number;

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

@ -1,7 +1,10 @@
import { EventsModule } from '@ghostfolio/api/events/events.module'; import { EventsModule } from '@ghostfolio/api/events/events.module';
import { PortfolioSnapshotComputationExceptionFilter } from '@ghostfolio/api/filters/portfolio-snapshot-computation-exception.filter';
import { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper';
import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware'; import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware';
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware'; import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CronModule } from '@ghostfolio/api/services/cron/cron.module'; import { CronModule } from '@ghostfolio/api/services/cron/cron.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -12,19 +15,22 @@ import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-g
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { import {
BULL_BOARD_ROUTE, BULL_BOARD_ROUTE,
DEFAULT_LANGUAGE_CODE, THROTTLE_DEFAULT_LIMIT,
SUPPORTED_LANGUAGE_CODES THROTTLE_DEFAULT_TTL
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { ExpressAdapter } from '@bull-board/express'; import { ExpressAdapter } from '@bull-board/express';
import { BullBoardModule } from '@bull-board/nestjs'; import { BullBoardModule } from '@bull-board/nestjs';
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { APP_FILTER } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { StatusCodes } from 'http-status-codes'; import { ThrottlerModule } from '@nestjs/throttler';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { join } from 'node:path'; import { join } from 'node:path';
import { AccessModule } from './access/access.module'; import { AccessModule } from './access/access.module';
@ -38,6 +44,7 @@ import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { AiModule } from './endpoints/ai/ai.module'; import { AiModule } from './endpoints/ai/ai.module';
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
import { AssetProfilesModule } from './endpoints/asset-profiles/asset-profiles.module';
import { AssetsModule } from './endpoints/assets/assets.module'; import { AssetsModule } from './endpoints/assets/assets.module';
import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module'; import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
@ -69,6 +76,7 @@ import { UserModule } from './user/user.module';
ActivitiesModule, ActivitiesModule,
AiModule, AiModule,
ApiKeysModule, ApiKeysModule,
AssetProfilesModule,
AssetModule, AssetModule,
AssetsModule, AssetsModule,
AuthDeviceModule, AuthDeviceModule,
@ -93,12 +101,13 @@ import { UserModule } from './user/user.module';
middleware: BullBoardAuthMiddleware, middleware: BullBoardAuthMiddleware,
route: BULL_BOARD_ROUTE route: BULL_BOARD_ROUTE
}), }),
BullModule.forRoot({ BullModule.forRootAsync({
redis: { imports: [ConfigurationModule],
db: parseInt(process.env.REDIS_DB ?? '0', 10), inject: [ConfigurationService],
host: process.env.REDIS_HOST, useFactory: (configurationService: ConfigurationService) => {
password: process.env.REDIS_PASSWORD, return {
port: parseInt(process.env.REDIS_PORT ?? '6379', 10) redis: getRedisConnectionOptions(configurationService)
};
} }
}), }),
CacheModule, CacheModule,
@ -134,27 +143,7 @@ import { UserModule } from './user/user.module';
'/api/*wildcard', '/api/*wildcard',
'/sitemap.xml' '/sitemap.xml'
], ],
rootPath: join(__dirname, '..', 'client'), rootPath: join(__dirname, '..', 'client')
serveStaticOptions: {
setHeaders: (res) => {
if (res.req?.path === '/') {
let languageCode = DEFAULT_LANGUAGE_CODE;
try {
const code = res.req.headers['accept-language']
.split(',')[0]
.split('-')[0];
if (SUPPORTED_LANGUAGE_CODES.includes(code)) {
languageCode = code;
}
} catch {}
res.set('Location', `/${languageCode}`);
res.statusCode = StatusCodes.MOVED_PERMANENTLY;
}
}
}
}), }),
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'client', '.well-known'), rootPath: join(__dirname, '..', 'client', '.well-known'),
@ -164,10 +153,46 @@ import { UserModule } from './user/user.module';
SubscriptionModule, SubscriptionModule,
SymbolModule, SymbolModule,
TagsModule, TagsModule,
ThrottlerModule.forRootAsync({
imports: [ConfigurationModule],
inject: [ConfigurationService],
useFactory: (configurationService: ConfigurationService) => {
const isRateLimitingEnabled = configurationService.get(
'ENABLE_FEATURE_RATE_LIMITING'
);
return {
errorMessage: getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
skipIf: () => {
return !isRateLimitingEnabled;
},
storage: isRateLimitingEnabled
? new ThrottlerStorageRedisService({
...getRedisConnectionOptions(configurationService),
// Reject commands immediately while Redis is unavailable
enableOfflineQueue: false,
maxRetriesPerRequest: 1
})
: undefined,
throttlers: [
{
limit: THROTTLE_DEFAULT_LIMIT,
ttl: THROTTLE_DEFAULT_TTL
}
]
};
}
}),
UserModule, UserModule,
WatchlistModule WatchlistModule
], ],
providers: [I18nService] providers: [
I18nService,
{
provide: APP_FILTER,
useClass: PortfolioSnapshotComputationExceptionFilter
}
]
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {
public configure(consumer: MiddlewareConsumer) { public configure(consumer: MiddlewareConsumer) {

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

@ -1,4 +1,4 @@
import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; import { AssetProfilesService } from '@ghostfolio/api/app/endpoints/asset-profiles/asset-profiles.service';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import type { AssetResponse } from '@ghostfolio/common/interfaces'; import type { AssetResponse } from '@ghostfolio/common/interfaces';
@ -9,7 +9,9 @@ import { pick } from 'lodash';
@Controller('asset') @Controller('asset')
export class AssetController { export class AssetController {
public constructor(private readonly adminService: AdminService) {} public constructor(
private readonly assetProfilesService: AssetProfilesService
) {}
@Get(':dataSource/:symbol') @Get(':dataSource/:symbol')
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@ -18,11 +20,15 @@ export class AssetController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<AssetResponse> { ): Promise<AssetResponse> {
const { assetProfile, marketData } = const { assetProfile, marketData, splits } =
await this.adminService.getMarketDataBySymbol({ dataSource, symbol }); await this.assetProfilesService.getAssetProfile({
dataSource,
symbol
});
return { return {
marketData, marketData,
splits,
assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol']) assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol'])
}; };
} }

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

@ -1,4 +1,4 @@
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module'; import { AssetProfilesModule } from '@ghostfolio/api/app/endpoints/asset-profiles/asset-profiles.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
@ -9,7 +9,7 @@ import { AssetController } from './asset.controller';
@Module({ @Module({
controllers: [AssetController], controllers: [AssetController],
imports: [ imports: [
AdminModule, AssetProfilesModule,
TransformDataSourceInRequestModule, TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule TransformDataSourceInResponseModule
] ]

7
apps/api/src/app/auth/api-key.strategy.ts

@ -35,6 +35,13 @@ export class ApiKeyStrategy extends PassportStrategy(
); );
} }
if (await this.userService.isDailyRequestLimitExceeded({ user })) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
await this.prismaService.analytics.upsert({ await this.prismaService.analytics.upsert({
create: { user: { connect: { id: user.id } } }, create: { user: { connect: { id: user.id } } },
update: { update: {

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

@ -1,4 +1,5 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
@ -13,7 +14,6 @@ import {
Controller, Controller,
Get, Get,
HttpException, HttpException,
Param,
Post, Post,
Req, Req,
Res, Res,
@ -35,26 +35,8 @@ export class AuthController {
private readonly webAuthService: WebAuthService private readonly webAuthService: WebAuthService
) {} ) {}
/**
* @deprecated
*/
@Get('anonymous/:accessToken')
public async accessTokenLoginGet(
@Param('accessToken') accessToken: string
): Promise<OAuthResponse> {
try {
const authToken =
await this.authService.validateAnonymousLogin(accessToken);
return { authToken };
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
@Post('anonymous') @Post('anonymous')
@UseGuards(CustomThrottlerGuard)
public async accessTokenLogin( public async accessTokenLogin(
@Body() body: { accessToken: string } @Body() body: { accessToken: string }
): Promise<OAuthResponse> { ): Promise<OAuthResponse> {
@ -135,6 +117,7 @@ export class AuthController {
} }
@Post('webauthn/generate-authentication-options') @Post('webauthn/generate-authentication-options')
@UseGuards(CustomThrottlerGuard)
public async generateAuthenticationOptions( public async generateAuthenticationOptions(
@Body() body: { deviceId: string } @Body() body: { deviceId: string }
) { ) {
@ -156,6 +139,7 @@ export class AuthController {
} }
@Post('webauthn/verify-authentication') @Post('webauthn/verify-authentication')
@UseGuards(CustomThrottlerGuard)
public async verifyAuthentication( public async verifyAuthentication(
@Body() body: { deviceId: string; credential: AssertionCredentialJSON } @Body() body: { deviceId: string; credential: AssertionCredentialJSON }
) { ) {

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

@ -1,12 +1,17 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { Logger, Module } from '@nestjs/common'; import { Logger, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -22,13 +27,17 @@ import { OidcStrategy } from './oidc.strategy';
@Module({ @Module({
controllers: [AuthController], controllers: [AuthController],
imports: [ imports: [
ApiModule,
ConfigurationModule, ConfigurationModule,
FetchModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' } signOptions: { expiresIn: '180 days' }
}), }),
PortfolioSnapshotQueueModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
RedisCacheModule,
SubscriptionModule, SubscriptionModule,
UserModule UserModule
], ],
@ -40,12 +49,15 @@ import { OidcStrategy } from './oidc.strategy';
GoogleStrategy, GoogleStrategy,
JwtStrategy, JwtStrategy,
{ {
inject: [AuthService, ConfigurationService], inject: [AuthService, ConfigurationService, FetchService],
provide: OidcStrategy, provide: OidcStrategy,
useFactory: async ( useFactory: async (
authService: AuthService, authService: AuthService,
configurationService: ConfigurationService configurationService: ConfigurationService,
fetchService: FetchService
) => { ) => {
const logger = new Logger('OidcStrategy');
const isOidcEnabled = configurationService.get( const isOidcEnabled = configurationService.get(
'ENABLE_FEATURE_AUTH_OIDC' 'ENABLE_FEATURE_AUTH_OIDC'
); );
@ -81,7 +93,7 @@ import { OidcStrategy } from './oidc.strategy';
} else { } else {
// Fetch OIDC configuration from discovery endpoint // Fetch OIDC configuration from discovery endpoint
try { try {
const response = await fetch( const response = await fetchService.fetch(
`${issuer}/.well-known/openid-configuration` `${issuer}/.well-known/openid-configuration`
); );
@ -97,7 +109,7 @@ import { OidcStrategy } from './oidc.strategy';
tokenURL = manualTokenUrl || config.token_endpoint; tokenURL = manualTokenUrl || config.token_endpoint;
userInfoURL = manualUserInfoUrl || config.userinfo_endpoint; userInfoURL = manualUserInfoUrl || config.userinfo_endpoint;
} catch (error) { } catch (error) {
Logger.error(error, 'OidcStrategy'); logger.error(error);
throw new Error('Failed to fetch OIDC configuration from issuer'); throw new Error('Failed to fetch OIDC configuration from issuer');
} }
} }

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

@ -10,6 +10,8 @@ import { AuthService } from './auth.service';
@Injectable() @Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
private readonly logger = new Logger(GoogleStrategy.name);
public constructor( public constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
configurationService: ConfigurationService configurationService: ConfigurationService
@ -40,7 +42,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
done(null, { jwt }); done(null, { jwt });
} catch (error) { } catch (error) {
Logger.error(error, 'GoogleStrategy'); this.logger.error(error);
done(error, false); done(error, false);
} }
} }

7
apps/api/src/app/auth/jwt.strategy.ts

@ -42,6 +42,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
); );
} }
if (await this.userService.isDailyRequestLimitExceeded({ user })) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
const country = const country =
countriesAndTimezones.getCountryForTimezone(timezone)?.id; countriesAndTimezones.getCountryForTimezone(timezone)?.id;

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

@ -15,6 +15,8 @@ import { OidcStateStore } from './oidc-state.store';
@Injectable() @Injectable()
export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
private readonly logger = new Logger(OidcStrategy.name);
private static readonly stateStore = new OidcStateStore(); private static readonly stateStore = new OidcStateStore();
public constructor( public constructor(
@ -52,9 +54,8 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
}); });
if (!thirdPartyId) { if (!thirdPartyId) {
Logger.error( this.logger.error(
`Missing subject identifier in OIDC response from ${issuer}`, `Missing subject identifier in OIDC response from ${issuer}`
'OidcStrategy'
); );
throw new Error('Missing subject identifier in OIDC response'); throw new Error('Missing subject identifier in OIDC response');
@ -62,7 +63,7 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
return { jwt }; return { jwt };
} catch (error) { } catch (error) {
Logger.error(error, 'OidcStrategy'); this.logger.error(error);
throw error; throw error;
} }
} }

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

@ -1,6 +1,15 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import {
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS
} from '@ghostfolio/common/config';
import { AuthDeviceDto } from '@ghostfolio/common/dtos'; import { AuthDeviceDto } from '@ghostfolio/common/dtos';
import { import {
AssertionCredentialJSON, AssertionCredentialJSON,
@ -29,14 +38,20 @@ import {
VerifyRegistrationResponseOpts VerifyRegistrationResponseOpts
} from '@simplewebauthn/server'; } from '@simplewebauthn/server';
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers'; import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
import { isPast } from 'date-fns';
import ms from 'ms'; import ms from 'ms';
@Injectable() @Injectable()
export class WebAuthService { export class WebAuthService {
private readonly logger = new Logger(WebAuthService.name);
public constructor( public constructor(
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly deviceService: AuthDeviceService, private readonly deviceService: AuthDeviceService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly portfolioSnapshotService: PortfolioSnapshotService,
private readonly redisCacheService: RedisCacheService,
private readonly userService: UserService, private readonly userService: UserService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -103,7 +118,7 @@ export class WebAuthService {
verification = await verifyRegistrationResponse(opts); verification = await verifyRegistrationResponse(opts);
} catch (error) { } catch (error) {
Logger.error(error, 'WebAuthService'); this.logger.error(error);
throw new InternalServerErrorException(error.message); throw new InternalServerErrorException(error.message);
} }
@ -153,6 +168,9 @@ export class WebAuthService {
throw new Error('Device not found'); throw new Error('Device not found');
} }
// Compute in the background during the biometric authentication
void this.warmUpPortfolioSnapshot({ userId: device.userId });
const opts: GenerateAuthenticationOptionsOpts = { const opts: GenerateAuthenticationOptionsOpts = {
allowCredentials: [], allowCredentials: [],
rpID: this.rpID, rpID: this.rpID,
@ -210,7 +228,7 @@ export class WebAuthService {
verification = await verifyAuthenticationResponse(opts); verification = await verifyAuthenticationResponse(opts);
} catch (error) { } catch (error) {
Logger.error(error, 'WebAuthService'); this.logger.error(error);
throw new InternalServerErrorException({ error: error.message }); throw new InternalServerErrorException({ error: error.message });
} }
@ -231,4 +249,57 @@ export class WebAuthService {
throw new Error(); throw new Error();
} }
private async isPortfolioSnapshotExpired(portfolioSnapshotKey: string) {
try {
const { expiration }: PortfolioSnapshotValue = JSON.parse(
await this.redisCacheService.get(portfolioSnapshotKey)
);
return isPast(new Date(expiration));
} catch {
return true;
}
}
private async warmUpPortfolioSnapshot({ userId }: { userId: string }) {
try {
const user = await this.userService.user({ id: userId });
if (!user) {
return;
}
const userSettings = user.settings.settings;
const filters = this.apiService.buildFiltersFromUserSettings({
userSettings
});
const portfolioSnapshotKey =
this.redisCacheService.getPortfolioSnapshotKey({ filters, userId });
if (await this.isPortfolioSnapshotExpired(portfolioSnapshotKey)) {
await this.portfolioSnapshotService.addJobToQueue({
data: {
filters,
userId,
calculationType: userSettings.performanceCalculationType,
userCurrency: userSettings.baseCurrency
},
name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
opts: {
...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
jobId: portfolioSnapshotKey,
priority: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW
}
});
}
} catch (error) {
this.logger.error(
`Portfolio snapshot of user '${userId}' could not be warmed up`,
error
);
}
}
} }

18
apps/api/src/app/endpoints/ai/ai.controller.ts

@ -1,4 +1,5 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { FilterDto } from '@ghostfolio/api/dtos/filter.dto';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { AiPromptResponse } from '@ghostfolio/common/interfaces'; import { AiPromptResponse } from '@ghostfolio/common/interfaces';
@ -31,18 +32,15 @@ export class AiController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPrompt( public async getPrompt(
@Param('mode') mode: AiPromptMode, @Param('mode') mode: AiPromptMode,
@Query('accounts') filterByAccounts?: string, @Query()
@Query('assetClasses') filterByAssetClasses?: string, { accounts, assetClasses, dataSource, symbol, tags }: FilterDto
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<AiPromptResponse> { ): Promise<AiPromptResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
const prompt = await this.aiService.getPrompt({ const prompt = await this.aiService.getPrompt({

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

@ -20,6 +20,7 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -44,6 +45,7 @@ import { AiService } from './ai.service';
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule, SymbolProfileModule,
TagModule,
UserModule UserModule
], ],
providers: [ providers: [

236
apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts

@ -0,0 +1,236 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
CreateAssetProfileSplitDto,
UpdateAssetProfileDataDto
} from '@ghostfolio/common/dtos';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { AssetProfileResponse } from '@ghostfolio/common/interfaces';
import {
AssetProfilesResponse,
EnhancedAssetProfile
} from '@ghostfolio/common/interfaces';
import { hasPermission } from '@ghostfolio/common/permissions';
import { permissions } from '@ghostfolio/common/permissions';
import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { AssetProfileSplit, DataSource, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AssetProfilesService } from './asset-profiles.service';
@Controller('asset-profiles')
export class AssetProfilesController {
public constructor(
private readonly apiService: ApiService,
private readonly assetProfilesService: AssetProfilesService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly symbolProfileService: SymbolProfileService
) {}
@Get()
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAssetProfiles(
@Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('presetId') presetId?: MarketDataPreset,
@Query('query') filterBySearchQuery?: string,
@Query('skip', new ParseIntPipe({ optional: true })) skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('take', new ParseIntPipe({ optional: true })) take?: number
): Promise<AssetProfilesResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses,
filterByDataSource,
filterBySearchQuery
});
return this.assetProfilesService.getAssetProfiles({
filters,
presetId,
skip,
sortColumn,
sortDirection,
take
});
}
@Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAssetProfile(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AssetProfileResponse> {
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource, symbol }
]);
if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const canReadAllAssetProfiles = hasPermission(
this.request.user.permissions,
permissions.readMarketData
);
const canReadOwnAssetProfile =
assetProfile?.userId === this.request.user.id &&
hasPermission(
this.request.user.permissions,
permissions.readMarketDataOfOwnAssetProfile
);
if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) {
throw new HttpException(
assetProfile?.userId
? getReasonPhrase(StatusCodes.NOT_FOUND)
: getReasonPhrase(StatusCodes.FORBIDDEN),
assetProfile?.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN
);
}
return this.assetProfilesService.getAssetProfile({
dataSource,
symbol
});
}
@Post(':dataSource/:symbol/splits')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createSplit(
@Body() data: CreateAssetProfileSplitDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AssetProfileSplit> {
const { id: symbolProfileId } = await this.validateAccessToSplits({
dataSource,
symbol,
permission: permissions.createAssetProfileSplit,
permissionOfOwnAssetProfile:
permissions.createAssetProfileSplitOfOwnAssetProfile
});
return this.assetProfilesService.createSplit({
dataSource,
symbol,
symbolProfileId,
date: parseISO(data.date),
denominator: data.denominator,
numerator: data.numerator
});
}
@Delete(':dataSource/:symbol/splits/:id')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteSplit(
@Param('dataSource') dataSource: DataSource,
@Param('id') id: string,
@Param('symbol') symbol: string
): Promise<void> {
const { id: symbolProfileId } = await this.validateAccessToSplits({
dataSource,
symbol,
permission: permissions.deleteAssetProfileSplit,
permissionOfOwnAssetProfile:
permissions.deleteAssetProfileSplitOfOwnAssetProfile
});
return this.assetProfilesService.deleteSplit({ id, symbolProfileId });
}
@HasPermission(permissions.accessAdminControl)
@Patch(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateAssetProfileData(
@Body() assetProfileData: UpdateAssetProfileDataDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<EnhancedAssetProfile> {
if (!this.request.user.settings.settings.isExperimentalFeatures) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return this.assetProfilesService.updateAssetProfileData(
{ dataSource, symbol },
assetProfileData
);
}
private async validateAccessToSplits({
dataSource,
permission,
permissionOfOwnAssetProfile,
symbol
}: {
dataSource: DataSource;
permission: string;
permissionOfOwnAssetProfile: string;
symbol: string;
}) {
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource, symbol }
]);
if (!assetProfile) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const canAccessAllAssetProfiles = hasPermission(
this.request.user.permissions,
permission
);
const canAccessOwnAssetProfile =
assetProfile.userId === this.request.user.id &&
hasPermission(this.request.user.permissions, permissionOfOwnAssetProfile);
if (!canAccessAllAssetProfiles && !canAccessOwnAssetProfile) {
throw new HttpException(
assetProfile.userId
? getReasonPhrase(StatusCodes.NOT_FOUND)
: getReasonPhrase(StatusCodes.FORBIDDEN),
assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN
);
}
return assetProfile;
}
}

38
apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts

@ -0,0 +1,38 @@
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { 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 { AssetProfileSplitModule } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { AssetProfilesController } from './asset-profiles.controller';
import { AssetProfilesService } from './asset-profiles.service';
@Module({
controllers: [AssetProfilesController],
exports: [AssetProfilesService],
imports: [
ActivitiesModule,
ApiModule,
AssetProfileSplitModule,
BenchmarkModule,
DataGatheringQueueModule,
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
PrismaModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],
providers: [AssetProfilesService]
})
export class AssetProfilesModule {}

587
apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts

@ -0,0 +1,587 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos';
import {
applyAssetProfileOverrides,
getAssetProfileIdentifier,
getCurrencyFromSymbol,
isCurrency
} from '@ghostfolio/common/helper';
import {
AdminMarketDataDetails,
AssetProfileIdentifier,
AssetProfileItem,
AssetProfilesResponse,
EnhancedAssetProfile,
Filter
} from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types';
import { Injectable, NotFoundException } from '@nestjs/common';
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
import { groupBy } from 'lodash';
@Injectable()
export class AssetProfilesService {
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly assetProfileSplitService: AssetProfileSplitService,
private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async createSplit({
dataSource,
date,
denominator,
numerator,
symbol,
symbolProfileId
}: {
date: Date;
denominator: number;
numerator: number;
symbolProfileId: string;
} & AssetProfileIdentifier) {
const assetProfileSplit = await this.assetProfileSplitService.upsert({
date,
denominator,
numerator,
symbolProfileId
});
await this.dataGatheringService.gatherSymbol({ dataSource, symbol });
return assetProfileSplit;
}
public async deleteSplit({
id,
symbolProfileId
}: {
id: string;
symbolProfileId: string;
}) {
const isDeleted = await this.assetProfileSplitService.deleteById({
id,
symbolProfileId
});
if (!isDeleted) {
throw new NotFoundException();
}
}
public async getAssetProfile({
dataSource,
symbol
}: AssetProfileIdentifier): Promise<AdminMarketDataDetails> {
let activitiesCount: EnhancedAssetProfile['activitiesCount'] = 0;
let currency: EnhancedAssetProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedAssetProfile['dateOfFirstActivity'];
const isCurrencyAssetProfile = isCurrency(getCurrencyFromSymbol(symbol));
if (isCurrencyAssetProfile) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.activitiesService.getStatisticsByCurrency(currency));
}
const [[assetProfile], marketData, splits] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]),
this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
dataSource,
symbol
}
}),
this.assetProfileSplitService.getSplits({ dataSource, symbol })
]);
if (assetProfile) {
assetProfile.dataProviderInfo = this.dataProviderService
.getDataProvider(assetProfile.dataSource)
.getDataProviderInfo();
}
return {
marketData,
splits,
assetProfile: assetProfile ?? {
activitiesCount,
currency,
dataSource,
dateOfFirstActivity,
symbol,
assetClass: isCurrencyAssetProfile ? AssetClass.LIQUIDITY : undefined,
assetSubClass: isCurrencyAssetProfile ? AssetSubClass.CASH : undefined,
isActive: true
}
};
}
public async getAssetProfiles({
filters = [],
presetId,
sortColumn,
sortDirection = 'asc',
skip,
take = Number.MAX_SAFE_INTEGER
}: {
filters?: Filter[];
presetId?: MarketDataPreset;
skip?: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
take?: number;
}): Promise<AssetProfilesResponse> {
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
[{ symbol: 'asc' }];
const where: Prisma.SymbolProfileWhereInput = {};
if (presetId === 'BENCHMARKS') {
const benchmarkAssetProfiles =
await this.benchmarkService.getBenchmarkAssetProfiles();
where.id = {
in: benchmarkAssetProfiles.map(({ id }) => {
return id;
})
};
} else if (presetId === 'CURRENCIES') {
return this.getAssetProfilesForCurrencies();
} else if (
presetId === 'ETF_WITHOUT_COUNTRIES' ||
presetId === 'ETF_WITHOUT_SECTORS'
) {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
} else if (presetId === 'NO_ACTIVITIES') {
where.activities = {
none: {}
};
}
const {
ASSET_SUB_CLASS: [filterByAssetSubClass] = [],
DATA_SOURCE: [filterByDataSource] = [],
SEARCH_QUERY: [filterBySearchQuery] = []
} = groupBy(filters, ({ type }) => {
return type;
});
const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
if (filterByAssetSubClass) {
where.assetSubClass = AssetSubClass[filterByAssetSubClass.id];
}
if (filterByDataSource) {
where.dataSource = DataSource[filterByDataSource.id];
}
if (filterBySearchQuery) {
where.OR = [
{ id: { mode: 'insensitive', startsWith: filterBySearchQuery.id } },
{ isin: { mode: 'insensitive', startsWith: filterBySearchQuery.id } },
{ name: { mode: 'insensitive', startsWith: filterBySearchQuery.id } },
{ symbol: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }
];
}
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }];
if (sortColumn === 'activitiesCount') {
orderBy = [
{
activities: {
_count: sortDirection
}
}
];
}
}
const extendedPrismaClient = this.getExtendedPrismaClient();
const symbolProfileResult = await Promise.all([
extendedPrismaClient.symbolProfile.findMany({
skip,
take,
where,
orderBy: [...orderBy, { id: sortDirection }],
select: {
_count: {
select: {
activities: true,
watchedBy: true
}
},
activities: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
assetClass: true,
assetProfileOverrides: true,
assetSubClass: true,
comment: true,
countries: true,
currency: true,
dataSource: true,
id: true,
isin: true,
isActive: true,
isUsedByUsersWithSubscription: true,
name: true,
scraperConfiguration: true,
sectors: true,
symbol: true
}
}),
this.prismaService.symbolProfile.count({ where })
]);
const symbolProfiles = symbolProfileResult[0];
let count = symbolProfileResult[1];
const lastMarketPrices = await this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: symbolProfiles.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: symbolProfiles.map(({ symbol }) => {
return symbol;
})
}
}
});
const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
let assetProfiles: AssetProfileItem[] = await Promise.all(
symbolProfiles.map(async (assetProfile) => {
const {
_count,
activities,
comment,
currency,
dataSource,
id,
isin,
isActive,
isUsedByUsersWithSubscription,
symbol
} = assetProfile;
const { assetClass, assetSubClass, countries, name, sectors } =
applyAssetProfileOverrides(
assetProfile,
assetProfile.assetProfileOverrides
);
const countriesCount = countries ? Object.keys(countries).length : 0;
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
assetClass,
assetSubClass,
comment,
countriesCount,
currency,
dataSource,
id,
isActive,
isin,
lastMarketPrice,
marketDataItemCount,
name,
sectorsCount,
symbol,
activitiesCount: _count.activities,
date: activities?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription,
watchedByCount: _count.watchedBy
};
})
);
if (presetId) {
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
assetProfiles = assetProfiles.filter(({ countriesCount }) => {
return countriesCount === 0;
});
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
assetProfiles = assetProfiles.filter(({ sectorsCount }) => {
return sectorsCount === 0;
});
}
count = assetProfiles.length;
}
return {
assetProfiles,
count
};
}
public async updateAssetProfileData(
{ dataSource, symbol }: AssetProfileIdentifier,
assetProfileData: UpdateAssetProfileDataDto
): Promise<EnhancedAssetProfile> {
const notFoundMessage = `Could not find the asset profile for ${symbol} (${dataSource})`;
const data = this.getAssetProfileDataUpdate(assetProfileData);
if (Object.keys(data).length > 0) {
try {
await this.symbolProfileService.updateSymbolProfile(
{
dataSource,
symbol
},
this.symbolProfileService.getAssetProfileUpdateInput(
{ dataSource, symbol },
data
)
);
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2025'
) {
throw new NotFoundException(notFoundMessage);
}
throw error;
}
}
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]);
if (!assetProfile) {
throw new NotFoundException(notFoundMessage);
}
return assetProfile;
}
private getAssetProfileDataUpdate({
countries,
holdings,
sectors
}: UpdateAssetProfileDataDto): Pick<
Prisma.SymbolProfileUpdateInput,
'countries' | 'holdings' | 'sectors'
> {
const data: Pick<
Prisma.SymbolProfileUpdateInput,
'countries' | 'holdings' | 'sectors'
> = {};
if (countries !== undefined) {
data.countries = countries as Prisma.JsonArray;
}
if (holdings !== undefined) {
data.holdings = holdings as Prisma.JsonArray;
}
if (sectors !== undefined) {
data.sectors = sectors as Prisma.JsonArray;
}
return data;
}
private async getAssetProfilesForCurrencies(): Promise<AssetProfilesResponse> {
const currencyPairs = this.exchangeRateDataService.getCurrencyPairs();
const [lastMarketPrices, marketDataItems] = await Promise.all([
this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: currencyPairs.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: currencyPairs.map(({ symbol }) => {
return symbol;
})
}
}
}),
this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
})
]);
const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
const assetProfilePromises: Promise<AssetProfileItem>[] = currencyPairs.map(
async ({ dataSource, symbol }) => {
let activitiesCount: EnhancedAssetProfile['activitiesCount'] = 0;
let currency: EnhancedAssetProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedAssetProfile['dateOfFirstActivity'];
if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.activitiesService.getStatisticsByCurrency(currency));
}
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
activitiesCount,
currency,
dataSource,
lastMarketPrice,
marketDataItemCount,
symbol,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countriesCount: 0,
date: dateOfFirstActivity,
id: undefined,
isActive: true,
name: symbol,
sectorsCount: 0,
watchedByCount: 0
};
}
);
const assetProfiles = await Promise.all(assetProfilePromises);
return { assetProfiles, count: assetProfiles.length };
}
private getExtendedPrismaClient() {
const symbolProfileExtension = Prisma.defineExtension((client) => {
return client.$extends({
result: {
symbolProfile: {
isUsedByUsersWithSubscription: {
compute: async ({ id }) => {
const { _count } =
await this.prismaService.symbolProfile.findUnique({
select: {
_count: {
select: {
activities: {
where: {
user: {
subscriptions: {
some: {
expiresAt: {
gt: new Date()
}
}
}
}
}
}
}
}
},
where: {
id
}
});
return _count.activities > 0;
}
}
}
}
});
});
return this.prismaService.$extends(symbolProfileExtension);
}
}

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

@ -12,7 +12,7 @@ import type {
BenchmarkResponse BenchmarkResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -34,6 +34,7 @@ import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { BenchmarksService } from './benchmarks.service'; import { BenchmarksService } from './benchmarks.service';
import { GetBenchmarkMarketDataDto } from './get-benchmark-market-data.dto';
@Controller('benchmarks') @Controller('benchmarks')
export class BenchmarksController { export class BenchmarksController {
@ -118,38 +119,39 @@ export class BenchmarksController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string, @Param('startDateString') startDateString: string,
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Query('range') dateRange: DateRange = 'max', @Query()
@Query('accounts') filterByAccounts?: string, {
@Query('assetClasses') filterByAssetClasses?: string, accounts,
@Query('dataSource') filterByDataSource?: string, assetClasses,
@Query('symbol') filterBySymbol?: string, dataSource: filterByDataSource,
@Query('tags') filterByTags?: string, range,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false' symbol: filterBySymbol,
tags,
withExcludedAccounts
}: GetBenchmarkMarketDataDto
): Promise<BenchmarkMarketDataDetailsResponse> { ): Promise<BenchmarkMarketDataDetailsResponse> {
const { endDate, startDate } = getIntervalFromDateRange({ const { endDate, startDate } = getIntervalFromDateRange({
dateRange, dateRange: range,
startDate: new Date(startDateString) startDate: new Date(startDateString)
}); });
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource, filterByDataSource,
filterBySymbol, filterBySymbol,
filterByTags filterByAccounts: accounts,
filterByAssetClasses: assetClasses,
filterByTags: tags
}); });
const withExcludedAccounts = withExcludedAccountsParam === 'true';
return this.benchmarksService.getMarketDataForUser({ return this.benchmarksService.getMarketDataForUser({
dataSource, dataSource,
dateRange,
endDate, endDate,
filters, filters,
impersonationId, impersonationId,
startDate, startDate,
symbol, symbol,
withExcludedAccounts, withExcludedAccounts,
dateRange: range,
user: this.request.user user: this.request.user
}); });
} }

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

@ -23,6 +23,7 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -46,6 +47,7 @@ import { BenchmarksService } from './benchmarks.service';
RedisCacheModule, RedisCacheModule,
SymbolModule, SymbolModule,
SymbolProfileModule, SymbolProfileModule,
TagModule,
TransformDataSourceInRequestModule, TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule, TransformDataSourceInResponseModule,
UserModule UserModule

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

@ -17,6 +17,8 @@ import { isNumber } from 'lodash';
@Injectable() @Injectable()
export class BenchmarksService { export class BenchmarksService {
private readonly logger = new Logger(BenchmarksService.name);
public constructor( public constructor(
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
@ -79,6 +81,14 @@ export class BenchmarksService {
}) })
]); ]);
if (!currentSymbolItem) {
this.logger.error(
`No current market price is available for ${symbol} (${dataSource})`
);
return { marketData };
}
const exchangeRates = const exchangeRates =
await this.exchangeRateDataService.getExchangeRatesByCurrency({ await this.exchangeRateDataService.getExchangeRatesByCurrency({
startDate, startDate,
@ -96,12 +106,11 @@ export class BenchmarksService {
})?.marketPrice; })?.marketPrice;
if (!marketPriceAtStartDate) { if (!marketPriceAtStartDate) {
Logger.error( this.logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format( `No historical market data has been found for ${symbol} (${dataSource}) at ${format(
startDate, startDate,
DATE_FORMAT DATE_FORMAT
)}`, )}`
'BenchmarkService'
); );
return { marketData }; return { marketData };

12
apps/api/src/app/endpoints/benchmarks/get-benchmark-market-data.dto.ts

@ -0,0 +1,12 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsBoolean } from 'class-validator';
export class GetBenchmarkMarketDataDto extends DateRangeFilterDto {
@IsBoolean()
@Transform(({ value }: TransformFnParams) => {
return value === 'true';
})
withExcludedAccounts? = false;
}

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

@ -8,6 +8,7 @@ import {
DividendsResponse, DividendsResponse,
HistoricalResponse, HistoricalResponse,
LookupResponse, LookupResponse,
MarketDataOfMarketsResponse,
QuotesResponse QuotesResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -19,6 +20,7 @@ import {
HttpException, HttpException,
Inject, Inject,
Param, Param,
ParseIntPipe,
Query, Query,
UseGuards, UseGuards,
Version Version
@ -49,7 +51,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -88,12 +90,12 @@ export class GhostfolioController {
@Version('2') @Version('2')
public async getDividends( public async getDividends(
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Query() query: GetDividendsDto @Query() { from, granularity, to }: GetDividendsDto
): Promise<DividendsResponse> { ): Promise<DividendsResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -103,10 +105,10 @@ export class GhostfolioController {
try { try {
const dividends = await this.ghostfolioService.getDividends({ const dividends = await this.ghostfolioService.getDividends({
granularity,
symbol, symbol,
from: parseDate(query.from), from: parseDate(from),
granularity: query.granularity, to: parseDate(to)
to: parseDate(query.to)
}); });
await this.ghostfolioService.incrementDailyRequests({ await this.ghostfolioService.incrementDailyRequests({
@ -128,12 +130,12 @@ export class GhostfolioController {
@Version('2') @Version('2')
public async getHistorical( public async getHistorical(
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Query() query: GetHistoricalDto @Query() { from, granularity, to }: GetHistoricalDto
): Promise<HistoricalResponse> { ): Promise<HistoricalResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -143,10 +145,10 @@ export class GhostfolioController {
try { try {
const historicalData = await this.ghostfolioService.getHistorical({ const historicalData = await this.ghostfolioService.getHistorical({
granularity,
symbol, symbol,
from: parseDate(query.from), from: parseDate(from),
granularity: query.granularity, to: parseDate(to)
to: parseDate(query.to)
}); });
await this.ghostfolioService.incrementDailyRequests({ await this.ghostfolioService.incrementDailyRequests({
@ -174,7 +176,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -203,17 +205,54 @@ export class GhostfolioController {
} }
} }
@Get('markets')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
public async getMarketDataOfMarkets(
@Query('includeHistoricalData', new ParseIntPipe({ optional: true }))
includeHistoricalData = 0
): Promise<MarketDataOfMarketsResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const marketDataOfMarkets =
await this.ghostfolioService.getMarketDataOfMarkets({
includeHistoricalData
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return marketDataOfMarkets;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('quotes') @Get('quotes')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2') @Version('2')
public async getQuotes( public async getQuotes(
@Query() query: GetQuotesDto @Query() { symbols }: GetQuotesDto
): Promise<QuotesResponse> { ): Promise<QuotesResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -223,7 +262,7 @@ export class GhostfolioController {
try { try {
const quotes = await this.ghostfolioService.getQuotes({ const quotes = await this.ghostfolioService.getQuotes({
symbols: query.symbols symbols
}); });
await this.ghostfolioService.incrementDailyRequests({ await this.ghostfolioService.incrementDailyRequests({

4
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts

@ -1,4 +1,5 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
@ -12,6 +13,7 @@ import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/goog
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service'; import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
@ -27,10 +29,12 @@ import { GhostfolioService } from './ghostfolio.service';
imports: [ imports: [
CryptocurrencyModule, CryptocurrencyModule,
DataProviderModule, DataProviderModule,
FetchModule,
MarketDataModule, MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolModule,
SymbolProfileModule SymbolProfileModule
], ],
providers: [ providers: [

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

@ -1,3 +1,4 @@
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service'; import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service';
@ -8,6 +9,7 @@ import {
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
@ -15,6 +17,10 @@ import {
DERIVED_CURRENCIES DERIVED_CURRENCIES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config'; import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import {
getAssetProfileIdentifier,
isValidSearchQuery
} from '@ghostfolio/common/helper';
import { import {
DataProviderGhostfolioAssetProfileResponse, DataProviderGhostfolioAssetProfileResponse,
DataProviderHistoricalResponse, DataProviderHistoricalResponse,
@ -23,6 +29,7 @@ import {
HistoricalResponse, HistoricalResponse,
LookupItem, LookupItem,
LookupResponse, LookupResponse,
MarketDataOfMarketsResponse,
QuotesResponse QuotesResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
@ -30,14 +37,19 @@ import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isEmpty } from 'lodash';
@Injectable() @Injectable()
export class GhostfolioService { export class GhostfolioService {
private readonly logger = new Logger(GhostfolioService.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly fetchService: FetchService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService private readonly propertyService: PropertyService,
private readonly symbolService: SymbolService
) {} ) {}
public async getAssetProfile({ symbol }: GetAssetProfileParams) { public async getAssetProfile({ symbol }: GetAssetProfileParams) {
@ -56,7 +68,13 @@ export class GhostfolioService {
} }
]) ])
.then(async (assetProfiles) => { .then(async (assetProfiles) => {
const assetProfile = assetProfiles[symbol]; const assetProfile =
assetProfiles[
getAssetProfileIdentifier({
symbol,
dataSource: dataProviderService.getName()
})
];
const dataSourceOrigin = DataSource.GHOSTFOLIO; const dataSourceOrigin = DataSource.GHOSTFOLIO;
if (assetProfile) { if (assetProfile) {
@ -97,7 +115,7 @@ export class GhostfolioService {
return result; return result;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error; throw error;
} }
@ -139,7 +157,7 @@ export class GhostfolioService {
return result; return result;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error; throw error;
} }
@ -156,7 +174,7 @@ export class GhostfolioService {
try { try {
const promises: Promise<{ const promises: Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}>[] = []; }>[] = [];
for (const dataProviderService of this.getDataProviderServices()) { for (const dataProviderService of this.getDataProviderServices()) {
@ -170,7 +188,7 @@ export class GhostfolioService {
to to
}) })
.then((historicalData) => { .then((historicalData) => {
result.historicalData = historicalData[symbol]; result.historicalData = historicalData;
return historicalData; return historicalData;
}) })
@ -181,7 +199,34 @@ export class GhostfolioService {
return result; return result;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error;
}
}
public async getMarketDataOfMarkets({
includeHistoricalData
}: {
includeHistoricalData: number;
}): Promise<MarketDataOfMarketsResponse> {
try {
const marketDataOfMarkets =
await this.symbolService.getMarketDataOfMarkets({
includeHistoricalData
});
for (const symbolItem of Object.values(
marketDataOfMarkets.fearAndGreedIndex
)) {
if (!isEmpty(symbolItem)) {
symbolItem.dataSource = DataSource.GHOSTFOLIO;
}
}
return marketDataOfMarkets;
} catch (error) {
this.logger.error(error);
throw error; throw error;
} }
@ -269,7 +314,7 @@ export class GhostfolioService {
return results; return results;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error; throw error;
} }
@ -284,8 +329,12 @@ export class GhostfolioService {
} }
public async incrementDailyRequests({ userId }: { userId: string }) { public async incrementDailyRequests({ userId }: { userId: string }) {
await this.prismaService.analytics.update({ await this.prismaService.analytics.upsert({
data: { create: {
dataProviderGhostfolioDailyRequests: 1,
user: { connect: { id: userId } }
},
update: {
dataProviderGhostfolioDailyRequests: { increment: 1 } dataProviderGhostfolioDailyRequests: { increment: 1 }
}, },
where: { userId } where: { userId }
@ -298,7 +347,9 @@ export class GhostfolioService {
}: GetSearchParams): Promise<LookupResponse> { }: GetSearchParams): Promise<LookupResponse> {
const results: LookupResponse = { items: [] }; const results: LookupResponse = { items: [] };
if (!query) { query = query?.trim();
if (!isValidSearchQuery(query)) {
return results; return results;
} }
@ -306,10 +357,6 @@ export class GhostfolioService {
let lookupItems: LookupItem[] = []; let lookupItems: LookupItem[] = [];
const promises: Promise<{ items: LookupItem[] }>[] = []; const promises: Promise<{ items: LookupItem[] }>[] = [];
if (query?.length < 2) {
return { items: lookupItems };
}
for (const dataProviderService of this.getDataProviderServices()) { for (const dataProviderService of this.getDataProviderServices()) {
promises.push( promises.push(
dataProviderService.search({ dataProviderService.search({
@ -346,7 +393,7 @@ export class GhostfolioService {
return results; return results;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error; throw error;
} }
@ -355,6 +402,7 @@ export class GhostfolioService {
private getDataProviderInfo(): DataProviderInfo { private getDataProviderInfo(): DataProviderInfo {
const ghostfolioDataProviderService = new GhostfolioDataProviderService( const ghostfolioDataProviderService = new GhostfolioDataProviderService(
this.configurationService, this.configurationService,
this.fetchService,
this.propertyService this.propertyService
); );

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

@ -1,23 +1,11 @@
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
ghostfolioFearAndGreedIndexDataSourceStocks,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks
} from '@ghostfolio/common/config';
import { UpdateBulkMarketDataDto } from '@ghostfolio/common/dtos'; import { UpdateBulkMarketDataDto } from '@ghostfolio/common/dtos';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { import { MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces';
MarketDataDetailsResponse,
MarketDataOfMarketsResponse
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
@ -28,10 +16,10 @@ import {
HttpException, HttpException,
Inject, Inject,
Param, Param,
ParseIntPipe,
Post, Post,
Query, Query,
UseGuards, UseGuards
UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -42,7 +30,6 @@ import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@Controller('market-data') @Controller('market-data')
export class MarketDataController { export class MarketDataController {
public constructor( public constructor(
private readonly adminService: AdminService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService,
@ -53,81 +40,12 @@ export class MarketDataController {
@HasPermission(permissions.readMarketDataOfMarkets) @HasPermission(permissions.readMarketDataOfMarkets)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketDataOfMarkets( public async getMarketDataOfMarkets(
@Query('includeHistoricalData') includeHistoricalData = 0 @Query('includeHistoricalData', new ParseIntPipe({ optional: true }))
includeHistoricalData = 0
): Promise<MarketDataOfMarketsResponse> { ): Promise<MarketDataOfMarketsResponse> {
const [ return this.symbolService.getMarketDataOfMarkets({
marketDataFearAndGreedIndexCryptocurrencies, includeHistoricalData
marketDataFearAndGreedIndexStocks });
] = await Promise.all([
this.symbolService.get({
includeHistoricalData,
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies
}
}),
this.symbolService.get({
includeHistoricalData,
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceStocks,
symbol: ghostfolioFearAndGreedIndexSymbolStocks
}
})
]);
return {
fearAndGreedIndex: {
CRYPTOCURRENCIES: {
...marketDataFearAndGreedIndexCryptocurrencies
},
STOCKS: {
...marketDataFearAndGreedIndexStocks
}
}
};
}
@Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<MarketDataDetailsResponse> {
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource, symbol }
]);
if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const canReadAllAssetProfiles = hasPermission(
this.request.user.permissions,
permissions.readMarketData
);
const canReadOwnAssetProfile =
assetProfile?.userId === this.request.user.id &&
hasPermission(
this.request.user.permissions,
permissions.readMarketDataOfOwnAssetProfile
);
if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) {
throw new HttpException(
assetProfile.userId
? getReasonPhrase(StatusCodes.NOT_FOUND)
: getReasonPhrase(StatusCodes.FORBIDDEN),
assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN
);
}
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
} }
@Post(':dataSource/:symbol') @Post(':dataSource/:symbol')

12
apps/api/src/app/endpoints/market-data/market-data.module.ts

@ -1,7 +1,4 @@
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -11,13 +8,6 @@ import { MarketDataController } from './market-data.controller';
@Module({ @Module({
controllers: [MarketDataController], controllers: [MarketDataController],
imports: [ imports: [MarketDataServiceModule, SymbolModule, SymbolProfileModule]
AdminModule,
MarketDataServiceModule,
SymbolModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
]
}) })
export class MarketDataModule {} export class MarketDataModule {}

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

@ -9,19 +9,23 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { getSum } from '@ghostfolio/common/helper'; import { getSum } from '@ghostfolio/common/helper';
import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces'; import {
import type { RequestWithUser } from '@ghostfolio/common/types'; AccessSettings,
PublicPortfolioResponse
} from '@ghostfolio/common/interfaces';
import { import {
Controller, Controller,
Get, Get,
HttpException, HttpException,
Inject,
Param, Param,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import {
import { Type as ActivityType } from '@prisma/client'; AssetClass,
AssetSubClass,
Type as ActivityType
} from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -33,7 +37,6 @@ export class PublicController {
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -43,7 +46,10 @@ export class PublicController {
public async getPublicPortfolio( public async getPublicPortfolio(
@Param('accessId') accessId: string @Param('accessId') accessId: string
): Promise<PublicPortfolioResponse> { ): Promise<PublicPortfolioResponse> {
const access = await this.accessService.access({ id: accessId }); const access = await this.accessService.access({
granteeUserId: null,
id: accessId
});
if (!access) { if (!access) {
throw new HttpException( throw new HttpException(
@ -59,9 +65,11 @@ export class PublicController {
}); });
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = user.subscription.type === SubscriptionType.Premium; hasDetails = user?.subscription?.type === SubscriptionType.Premium;
} }
const { filters } = (access.settings ?? {}) as AccessSettings;
const [ const [
{ createdAt, holdings, markets }, { createdAt, holdings, markets },
{ performance: performance1d }, { performance: performance1d },
@ -69,6 +77,7 @@ export class PublicController {
{ performance: performanceYtd } { performance: performanceYtd }
] = await Promise.all([ ] = await Promise.all([
this.portfolioService.getDetails({ this.portfolioService.getDetails({
filters,
impersonationId: access.userId, impersonationId: access.userId,
userId: user.id, userId: user.id,
withMarkets: true withMarkets: true
@ -76,6 +85,7 @@ export class PublicController {
...['1d', 'max', 'ytd'].map((dateRange) => { ...['1d', 'max', 'ytd'].map((dateRange) => {
return this.portfolioService.getPerformance({ return this.portfolioService.getPerformance({
dateRange, dateRange,
filters,
impersonationId: undefined, impersonationId: undefined,
userId: user.id userId: user.id
}); });
@ -83,11 +93,12 @@ export class PublicController {
]); ]);
const { activities } = await this.activitiesService.getActivities({ const { activities } = await this.activitiesService.getActivities({
filters,
sortColumn: 'date', sortColumn: 'date',
sortDirection: 'desc', sortDirection: 'desc',
take: 10, take: 10,
types: [ActivityType.BUY, ActivityType.SELL], types: [ActivityType.BUY, ActivityType.SELL],
userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY, userCurrency: user?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY,
userId: user.id, userId: user.id,
withExcludedAccountsAndActivities: false withExcludedAccountsAndActivities: false
}); });
@ -99,22 +110,22 @@ export class PublicController {
? [] ? []
: activities.map( : activities.map(
({ ({
assetProfile,
currency, currency,
date, date,
fee, fee,
quantity, quantity,
SymbolProfile,
type, type,
unitPrice, unitPrice,
value, value,
valueInBaseCurrency valueInBaseCurrency
}) => { }) => {
return { return {
assetProfile,
currency, currency,
date, date,
fee, fee,
quantity, quantity,
SymbolProfile,
type, type,
unitPrice, unitPrice,
value, value,
@ -156,8 +167,7 @@ export class PublicController {
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
quantity * marketPrice, quantity * marketPrice,
assetProfile.currency, assetProfile.currency,
this.request.user?.settings?.settings.baseCurrency ?? user?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY
DEFAULT_CURRENCY
) )
); );
}) })
@ -167,19 +177,46 @@ export class PublicController {
publicPortfolioResponse.holdings[symbol] = { publicPortfolioResponse.holdings[symbol] = {
allocationInPercentage: allocationInPercentage:
portfolioPosition.valueInBaseCurrency / totalValue, portfolioPosition.valueInBaseCurrency / totalValue,
assetClass: hasDetails ? portfolioPosition.assetClass : undefined, assetProfile: {
assetProfile: hasDetails ? portfolioPosition.assetProfile : undefined, ...portfolioPosition.assetProfile,
countries: hasDetails ? portfolioPosition.countries : [], assetClass:
currency: hasDetails ? portfolioPosition.currency : undefined, hasDetails ||
dataSource: portfolioPosition.dataSource, portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetProfile.assetClass
: undefined,
assetClassLabel:
hasDetails ||
portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetProfile.assetClassLabel
: undefined,
assetSubClass:
hasDetails ||
portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetProfile.assetSubClass
: undefined,
assetSubClassLabel:
hasDetails ||
portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetProfile.assetSubClassLabel
: undefined,
holdings: portfolioPosition.assetProfile.holdings?.map(
({ allocationInPercentage, name }) => {
return { allocationInPercentage, name };
}
),
...(hasDetails
? {}
: {
countries: [],
currency: undefined,
holdings: [],
sectors: []
})
},
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
markets: hasDetails ? portfolioPosition.markets : undefined, markets: hasDetails ? portfolioPosition.markets : undefined,
name: portfolioPosition.name,
netPerformancePercentWithCurrencyEffect: netPerformancePercentWithCurrencyEffect:
portfolioPosition.netPerformancePercentWithCurrencyEffect, portfolioPosition.netPerformancePercentWithCurrencyEffect,
sectors: hasDetails ? portfolioPosition.sectors : [],
symbol: portfolioPosition.symbol,
url: portfolioPosition.url,
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue
}; };
} }

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

@ -18,6 +18,7 @@ import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-da
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -38,6 +39,7 @@ import { PublicController } from './public.controller';
PrismaModule, PrismaModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule, SymbolProfileModule,
TagModule,
TransformDataSourceInRequestModule, TransformDataSourceInRequestModule,
UserModule UserModule
], ],

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

@ -2,6 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos'; import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { isSystemTag } from '@ghostfolio/common/helper';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
@ -69,7 +70,7 @@ export class TagsController {
id id
}); });
if (!originalTag) { if (!originalTag || isSystemTag(originalTag)) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -83,7 +84,7 @@ export class TagsController {
@HasPermission(permissions.readTags) @HasPermission(permissions.readTags)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() { public async getTags() {
return this.tagService.getTagsWithActivityCount(); return this.tagService.getTagsWithAccountAndActivityCount();
} }
@HasPermission(permissions.updateTag) @HasPermission(permissions.updateTag)
@ -94,7 +95,7 @@ export class TagsController {
id id
}); });
if (!originalTag) { if (!originalTag || isSystemTag(originalTag)) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN

30
apps/api/src/app/endpoints/watchlist/watchlist.service.ts

@ -4,10 +4,14 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { WatchlistResponse } from '@ghostfolio/common/interfaces'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
WatchlistResponse
} from '@ghostfolio/common/interfaces';
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { DataSource, Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
@Injectable() @Injectable()
export class WatchlistService { export class WatchlistService {
@ -24,11 +28,7 @@ export class WatchlistService {
dataSource, dataSource,
symbol, symbol,
userId userId
}: { }: { userId: string } & AssetProfileIdentifier): Promise<void> {
dataSource: DataSource;
symbol: string;
userId: string;
}): Promise<void> {
const symbolProfile = await this.prismaService.symbolProfile.findUnique({ const symbolProfile = await this.prismaService.symbolProfile.findUnique({
where: { where: {
dataSource_symbol: { dataSource, symbol } dataSource_symbol: { dataSource, symbol }
@ -40,14 +40,17 @@ export class WatchlistService {
{ dataSource, symbol } { dataSource, symbol }
]); ]);
if (!assetProfiles[symbol]?.currency) { const assetProfile =
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })];
if (!assetProfile?.currency) {
throw new BadRequestException( throw new BadRequestException(
`Asset profile not found for ${symbol} (${dataSource})` `Asset profile not found for ${symbol} (${dataSource})`
); );
} }
await this.symbolProfileService.add( await this.symbolProfileService.add(
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput assetProfile as Prisma.SymbolProfileCreateInput
); );
} }
@ -72,11 +75,7 @@ export class WatchlistService {
dataSource, dataSource,
symbol, symbol,
userId userId
}: { }: { userId: string } & AssetProfileIdentifier) {
dataSource: DataSource;
symbol: string;
userId: string;
}) {
await this.prismaService.user.update({ await this.prismaService.user.update({
data: { data: {
watchlist: { watchlist: {
@ -127,7 +126,8 @@ export class WatchlistService {
const performancePercent = const performancePercent =
this.benchmarkService.calculateChangeInPercentage( this.benchmarkService.calculateChangeInPercentage(
allTimeHigh?.marketPrice, allTimeHigh?.marketPrice,
quotes[symbol]?.marketPrice quotes[getAssetProfileIdentifier({ dataSource, symbol })]
?.marketPrice
); );
return { return {

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

@ -2,6 +2,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { ExportResponse } from '@ghostfolio/common/interfaces'; import { ExportResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -15,9 +16,9 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Type as ActivityType } from '@prisma/client';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
import { GetExportDto } from './get-export.dto';
@Controller('export') @Controller('export')
export class ExportController { export class ExportController {
@ -32,29 +33,41 @@ export class ExportController {
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async export( public async export(
@Query('accounts') filterByAccounts?: string, @Query()
@Query('activityIds') filterByActivityIds?: string, {
@Query('activityTypes') filterByTypes?: string, accounts,
@Query('assetClasses') filterByAssetClasses?: string, activityIds,
@Query('dataSource') filterByDataSource?: string, activityTypes,
@Query('symbol') filterBySymbol?: string, assetClasses,
@Query('tags') filterByTags?: string dataSource,
range,
symbol,
tags
}: GetExportDto
): Promise<ExportResponse> { ): Promise<ExportResponse> {
const activityIds = filterByActivityIds?.split(',') ?? []; let endDate: Date;
const activityTypes = (filterByTypes?.split(',') as ActivityType[]) ?? []; let startDate: Date;
if (range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
return this.exportService.export({ return this.exportService.export({
activityIds, activityIds,
activityTypes, activityTypes,
endDate,
filters, filters,
startDate,
userId: this.request.user.id, userId: this.request.user.id,
userSettings: this.request.user.settings.settings userSettings: this.request.user.settings.settings
}); });

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

@ -25,23 +25,29 @@ export class ExportService {
public async export({ public async export({
activityIds, activityIds,
activityTypes, activityTypes,
endDate,
filters, filters,
startDate,
userId, userId,
userSettings userSettings
}: { }: {
activityIds?: string[]; activityIds?: string[];
activityTypes?: ActivityType[]; activityTypes?: ActivityType[];
endDate?: Date;
filters?: Filter[]; filters?: Filter[];
startDate?: Date;
userId: string; userId: string;
userSettings: UserSettings; userSettings: UserSettings;
}): Promise<ExportResponse> { }): Promise<ExportResponse> {
const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => { const { ACCOUNT: filtersByAccount = [] } = groupBy(filters, ({ type }) => {
return type; return type;
}); });
const platformsMap: { [platformId: string]: Platform } = {}; const platformsMap: { [platformId: string]: Platform } = {};
let { activities } = await this.activitiesService.getActivities({ let { activities } = await this.activitiesService.getActivities({
endDate,
filters, filters,
startDate,
userId, userId,
includeDrafts: true, includeDrafts: true,
sortColumn: 'date', sortColumn: 'date',
@ -59,7 +65,7 @@ export class ExportService {
const where: Prisma.AccountWhereInput = { userId }; const where: Prisma.AccountWhereInput = { userId };
if (filtersByAccount?.length > 0) { if (filtersByAccount.length > 0) {
where.id = { where.id = {
in: filtersByAccount.map(({ id }) => { in: filtersByAccount.map(({ id }) => {
return id; return id;
@ -67,12 +73,20 @@ export class ExportService {
}; };
} }
const isFilteredExport =
activityIds?.length > 0 ||
activityTypes?.length > 0 ||
filters?.length > 0 ||
!!endDate ||
!!startDate;
const accounts = ( const accounts = (
await this.accountService.accounts({ await this.accountService.accounts({
where, where,
include: { include: {
balances: true, balances: true,
platform: true platform: true,
tags: true
}, },
orderBy: { orderBy: {
name: 'asc' name: 'asc'
@ -80,7 +94,7 @@ export class ExportService {
}) })
) )
.filter(({ id }) => { .filter(({ id }) => {
return activityIds?.length > 0 return isFilteredExport
? activities.some(({ accountId }) => { ? activities.some(({ accountId }) => {
return accountId === id; return accountId === id;
}) })
@ -88,39 +102,39 @@ export class ExportService {
}) })
.map( .map(
({ ({
balance,
balances, balances,
comment, comment,
currency, currency,
id, id,
isExcluded,
name, name,
platform, platform,
platformId platformId,
}) => { tags
}): ExportResponse['accounts'][number] => {
if (platformId) { if (platformId) {
platformsMap[platformId] = platform; platformsMap[platformId] = platform;
} }
return { return {
balance,
balances: balances.map(({ date, value }) => { balances: balances.map(({ date, value }) => {
return { date: date.toISOString(), value }; return { date: date.toISOString(), value };
}), }),
comment, comment,
currency, currency,
id, id,
isExcluded,
name, name,
platformId platformId,
tags: tags.map(({ id: tagId }) => {
return tagId;
})
}; };
} }
); );
const customAssetProfiles = uniqBy( const customAssetProfiles = uniqBy(
activities activities
.map(({ SymbolProfile }) => { .map(({ assetProfile }) => {
return SymbolProfile; return assetProfile;
}) })
.filter(({ userId: assetProfileUserId }) => { .filter(({ userId: assetProfileUserId }) => {
return assetProfileUserId === userId; return assetProfileUserId === userId;
@ -151,11 +165,14 @@ export class ExportService {
.filter(({ id, isUsed }) => { .filter(({ id, isUsed }) => {
return ( return (
isUsed && isUsed &&
activities.some((activity) => { (accounts.some(({ tags: tagIds }) => {
return activity.tags.some(({ id: tagId }) => { return tagIds.includes(id);
return tagId === id; }) ||
}); activities.some((activity) => {
}) return activity.tags.some(({ id: tagId }) => {
return tagId === id;
});
}))
); );
}) })
.map(({ id, name }) => { .map(({ id, name }) => {
@ -216,13 +233,13 @@ export class ExportService {
activities: activities.map( activities: activities.map(
({ ({
accountId, accountId,
assetProfile,
comment, comment,
currency, currency,
date, date,
fee, fee,
id, id,
quantity, quantity,
SymbolProfile,
tags: currentTags, tags: currentTags,
type, type,
unitPrice unitPrice
@ -235,10 +252,10 @@ export class ExportService {
quantity, quantity,
type, type,
unitPrice, unitPrice,
currency: currency ?? SymbolProfile.currency, currency: currency ?? assetProfile.currency,
dataSource: SymbolProfile.dataSource, dataSource: assetProfile.dataSource,
date: date.toISOString(), date: date.toISOString(),
symbol: SymbolProfile.symbol, symbol: assetProfile.symbol,
tags: currentTags.map(({ id: tagId }) => { tags: currentTags.map(({ id: tagId }) => {
return tagId; return tagId;
}) })

14
apps/api/src/app/export/get-export.dto.ts

@ -0,0 +1,14 @@
import { ActivitiesFilterDto } from '@ghostfolio/api/app/activities/activities-filter.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsOptional, IsUUID } from 'class-validator';
import { isString } from 'lodash';
export class GetExportDto extends ActivitiesFilterDto {
@IsOptional()
@IsUUID(undefined, { each: true })
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
activityIds?: string[];
}

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

@ -24,6 +24,8 @@ import { HealthService } from './health.service';
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {
private readonly logger = new Logger(HealthController.name);
public constructor( public constructor(
private readonly aiService: AiService, private readonly aiService: AiService,
private readonly healthService: HealthService private readonly healthService: HealthService
@ -61,7 +63,7 @@ export class HealthController {
.json({ status: getReasonPhrase(StatusCodes.OK) }); .json({ status: getReasonPhrase(StatusCodes.OK) });
} }
} catch (error) { } catch (error) {
Logger.error(error, 'HealthController'); this.logger.error(error);
} }
return response return response

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

@ -26,7 +26,9 @@ export class HealthService {
public async isDatabaseHealthy() { public async isDatabaseHealthy() {
try { try {
await this.propertyService.getByKey(PROPERTY_CURRENCIES); await this.propertyService.getByKey(PROPERTY_CURRENCIES, {
skipCache: true
});
return true; return true;
} catch { } catch {

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

@ -2,6 +2,7 @@ import {
CreateAccountWithBalancesDto, CreateAccountWithBalancesDto,
CreateAssetProfileWithMarketDataDto, CreateAssetProfileWithMarketDataDto,
CreateOrderDto, CreateOrderDto,
CreatePlatformDto,
CreateTagDto CreateTagDto
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
@ -26,6 +27,12 @@ export class ImportDataDto {
@ValidateNested({ each: true }) @ValidateNested({ each: true })
assetProfiles?: CreateAssetProfileWithMarketDataDto[]; assetProfiles?: CreateAssetProfileWithMarketDataDto[];
@IsArray()
@IsOptional()
@Type(() => CreatePlatformDto)
@ValidateNested({ each: true })
platforms?: CreatePlatformDto[];
@IsArray() @IsArray()
@IsOptional() @IsOptional()
@Type(() => CreateTagDto) @Type(() => CreateTagDto)

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

@ -31,6 +31,8 @@ import { ImportService } from './import.service';
@Controller('import') @Controller('import')
export class ImportController { export class ImportController {
private readonly logger = new Logger(ImportController.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly importService: ImportService, private readonly importService: ImportService,
@ -63,7 +65,7 @@ export class ImportController {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Premium this.request.user.subscription?.type === SubscriptionType.Premium
) { ) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER; maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
} }
@ -75,13 +77,14 @@ export class ImportController {
accountsWithBalancesDto: importData.accounts ?? [], accountsWithBalancesDto: importData.accounts ?? [],
activitiesDto: importData.activities, activitiesDto: importData.activities,
assetProfilesWithMarketDataDto: importData.assetProfiles ?? [], assetProfilesWithMarketDataDto: importData.assetProfiles ?? [],
platformsDto: importData.platforms ?? [],
tagsDto: importData.tags ?? [], tagsDto: importData.tags ?? [],
user: this.request.user user: this.request.user
}); });
return { activities }; return { activities };
} catch (error) { } catch (error) {
Logger.error(error, ImportController); this.logger.error(error);
throw new HttpException( throw new HttpException(
{ {
@ -107,7 +110,7 @@ export class ImportController {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Premium this.request.user.subscription?.type === SubscriptionType.Premium
) { ) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER; maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
} }

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

@ -9,14 +9,16 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import { import {
CreateAssetProfileDto, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
CreateAccountDto, ghostfolioPrefix,
CreateOrderDto NON_INVESTMENT_ACTIVITY_TYPES,
} from '@ghostfolio/common/dtos'; TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos';
import { import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
isValidCustomAssetProfileSymbol,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -69,8 +71,7 @@ export class ImportService {
const holding = await this.portfolioService.getHolding({ const holding = await this.portfolioService.getHolding({
dataSource, dataSource,
symbol, symbol,
userId, userId
impersonationId: undefined
}); });
if (!holding) { if (!holding) {
@ -127,11 +128,11 @@ export class ImportService {
const isDuplicate = activities.some((activity) => { const isDuplicate = activities.some((activity) => {
return ( return (
activity.accountId === account?.id && activity.accountId === account?.id &&
activity.SymbolProfile.currency === assetProfile.currency && activity.assetProfile.currency === assetProfile.currency &&
activity.SymbolProfile.dataSource === assetProfile.dataSource && activity.assetProfile.dataSource === assetProfile.dataSource &&
isSameSecond(activity.date, date) && isSameSecond(activity.date, date) &&
activity.quantity === quantity && activity.quantity === quantity &&
activity.SymbolProfile.symbol === assetProfile.symbol && activity.assetProfile.symbol === assetProfile.symbol &&
activity.type === 'DIVIDEND' && activity.type === 'DIVIDEND' &&
activity.unitPrice === marketPrice activity.unitPrice === marketPrice
); );
@ -143,6 +144,7 @@ export class ImportService {
return { return {
account, account,
assetProfile,
date, date,
error, error,
quantity, quantity,
@ -157,7 +159,6 @@ export class ImportService {
feeInBaseCurrency: 0, feeInBaseCurrency: 0,
id: assetProfile.id, id: assetProfile.id,
isDraft: false, isDraft: false,
SymbolProfile: assetProfile,
symbolProfileId: assetProfile.id, symbolProfileId: assetProfile.id,
type: 'DIVIDEND', type: 'DIVIDEND',
unitPrice: marketPrice, unitPrice: marketPrice,
@ -179,6 +180,7 @@ export class ImportService {
assetProfilesWithMarketDataDto, assetProfilesWithMarketDataDto,
isDryRun = false, isDryRun = false,
maxActivitiesToImport, maxActivitiesToImport,
platformsDto,
tagsDto, tagsDto,
user user
}: { }: {
@ -187,14 +189,143 @@ export class ImportService {
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles']; assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
isDryRun?: boolean; isDryRun?: boolean;
maxActivitiesToImport: number; maxActivitiesToImport: number;
platformsDto: ImportDataDto['platforms'];
tagsDto: ImportDataDto['tags']; tagsDto: ImportDataDto['tags'];
user: UserWithSettings; user: UserWithSettings;
}): Promise<Activity[]> { }): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {}; const accountIdMapping: { [oldAccountId: string]: string } = {};
const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {}; const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {};
const platformIdMapping: { [oldPlatformId: string]: string } = {};
const tagIdMapping: { [oldTagId: string]: string } = {}; const tagIdMapping: { [oldTagId: string]: string } = {};
const userCurrency = user.settings.settings.baseCurrency; const userCurrency = user.settings.settings.baseCurrency;
// Validate the symbols before any data is persisted
for (const [index, assetProfileWithMarketData] of (
assetProfilesWithMarketDataDto ?? []
).entries()) {
if (
assetProfileWithMarketData.dataSource === DataSource.MANUAL &&
!isValidCustomAssetProfileSymbol(assetProfileWithMarketData.symbol)
) {
throw new Error(
`assetProfiles.${index}.symbol ("${assetProfileWithMarketData.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")`
);
}
}
// Validate the symbols before any data is persisted. Activities without a
// data source are excluded, since a symbol is generated in
// createActivity() if needed.
for (const [index, activity] of activitiesDto.entries()) {
if (!activity.dataSource) {
if (NON_INVESTMENT_ACTIVITY_TYPES.includes(activity.type)) {
activity.dataSource = DataSource.MANUAL;
} else {
activity.dataSource =
this.dataProviderService.getDataSourceForImport();
}
} else if (
activity.dataSource === DataSource.MANUAL &&
!isValidCustomAssetProfileSymbol(activity.symbol)
) {
throw new Error(
`activities.${index}.symbol ("${activity.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")`
);
}
}
if (platformsDto?.length) {
const canCreatePlatform = hasPermission(
user.permissions,
permissions.createPlatform
);
const existingPlatforms = await this.platformService.getPlatforms();
for (const platform of platformsDto) {
// Check if there is any existing platform with the same ID, otherwise
// fall back to a platform with the same URL
const existingPlatform =
existingPlatforms.find(({ id }) => {
return id === platform.id;
}) ??
existingPlatforms.find(({ url }) => {
return url === platform.url;
});
if (existingPlatform) {
// Store the new to old platform ID mappings for creating accounts
if (platform.id && existingPlatform.id !== platform.id) {
platformIdMapping[platform.id] = existingPlatform.id;
}
} else {
if (!canCreatePlatform) {
throw new Error(
`Insufficient permissions to create platform ("${platform.name}")`
);
}
if (!isDryRun) {
await this.platformService.createPlatform(platform);
}
}
}
}
const existingTagsOfUser =
tagsDto?.length || (!isDryRun && accountsWithBalancesDto?.length)
? await this.tagService.getTagsForUser(user.id)
: [];
if (tagsDto?.length) {
const canCreateOwnTag = hasPermission(
user.permissions,
permissions.createOwnTag
);
for (const tag of tagsDto) {
const existingTagOfUser = existingTagsOfUser.find(({ id }) => {
return id === tag.id;
});
if (!existingTagOfUser) {
if (!canCreateOwnTag) {
throw new Error(
`Insufficient permissions to create custom tag ("${tag.name}")`
);
}
if (!isDryRun) {
const existingTag = await this.tagService.getTag({ id: tag.id });
let oldTagId: string;
if (existingTag) {
oldTagId = tag.id;
delete tag.id;
}
const tagObject: Prisma.TagCreateInput = {
...tag,
user: { connect: { id: user.id } }
};
const newTag = await this.tagService.createTag(tagObject);
if (existingTag && oldTagId) {
tagIdMapping[oldTagId] = newTag.id;
}
existingTagsOfUser.push({
id: newTag.id,
isUsed: false,
name: newTag.name,
userId: newTag.userId
});
}
}
}
}
if (!isDryRun && accountsWithBalancesDto?.length) { if (!isDryRun && accountsWithBalancesDto?.length) {
const [existingAccounts, existingPlatforms] = await Promise.all([ const [existingAccounts, existingPlatforms] = await Promise.all([
this.accountService.accounts({ this.accountService.accounts({
@ -209,6 +340,12 @@ export class ImportService {
this.platformService.getPlatforms() this.platformService.getPlatforms()
]); ]);
const existingTagIds = new Set(
existingTagsOfUser.map(({ id }) => {
return id;
})
);
for (const accountWithBalances of accountsWithBalancesDto) { for (const accountWithBalances of accountsWithBalancesDto) {
// Check if there is any existing account with the same ID // Check if there is any existing account with the same ID
const accountWithSameId = existingAccounts.find((existingAccount) => { const accountWithSameId = existingAccounts.find((existingAccount) => {
@ -217,13 +354,16 @@ export class ImportService {
// If there is no account or if the account belongs to a different user then create a new account // If there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== user.id) { if (!accountWithSameId || accountWithSameId.userId !== user.id) {
const account: CreateAccountDto = omit( const account = omit(accountWithBalances, [
accountWithBalances, 'balance',
'balances' 'balances',
); 'isExcluded',
'tags'
]);
let oldAccountId: string; let oldAccountId: string;
const platformId = account.platformId; const platformId =
platformIdMapping[account.platformId] ?? account.platformId;
delete account.platformId; delete account.platformId;
@ -232,6 +372,24 @@ export class ImportService {
delete account.id; delete account.id;
} }
const tagIds = (accountWithBalances.tags ?? [])
.map((tagId) => {
return tagIdMapping[tagId] ?? tagId;
})
.filter((tagId) => {
return existingTagIds.has(tagId);
});
// Map the legacy isExcluded attribute of old export files to
// the "Exclude from Analysis" tag
if (
accountWithBalances.isExcluded &&
existingTagIds.has(TAG_ID_EXCLUDE_FROM_ANALYSIS) &&
!tagIds.includes(TAG_ID_EXCLUDE_FROM_ANALYSIS)
) {
tagIds.push(TAG_ID_EXCLUDE_FROM_ANALYSIS);
}
let accountObject: Prisma.AccountCreateInput = { let accountObject: Prisma.AccountCreateInput = {
...account, ...account,
balances: { balances: {
@ -251,10 +409,12 @@ export class ImportService {
}; };
} }
const newAccount = await this.accountService.createAccount( const newAccount = await this.accountService.createAccount({
accountObject, tagIds,
user.id balance: accountWithBalances.balance,
); data: accountObject,
userId: user.id
});
// Store the new to old account ID mappings for updating activities // Store the new to old account ID mappings for updating activities
if (accountWithSameId && oldAccountId) { if (accountWithSameId && oldAccountId) {
@ -264,115 +424,117 @@ export class ImportService {
} }
} }
if (!isDryRun && assetProfilesWithMarketDataDto?.length) { if (assetProfilesWithMarketDataDto?.length) {
const existingAssetProfiles = const customAssetProfileNames = assetProfilesWithMarketDataDto
await this.symbolProfileService.getSymbolProfiles( .filter(({ dataSource, name }) => {
assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => { return dataSource === DataSource.MANUAL && Boolean(name);
return { dataSource, symbol }; })
.map(({ name }) => {
return name;
});
const [existingAssetProfiles, existingCustomAssetProfilesOfUser] =
await Promise.all([
this.symbolProfileService.getSymbolProfiles(
assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
),
this.symbolProfileService.getCustomSymbolProfilesByNames({
names: customAssetProfileNames,
userId: user.id
}) })
); ]);
for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) { for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) {
let symbol = assetProfileWithMarketData.symbol;
// Check if there is any existing asset profile // Check if there is any existing asset profile
const existingAssetProfile = existingAssetProfiles.find( const existingAssetProfile = existingAssetProfiles.find(
({ dataSource, symbol }) => { (assetProfile) => {
return ( return (
dataSource === assetProfileWithMarketData.dataSource && assetProfile.dataSource ===
symbol === assetProfileWithMarketData.symbol assetProfileWithMarketData.dataSource &&
assetProfile.symbol === assetProfileWithMarketData.symbol
); );
} }
); );
// If there is no asset profile or if the asset profile belongs to a different user, then create a new asset profile // If there is no asset profile or if the asset profile belongs to a
// different user, then reuse the custom asset profile of the user or
// create a new asset profile
if (!existingAssetProfile || existingAssetProfile.userId !== user.id) { if (!existingAssetProfile || existingAssetProfile.userId !== user.id) {
const assetProfile: CreateAssetProfileDto = omit( // Check if the user has a custom asset profile with the same name.
assetProfileWithMarketData, // Skip asset profiles with a legacy free-text symbol as they would
'marketData' // fail the symbol validation on a future import.
); const existingCustomAssetProfileOfUser =
assetProfileWithMarketData.dataSource === DataSource.MANUAL
? existingCustomAssetProfilesOfUser.find((customAssetProfile) => {
return (
customAssetProfile.name ===
assetProfileWithMarketData.name &&
isValidCustomAssetProfileSymbol(customAssetProfile.symbol)
);
})
: undefined;
if (existingCustomAssetProfileOfUser) {
// Reuse the custom asset profile of the user instead of creating a duplicate
symbol = existingCustomAssetProfileOfUser.symbol;
} else {
const assetProfile: CreateAssetProfileDto = omit(
assetProfileWithMarketData,
'marketData'
);
// Asset profile belongs to a different user, generate a new symbol
if (existingAssetProfile && !isDryRun) {
symbol = randomUUID();
}
// Asset profile belongs to a different user
if (existingAssetProfile) {
const symbol = randomUUID();
assetProfileSymbolMapping[assetProfile.symbol] = symbol;
assetProfile.symbol = symbol; assetProfile.symbol = symbol;
if (!isDryRun) {
// Create a new asset profile
const assetProfileObject: Prisma.SymbolProfileCreateInput = {
...assetProfile,
user: { connect: { id: user.id } }
};
await this.symbolProfileService.add(assetProfileObject);
}
} }
// Create a new asset profile if (symbol !== assetProfileWithMarketData.symbol) {
const assetProfileObject: Prisma.SymbolProfileCreateInput = { assetProfileSymbolMapping[assetProfileWithMarketData.symbol] =
...assetProfile, symbol;
user: { connect: { id: user.id } }
};
await this.symbolProfileService.add(assetProfileObject); // Keep the asset profile in sync with the activities to validate
assetProfileWithMarketData.symbol = symbol;
}
} }
// Insert or update market data if (!isDryRun) {
const marketDataObjects = assetProfileWithMarketData.marketData.map( // Insert or update market data
(marketData) => { const marketDataObjects = (
assetProfileWithMarketData.marketData ?? []
).map((marketData) => {
return { return {
...marketData, ...marketData,
dataSource: assetProfileWithMarketData.dataSource, symbol,
symbol: assetProfileWithMarketData.symbol dataSource: assetProfileWithMarketData.dataSource
} as Prisma.MarketDataUpdateInput; } as Prisma.MarketDataUpdateInput;
} });
);
await this.marketDataService.updateMany({ data: marketDataObjects });
}
}
if (tagsDto?.length) {
const existingTagsOfUser = await this.tagService.getTagsForUser(user.id);
const canCreateOwnTag = hasPermission(
user.permissions,
permissions.createOwnTag
);
for (const tag of tagsDto) {
const existingTagOfUser = existingTagsOfUser.find(({ id }) => {
return id === tag.id;
});
if (!existingTagOfUser || existingTagOfUser.userId !== null) {
if (!canCreateOwnTag) {
throw new Error(
`Insufficient permissions to create custom tag ("${tag.name}")`
);
}
if (!isDryRun) {
const existingTag = await this.tagService.getTag({ id: tag.id });
let oldTagId: string;
if (existingTag) {
oldTagId = tag.id;
delete tag.id;
}
const tagObject: Prisma.TagCreateInput = {
...tag,
user: { connect: { id: user.id } }
};
const newTag = await this.tagService.createTag(tagObject);
if (existingTag && oldTagId) { await this.marketDataService.updateMany({ data: marketDataObjects });
tagIdMapping[oldTagId] = newTag.id;
}
}
} }
} }
} }
for (const activity of activitiesDto) { for (const activity of activitiesDto) {
if (!activity.dataSource) { // If an asset profile is created or reused, then update the symbol in all activities
if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) { if (assetProfileSymbolMapping[activity.symbol]) {
activity.dataSource = DataSource.MANUAL; activity.symbol = assetProfileSymbolMapping[activity.symbol];
} else {
activity.dataSource =
this.dataProviderService.getDataSourceForImport();
}
} }
if (!isDryRun) { if (!isDryRun) {
@ -381,11 +543,6 @@ export class ImportService {
activity.accountId = accountIdMapping[activity.accountId]; activity.accountId = accountIdMapping[activity.accountId];
} }
// If a new asset profile is created, then update the symbol in all activities
if (assetProfileSymbolMapping[activity.symbol]) {
activity.symbol = assetProfileSymbolMapping[activity.symbol];
}
// If a new tag is created, then update the tag ID in all activities // If a new tag is created, then update the tag ID in all activities
activity.tags = (activity.tags ?? []).map((tagId) => { activity.tags = (activity.tags ?? []).map((tagId) => {
return tagIdMapping[tagId] ?? tagId; return tagIdMapping[tagId] ?? tagId;
@ -446,19 +603,18 @@ export class ImportService {
const error = activity.error; const error = activity.error;
const fee = activity.fee; const fee = activity.fee;
const quantity = activity.quantity; const quantity = activity.quantity;
const SymbolProfile = activity.SymbolProfile;
const tagIds = activity.tagIds ?? []; const tagIds = activity.tagIds ?? [];
const type = activity.type; const type = activity.type;
const unitPrice = activity.unitPrice; const unitPrice = activity.unitPrice;
const assetProfile = assetProfiles[ const assetProfile = assetProfiles[
getAssetProfileIdentifier({ getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource, dataSource: activity.assetProfile.dataSource,
symbol: SymbolProfile.symbol symbol: activity.assetProfile.symbol
}) })
] ?? { ] ?? {
dataSource: SymbolProfile.dataSource, dataSource: activity.assetProfile.dataSource,
symbol: SymbolProfile.symbol symbol: activity.assetProfile.symbol
}; };
const { const {
assetClass, assetClass,
@ -536,6 +692,8 @@ export class ImportService {
url, url,
comment: assetProfile.comment, comment: assetProfile.comment,
currency: assetProfile.currency, currency: assetProfile.currency,
dataGatheringFrequency:
assetProfile.dataGatheringFrequency ?? 'DAILY',
userId: dataSource === 'MANUAL' ? user.id : undefined userId: dataSource === 'MANUAL' ? user.id : undefined
}, },
symbolProfileId: undefined, symbolProfileId: undefined,
@ -590,20 +748,21 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber(); const value = new Big(quantity).mul(unitPrice).toNumber();
const valueInBaseCurrency = this.exchangeRateDataService.toCurrencyAtDate( const valueInBaseCurrency =
value, (await this.exchangeRateDataService.toCurrencyAtDate(
currency ?? assetProfile.currency, value,
userCurrency, currency ?? assetProfile.currency,
date userCurrency,
); date
)) ?? 0;
activities.push({ activities.push({
...order, ...order,
// @ts-ignore
assetProfile,
error, error,
value, value,
valueInBaseCurrency: await valueInBaseCurrency, valueInBaseCurrency
// @ts-ignore
SymbolProfile: assetProfile
}); });
} }
@ -613,19 +772,19 @@ export class ImportService {
if (!isDryRun) { if (!isDryRun) {
// Gather symbol data in the background, if not dry run // Gather symbol data in the background, if not dry run
const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => { const uniqueActivities = uniqBy(activities, ({ assetProfile }) => {
return getAssetProfileIdentifier({ return getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource, dataSource: assetProfile.dataSource,
symbol: SymbolProfile.symbol symbol: assetProfile.symbol
}); });
}); });
this.dataGatheringService.gatherSymbols({ this.dataGatheringService.gatherSymbols({
dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => { dataGatheringItems: uniqueActivities.map(({ assetProfile, date }) => {
return { return {
date, date,
dataSource: SymbolProfile.dataSource, dataSource: assetProfile.dataSource,
symbol: SymbolProfile.symbol symbol: assetProfile.symbol
}; };
}), }),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
@ -672,12 +831,12 @@ export class ImportService {
activity.accountId === accountId && activity.accountId === accountId &&
activity.comment === comment && activity.comment === comment &&
(activity.currency === currency || (activity.currency === currency ||
activity.SymbolProfile.currency === currency) && activity.assetProfile.currency === currency) &&
activity.SymbolProfile.dataSource === dataSource && activity.assetProfile.dataSource === dataSource &&
isSameSecond(activity.date, date) && isSameSecond(activity.date, date) &&
activity.fee === fee && activity.fee === fee &&
activity.quantity === quantity && activity.quantity === quantity &&
activity.SymbolProfile.symbol === symbol && activity.assetProfile.symbol === symbol &&
activity.type === type && activity.type === type &&
activity.unitPrice === unitPrice activity.unitPrice === unitPrice
); );
@ -697,7 +856,7 @@ export class ImportService {
quantity, quantity,
type, type,
unitPrice, unitPrice,
SymbolProfile: { assetProfile: {
dataSource, dataSource,
symbol, symbol,
activitiesCount: undefined, activitiesCount: undefined,

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

@ -7,6 +7,7 @@ import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.mo
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -29,6 +30,7 @@ import { InfoService } from './info.service';
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}), }),
MarketDataModule,
PlatformModule, PlatformModule,
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,

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

@ -3,10 +3,13 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
ghostfolioFearAndGreedIndexSymbolStocks,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS, PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID, PROPERTY_DEMO_USER_ID,
PROPERTY_DOCKER_HUB_PULLS, PROPERTY_DOCKER_HUB_PULLS,
@ -14,15 +17,14 @@ import {
PROPERTY_GITHUB_STARGAZERS, PROPERTY_GITHUB_STARGAZERS,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS, PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_UPTIME, PROPERTY_UPTIME
ghostfolioFearAndGreedIndexDataSourceStocks
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { encodeDataSource } from '@ghostfolio/common/helper';
import { InfoItem, Statistics } from '@ghostfolio/common/interfaces'; import { InfoItem, Statistics } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { MarketData } from '@prisma/client';
import { subDays } from 'date-fns'; import { subDays } from 'date-fns';
import { isNil } from 'lodash'; import { isNil } from 'lodash';
@ -33,8 +35,10 @@ export class InfoService {
public constructor( public constructor(
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly marketDataService: MarketDataService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
@ -44,6 +48,7 @@ export class InfoService {
public async get(): Promise<InfoItem> { public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {}; const info: Partial<InfoItem> = {};
let isReadOnlyMode: boolean; let isReadOnlyMode: boolean;
let latestFearAndGreedStocksMarketDataPromise: Promise<MarketData>;
const globalPermissions: string[] = []; const globalPermissions: string[] = [];
@ -60,14 +65,12 @@ export class InfoService {
} }
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { latestFearAndGreedStocksMarketDataPromise =
info.fearAndGreedDataSource = encodeDataSource( this.marketDataService.getLatest({
ghostfolioFearAndGreedIndexDataSourceStocks dataSource:
); this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(),
} else { symbol: ghostfolioFearAndGreedIndexSymbolStocks
info.fearAndGreedDataSource = });
ghostfolioFearAndGreedIndexDataSourceStocks;
}
globalPermissions.push(permissions.enableFearAndGreedIndex); globalPermissions.push(permissions.enableFearAndGreedIndex);
} }
@ -99,12 +102,14 @@ export class InfoService {
benchmarks, benchmarks,
demoAuthToken, demoAuthToken,
isUserSignupEnabled, isUserSignupEnabled,
latestFearAndGreedStocksMarketData,
statistics, statistics,
subscriptionOffer subscriptionOffer
] = await Promise.all([ ] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(), this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(), this.getDemoAuthToken(),
this.propertyService.isUserSignupEnabled(), this.propertyService.isUserSignupEnabled(),
latestFearAndGreedStocksMarketDataPromise,
this.getStatistics(), this.getStatistics(),
this.subscriptionService.getSubscriptionOffer({ key: 'default' }) this.subscriptionService.getSubscriptionOffer({ key: 'default' })
]); ]);
@ -122,7 +127,9 @@ export class InfoService {
statistics, statistics,
subscriptionOffer, subscriptionOffer,
baseCurrency: DEFAULT_CURRENCY, baseCurrency: DEFAULT_CURRENCY,
currencies: this.exchangeRateDataService.getCurrencies() currencies: this.exchangeRateDataService.getCurrencies(),
fearAndGreedStocksMarketPrice:
latestFearAndGreedStocksMarketData?.marketPrice
}; };
} }

9
apps/api/src/app/logo/get-logo.dto.ts

@ -0,0 +1,9 @@
import { IsUrl } from 'class-validator';
export class GetLogoDto {
@IsUrl({
protocols: ['http', 'https'],
require_protocol: true
})
url: string;
}

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

@ -12,6 +12,7 @@ import {
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { Response } from 'express'; import { Response } from 'express';
import { GetLogoDto } from './get-logo.dto';
import { LogoService } from './logo.service'; import { LogoService } from './logo.service';
@Controller('logo') @Controller('logo')
@ -41,7 +42,7 @@ export class LogoController {
@Get() @Get()
public async getLogoByUrl( public async getLogoByUrl(
@Query('url') url: string, @Query() { url }: GetLogoDto,
@Res() response: Response @Res() response: Response
) { ) {
try { try {

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

@ -1,5 +1,6 @@
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -11,6 +12,7 @@ import { LogoService } from './logo.service';
controllers: [LogoController], controllers: [LogoController],
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
FetchModule,
SymbolProfileModule, SymbolProfileModule,
TransformDataSourceInRequestModule TransformDataSourceInRequestModule
], ],

22
apps/api/src/app/logo/logo.service.ts

@ -1,4 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
@ -10,6 +11,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
export class LogoService { export class LogoService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -43,15 +45,17 @@ export class LogoService {
} }
private async getBuffer(aUrl: string) { private async getBuffer(aUrl: string) {
const blob = await fetch( const blob = await this.fetchService
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, .fetch(
{ `https://t0.gstatic.com/faviconV2?client=SOCIAL&fallback_opts=TYPE,SIZE,URL&size=64&type=FAVICON&url=${encodeURIComponent(aUrl)}`,
headers: { 'User-Agent': 'request' }, {
signal: AbortSignal.timeout( headers: { 'User-Agent': 'request' },
this.configurationService.get('REQUEST_TIMEOUT') signal: AbortSignal.timeout(
) this.configurationService.get('REQUEST_TIMEOUT')
} )
).then((res) => res.blob()); }
)
.then((res) => res.blob());
return { return {
buffer: await blob.arrayBuffer().then((arrayBuffer) => { buffer: await blob.arrayBuffer().then((arrayBuffer) => {

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

@ -22,7 +22,7 @@ export const activityDummyData = {
valueInBaseCurrency: undefined valueInBaseCurrency: undefined
}; };
export const symbolProfileDummyData = { export const assetProfileDummyData = {
activitiesCount: undefined, activitiesCount: undefined,
assetClass: undefined, assetClass: undefined,
assetSubClass: undefined, assetSubClass: undefined,

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

@ -1,4 +1,6 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error';
import { PortfolioCalculatorPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-position.interface';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface'; import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface';
@ -34,7 +36,7 @@ import {
ResponseError, ResponseError,
SymbolMetrics SymbolMetrics
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { GroupBy } from '@ghostfolio/common/types'; import { GroupBy } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
@ -51,6 +53,8 @@ import {
format, format,
isAfter, isAfter,
isBefore, isBefore,
isFuture,
isPast,
isWithinInterval, isWithinInterval,
min, min,
startOfDay, startOfDay,
@ -62,6 +66,10 @@ import { isNumber, sortBy, sum, uniqBy } from 'lodash';
export abstract class PortfolioCalculator { export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false; protected static readonly ENABLE_LOGGING = false;
private static readonly MAX_INITIALIZATION_ATTEMPTS = 3;
protected readonly logger = new Logger(PortfolioCalculator.name);
protected accountBalanceItems: HistoricalDataItem[]; protected accountBalanceItems: HistoricalDataItem[];
protected activities: PortfolioOrder[]; protected activities: PortfolioOrder[];
@ -119,11 +127,11 @@ export abstract class PortfolioCalculator {
this.activities = activities this.activities = activities
.map( .map(
({ ({
assetProfile,
date, date,
feeInAssetProfileCurrency, feeInAssetProfileCurrency,
feeInBaseCurrency, feeInBaseCurrency,
quantity, quantity,
SymbolProfile,
tags = [], tags = [],
type, type,
unitPriceInAssetProfileCurrency unitPriceInAssetProfileCurrency
@ -132,14 +140,14 @@ export abstract class PortfolioCalculator {
dateOfFirstActivity = date; dateOfFirstActivity = date;
} }
if (isAfter(date, new Date())) { if (isFuture(date)) {
// Adapt date to today if activity is in future (e.g. liability) // Adapt date to today if activity is in future (e.g. liability)
// to include it in the interval // to include it in the interval
date = endOfDay(new Date()); date = endOfDay(new Date());
} }
return { return {
SymbolProfile, assetProfile,
tags, tags,
type, type,
date: format(date, DATE_FORMAT), date: format(date, DATE_FORMAT),
@ -169,10 +177,15 @@ export abstract class PortfolioCalculator {
this.computeTransactionPoints(); this.computeTransactionPoints();
this.snapshotPromise = this.initialize(); this.snapshotPromise = this.initialize();
// Mark the rejection as handled to prevent an unhandled promise rejection
// in case the snapshot promise is never awaited. Consumers awaiting it
// still receive the error.
this.snapshotPromise.catch(() => undefined);
} }
protected abstract calculateOverallPerformance( protected abstract calculateOverallPerformance(
positions: TimelinePosition[] positions: PortfolioCalculatorPosition[]
): PortfolioSnapshot; ): PortfolioSnapshot;
@LogPerformance @LogPerformance
@ -192,6 +205,7 @@ export abstract class PortfolioCalculator {
hasErrors: false, hasErrors: false,
historicalData: [], historicalData: [],
positions: [], positions: [],
totalCashInBaseCurrency: new Big(0),
totalFeesWithCurrencyEffect: new Big(0), totalFeesWithCurrencyEffect: new Big(0),
totalInterestWithCurrencyEffect: new Big(0), totalInterestWithCurrencyEffect: new Big(0),
totalInvestment: new Big(0), totalInvestment: new Big(0),
@ -200,10 +214,12 @@ export abstract class PortfolioCalculator {
}; };
} }
const cashSymbols = new Set<string>();
const currencies: { [symbol: string]: string } = {}; const currencies: { [symbol: string]: string } = {};
const dataGatheringItems: DataGatheringItem[] = []; const dataGatheringItems: DataGatheringItem[] = [];
let firstIndex = transactionPoints.length; let firstIndex = transactionPoints.length;
let firstTransactionPoint: TransactionPoint = null; let firstTransactionPoint: TransactionPoint = null;
let totalCashInBaseCurrency = new Big(0);
let totalInterestWithCurrencyEffect = new Big(0); let totalInterestWithCurrencyEffect = new Big(0);
let totalLiabilitiesWithCurrencyEffect = new Big(0); let totalLiabilitiesWithCurrencyEffect = new Big(0);
@ -305,20 +321,19 @@ export abstract class PortfolioCalculator {
const errors: ResponseError['errors'] = []; const errors: ResponseError['errors'] = [];
let hasAnySymbolMetricsErrors = false; let hasAnySymbolMetricsErrors = false;
const positions: (TimelinePosition & { const positions: PortfolioCalculatorPosition[] = [];
includeInHoldings: boolean;
})[] = [];
const accumulatedValuesByDate: { const accumulatedValuesByDate: {
[date: string]: { [date: string]: {
investmentValueWithCurrencyEffect: Big; investmentValueWithCurrencyEffect: Big;
totalAccountBalanceWithCurrencyEffect: Big; totalCashValueWithCurrencyEffect: Big;
totalCurrentValue: Big; totalCurrentValue: Big;
totalCurrentValueWithCurrencyEffect: Big; totalCurrentValueWithCurrencyEffect: Big;
totalInvestmentValue: Big; totalInvestmentValue: Big;
totalInvestmentValueWithCurrencyEffect: Big; totalInvestmentValueWithCurrencyEffect: Big;
totalNetPerformanceValue: Big; totalNetPerformanceValue: Big;
totalNetPerformanceValueWithCurrencyEffect: Big; totalNetPerformanceValueWithCurrencyEffect: Big;
totalNetWorthValueWithCurrencyEffect: Big;
totalTimeWeightedInvestmentValue: Big; totalTimeWeightedInvestmentValue: Big;
totalTimeWeightedInvestmentValueWithCurrencyEffect: Big; totalTimeWeightedInvestmentValueWithCurrencyEffect: Big;
}; };
@ -333,6 +348,7 @@ export abstract class PortfolioCalculator {
investmentValuesWithCurrencyEffect: { [date: string]: Big }; investmentValuesWithCurrencyEffect: { [date: string]: Big };
netPerformanceValues: { [date: string]: Big }; netPerformanceValues: { [date: string]: Big };
netPerformanceValuesWithCurrencyEffect: { [date: string]: Big }; netPerformanceValuesWithCurrencyEffect: { [date: string]: Big };
netWorthValuesWithCurrencyEffect: { [date: string]: Big };
timeWeightedInvestmentValues: { [date: string]: Big }; timeWeightedInvestmentValues: { [date: string]: Big };
timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big }; timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big };
}; };
@ -347,6 +363,13 @@ export abstract class PortfolioCalculator {
] ?? 1 ] ?? 1
); );
const valueInBaseCurrency = marketPriceInBaseCurrency.mul(item.quantity);
const isCashInBaseCurrency =
item.assetSubClass === AssetSubClass.CASH &&
item.currency === this.currency &&
item.symbol === this.currency;
const { const {
currentValues, currentValues,
currentValuesWithCurrencyEffect, currentValuesWithCurrencyEffect,
@ -387,25 +410,37 @@ export abstract class PortfolioCalculator {
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
const includeInTotalAssetValue = // Cash in the base currency cannot generate a currency effect and thus
item.assetSubClass !== AssetSubClass.CASH; // contributes nothing but its balance to the performance calculation. It
// is therefore excluded from the value and the investment, while still
if (includeInTotalAssetValue) { // contributing to the net worth.
valuesBySymbol[item.symbol] = { valuesBySymbol[item.symbol] = isCashInBaseCurrency
currentValues, ? {
currentValuesWithCurrencyEffect, currentValues: {},
investmentValuesAccumulated, currentValuesWithCurrencyEffect: {},
investmentValuesAccumulatedWithCurrencyEffect, investmentValuesAccumulated: {},
investmentValuesWithCurrencyEffect, investmentValuesAccumulatedWithCurrencyEffect: {},
netPerformanceValues, investmentValuesWithCurrencyEffect: {},
netPerformanceValuesWithCurrencyEffect, netPerformanceValues: {},
timeWeightedInvestmentValues, netPerformanceValuesWithCurrencyEffect: {},
timeWeightedInvestmentValuesWithCurrencyEffect netWorthValuesWithCurrencyEffect: currentValuesWithCurrencyEffect,
}; timeWeightedInvestmentValues: {},
} timeWeightedInvestmentValuesWithCurrencyEffect: {}
}
: {
currentValues,
currentValuesWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect,
netWorthValuesWithCurrencyEffect: currentValuesWithCurrencyEffect
};
positions.push({ positions.push({
includeInTotalAssetValue,
timeWeightedInvestment, timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect, timeWeightedInvestmentWithCurrencyEffect,
activitiesCount: item.activitiesCount, activitiesCount: item.activitiesCount,
@ -428,6 +463,7 @@ export abstract class PortfolioCalculator {
? (grossPerformanceWithCurrencyEffect ?? null) ? (grossPerformanceWithCurrencyEffect ?? null)
: null, : null,
includeInHoldings: item.includeInHoldings, includeInHoldings: item.includeInHoldings,
includeInPerformance: !isCashInBaseCurrency,
investment: totalInvestment, investment: totalInvestment,
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
marketPrice: marketPrice:
@ -446,11 +482,16 @@ export abstract class PortfolioCalculator {
quantity: item.quantity, quantity: item.quantity,
symbol: item.symbol, symbol: item.symbol,
tags: item.tags, tags: item.tags,
valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul( valueInBaseCurrency
item.quantity
)
}); });
if (item.assetSubClass === AssetSubClass.CASH) {
cashSymbols.add(item.symbol);
totalCashInBaseCurrency =
totalCashInBaseCurrency.plus(valueInBaseCurrency);
}
totalInterestWithCurrencyEffect = totalInterestWithCurrencyEffect.plus( totalInterestWithCurrencyEffect = totalInterestWithCurrencyEffect.plus(
totalInterestInBaseCurrency totalInterestInBaseCurrency
); );
@ -470,28 +511,7 @@ export abstract class PortfolioCalculator {
} }
} }
const accountBalanceItemsMap = this.accountBalanceItems.reduce(
(map, { date, value }) => {
map[date] = new Big(value);
return map;
},
{} as { [date: string]: Big }
);
const accountBalanceMap: { [date: string]: Big } = {};
let lastKnownBalance = new Big(0);
for (const dateString of chartDates) { for (const dateString of chartDates) {
if (accountBalanceItemsMap[dateString] !== undefined) {
// If there's an exact balance for this date, update lastKnownBalance
lastKnownBalance = accountBalanceItemsMap[dateString];
}
// Add the most recent balance to the accountBalanceMap
accountBalanceMap[dateString] = lastKnownBalance;
for (const symbol of Object.keys(valuesBySymbol)) { for (const symbol of Object.keys(valuesBySymbol)) {
const symbolValues = valuesBySymbol[symbol]; const symbolValues = valuesBySymbol[symbol];
@ -521,6 +541,10 @@ export abstract class PortfolioCalculator {
symbolValues.netPerformanceValuesWithCurrencyEffect?.[dateString] ?? symbolValues.netPerformanceValuesWithCurrencyEffect?.[dateString] ??
new Big(0); new Big(0);
const netWorthValueWithCurrencyEffect =
symbolValues.netWorthValuesWithCurrencyEffect?.[dateString] ??
new Big(0);
const timeWeightedInvestmentValue = const timeWeightedInvestmentValue =
symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0); symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0);
@ -534,7 +558,14 @@ export abstract class PortfolioCalculator {
accumulatedValuesByDate[dateString] accumulatedValuesByDate[dateString]
?.investmentValueWithCurrencyEffect ?? new Big(0) ?.investmentValueWithCurrencyEffect ?? new Big(0)
).add(investmentValueWithCurrencyEffect), ).add(investmentValueWithCurrencyEffect),
totalAccountBalanceWithCurrencyEffect: accountBalanceMap[dateString], totalCashValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalCashValueWithCurrencyEffect ?? new Big(0)
).add(
cashSymbols.has(symbol)
? netWorthValueWithCurrencyEffect
: new Big(0)
),
totalCurrentValue: ( totalCurrentValue: (
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0) accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
).add(currentValue), ).add(currentValue),
@ -558,6 +589,10 @@ export abstract class PortfolioCalculator {
accumulatedValuesByDate[dateString] accumulatedValuesByDate[dateString]
?.totalNetPerformanceValueWithCurrencyEffect ?? new Big(0) ?.totalNetPerformanceValueWithCurrencyEffect ?? new Big(0)
).add(netPerformanceValueWithCurrencyEffect), ).add(netPerformanceValueWithCurrencyEffect),
totalNetWorthValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalNetWorthValueWithCurrencyEffect ?? new Big(0)
).add(netWorthValueWithCurrencyEffect),
totalTimeWeightedInvestmentValue: ( totalTimeWeightedInvestmentValue: (
accumulatedValuesByDate[dateString] accumulatedValuesByDate[dateString]
?.totalTimeWeightedInvestmentValue ?? new Big(0) ?.totalTimeWeightedInvestmentValue ?? new Big(0)
@ -575,13 +610,14 @@ export abstract class PortfolioCalculator {
).map(([date, values]) => { ).map(([date, values]) => {
const { const {
investmentValueWithCurrencyEffect, investmentValueWithCurrencyEffect,
totalAccountBalanceWithCurrencyEffect, totalCashValueWithCurrencyEffect,
totalCurrentValue, totalCurrentValue,
totalCurrentValueWithCurrencyEffect, totalCurrentValueWithCurrencyEffect,
totalInvestmentValue, totalInvestmentValue,
totalInvestmentValueWithCurrencyEffect, totalInvestmentValueWithCurrencyEffect,
totalNetPerformanceValue, totalNetPerformanceValue,
totalNetPerformanceValueWithCurrencyEffect, totalNetPerformanceValueWithCurrencyEffect,
totalNetWorthValueWithCurrencyEffect,
totalTimeWeightedInvestmentValue, totalTimeWeightedInvestmentValue,
totalTimeWeightedInvestmentValueWithCurrencyEffect totalTimeWeightedInvestmentValueWithCurrencyEffect
} = values; } = values;
@ -608,10 +644,8 @@ export abstract class PortfolioCalculator {
netPerformance: totalNetPerformanceValue.toNumber(), netPerformance: totalNetPerformanceValue.toNumber(),
netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffect:
totalNetPerformanceValueWithCurrencyEffect.toNumber(), totalNetPerformanceValueWithCurrencyEffect.toNumber(),
netWorth: totalCurrentValueWithCurrencyEffect netWorth: totalNetWorthValueWithCurrencyEffect.toNumber(),
.plus(totalAccountBalanceWithCurrencyEffect) totalCashInBaseCurrency: totalCashValueWithCurrencyEffect.toNumber(),
.toNumber(),
totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(),
totalInvestment: totalInvestmentValue.toNumber(), totalInvestment: totalInvestmentValue.toNumber(),
totalInvestmentValueWithCurrencyEffect: totalInvestmentValueWithCurrencyEffect:
totalInvestmentValueWithCurrencyEffect.toNumber(), totalInvestmentValueWithCurrencyEffect.toNumber(),
@ -627,7 +661,7 @@ export abstract class PortfolioCalculator {
return includeInHoldings; return includeInHoldings;
}) })
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(({ includeInHoldings, ...rest }) => { .map(({ includeInHoldings, includeInPerformance, ...rest }) => {
return rest; return rest;
}); });
@ -635,6 +669,7 @@ export abstract class PortfolioCalculator {
...overall, ...overall,
errors, errors,
historicalData, historicalData,
totalCashInBaseCurrency,
totalInterestWithCurrencyEffect, totalInterestWithCurrencyEffect,
totalLiabilitiesWithCurrencyEffect, totalLiabilitiesWithCurrencyEffect,
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors, hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors,
@ -772,11 +807,6 @@ export abstract class PortfolioCalculator {
? 0 ? 0
: netPerformanceWithCurrencyEffectSinceStartDate / : netPerformanceWithCurrencyEffectSinceStartDate /
timeWeightedInvestmentValue timeWeightedInvestmentValue
// TODO: Add net worth
// netWorth: totalCurrentValueWithCurrencyEffect
// .plus(totalAccountBalanceWithCurrencyEffect)
// .toNumber()
// netWorth: 0
}); });
} }
} }
@ -794,25 +824,39 @@ export abstract class PortfolioCalculator {
let firstAccountBalanceDate: Date; let firstAccountBalanceDate: Date;
let firstActivityDate: Date; let firstActivityDate: Date;
try { if (this.accountBalanceItems?.length > 0) {
const firstAccountBalanceDateString = this.accountBalanceItems[0]?.date; try {
firstAccountBalanceDate = firstAccountBalanceDateString const firstAccountBalanceDateString = this.accountBalanceItems[0].date;
? parseDate(firstAccountBalanceDateString) firstAccountBalanceDate = firstAccountBalanceDateString
: new Date(); ? parseDate(firstAccountBalanceDateString)
} catch (error) { : new Date();
firstAccountBalanceDate = new Date(); } catch (error) {
firstAccountBalanceDate = new Date();
}
} }
try { if (this.transactionPoints?.length > 0) {
const firstActivityDateString = this.transactionPoints[0].date; try {
firstActivityDate = firstActivityDateString const firstActivityDateString = this.transactionPoints[0].date;
? parseDate(firstActivityDateString) firstActivityDate = firstActivityDateString
: new Date(); ? parseDate(firstActivityDateString)
} catch (error) { : new Date();
firstActivityDate = new Date(); } catch (error) {
firstActivityDate = new Date();
}
} }
return min([firstAccountBalanceDate, firstActivityDate]); const dates = [firstAccountBalanceDate, firstActivityDate].filter(
(date) => {
return !!date;
}
);
if (dates.length === 0) {
return undefined;
}
return min(dates);
} }
protected abstract getSymbolMetrics({ protected abstract getSymbolMetrics({
@ -932,23 +976,23 @@ export abstract class PortfolioCalculator {
let lastTransactionPoint: TransactionPoint = null; let lastTransactionPoint: TransactionPoint = null;
for (const { for (const {
assetProfile,
date, date,
fee, fee,
feeInBaseCurrency, feeInBaseCurrency,
quantity, quantity,
SymbolProfile,
tags, tags,
type, type,
unitPrice unitPrice
} of this.activities) { } of this.activities) {
let currentTransactionPointItem: TransactionPointSymbol; let currentTransactionPointItem: TransactionPointSymbol;
const assetSubClass = SymbolProfile.assetSubClass; const assetSubClass = assetProfile.assetSubClass;
const currency = SymbolProfile.currency; const currency = assetProfile.currency;
const dataSource = SymbolProfile.dataSource; const dataSource = assetProfile.dataSource;
const factor = getFactor(type); const factor = getFactor(type);
const skipErrors = !!SymbolProfile.userId; // Skip errors for custom asset profiles const skipErrors = !!assetProfile.userId; // Skip errors for custom asset profiles
const symbol = SymbolProfile.symbol; const symbol = assetProfile.symbol;
const oldAccumulatedSymbol = symbols[symbol]; const oldAccumulatedSymbol = symbols[symbol];
@ -1032,12 +1076,12 @@ export abstract class PortfolioCalculator {
'id' 'id'
); );
symbols[SymbolProfile.symbol] = currentTransactionPointItem; symbols[symbol] = currentTransactionPointItem;
const items = lastTransactionPoint?.items ?? []; const items = lastTransactionPoint?.items ?? [];
const newItems = items.filter(({ symbol }) => { const newItems = items.filter(({ symbol }) => {
return symbol !== SymbolProfile.symbol; return symbol !== assetProfile.symbol;
}); });
newItems.push(currentTransactionPointItem); newItems.push(currentTransactionPointItem);
@ -1088,20 +1132,23 @@ export abstract class PortfolioCalculator {
} }
@LogPerformance @LogPerformance
private async initialize() { private async initialize(attempt = 1) {
const startTimeTotal = performance.now(); const startTimeTotal = performance.now();
let cachedPortfolioSnapshot: PortfolioSnapshot; let cachedPortfolioSnapshot: PortfolioSnapshot;
let isCachedPortfolioSnapshotExpired = false; let isCachedPortfolioSnapshotExpired = false;
const jobId = this.userId; const portfolioSnapshotKey = this.redisCacheService.getPortfolioSnapshotKey(
{
filters: this.filters,
userId: this.userId
}
);
const jobId = portfolioSnapshotKey;
try { try {
const cachedPortfolioSnapshotValue = await this.redisCacheService.get( const cachedPortfolioSnapshotValue =
this.redisCacheService.getPortfolioSnapshotKey({ await this.redisCacheService.get(portfolioSnapshotKey);
filters: this.filters,
userId: this.userId
})
);
const { expiration, portfolioSnapshot }: PortfolioSnapshotValue = const { expiration, portfolioSnapshot }: PortfolioSnapshotValue =
JSON.parse(cachedPortfolioSnapshotValue); JSON.parse(cachedPortfolioSnapshotValue);
@ -1111,7 +1158,7 @@ export abstract class PortfolioCalculator {
portfolioSnapshot portfolioSnapshot
); );
if (isAfter(new Date(), new Date(expiration))) { if (isPast(new Date(expiration))) {
isCachedPortfolioSnapshotExpired = true; isCachedPortfolioSnapshotExpired = true;
} }
} catch {} } catch {}
@ -1119,12 +1166,11 @@ export abstract class PortfolioCalculator {
if (cachedPortfolioSnapshot) { if (cachedPortfolioSnapshot) {
this.snapshot = cachedPortfolioSnapshot; this.snapshot = cachedPortfolioSnapshot;
Logger.debug( this.logger.debug(
`Fetched portfolio snapshot from cache in ${( `Fetched portfolio snapshot from cache in ${(
(performance.now() - startTimeTotal) / (performance.now() - startTimeTotal) /
1000 1000
).toFixed(3)} seconds`, ).toFixed(3)} seconds`
'PortfolioCalculator'
); );
if (isCachedPortfolioSnapshotExpired) { if (isCachedPortfolioSnapshotExpired) {
@ -1145,6 +1191,12 @@ export abstract class PortfolioCalculator {
}); });
} }
} else { } else {
if (attempt > PortfolioCalculator.MAX_INITIALIZATION_ATTEMPTS) {
throw new PortfolioSnapshotComputationError(
`Portfolio snapshot of user '${this.userId}' could not be computed after ${PortfolioCalculator.MAX_INITIALIZATION_ATTEMPTS} attempts`
);
}
// Wait for computation // Wait for computation
await this.portfolioSnapshotService.addJobToQueue({ await this.portfolioSnapshotService.addJobToQueue({
data: { data: {
@ -1167,7 +1219,7 @@ export abstract class PortfolioCalculator {
await job.finished(); await job.finished();
} }
await this.initialize(); await this.initialize(attempt + 1);
} }
} }
} }

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
@ -65,7 +68,7 @@ describe('PortfolioCalculator', () => {
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null); redisCacheService = new RedisCacheService(null, null);
@ -85,33 +88,33 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-22'), assetProfile: {
feeInAssetProfileCurrency: 1.55, ...assetProfileDummyData,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Bâloise Holding AG', name: 'Bâloise Holding AG',
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 142.9 unitPriceInAssetProfileCurrency: 142.9
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), assetProfile: {
feeInAssetProfileCurrency: 1.65, ...assetProfileDummyData,
feeInBaseCurrency: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Bâloise Holding AG', name: 'Bâloise Holding AG',
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 2,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 136.6 unitPriceInAssetProfileCurrency: 136.6
} }

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

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

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
@ -65,7 +68,7 @@ describe('PortfolioCalculator', () => {
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null); redisCacheService = new RedisCacheService(null, null);
@ -85,33 +88,33 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-22'), assetProfile: {
feeInAssetProfileCurrency: 1.55, ...assetProfileDummyData,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Bâloise Holding AG', name: 'Bâloise Holding AG',
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 142.9 unitPriceInAssetProfileCurrency: 142.9
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), assetProfile: {
feeInAssetProfileCurrency: 1.65, ...assetProfileDummyData,
feeInBaseCurrency: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Bâloise Holding AG', name: 'Bâloise Holding AG',
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 2,
type: 'SELL', type: 'SELL',
unitPriceInAssetProfileCurrency: 136.6 unitPriceInAssetProfileCurrency: 136.6
} }

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
@ -65,7 +68,7 @@ describe('PortfolioCalculator', () => {
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null); redisCacheService = new RedisCacheService(null, null);
@ -85,17 +88,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), assetProfile: {
feeInAssetProfileCurrency: 1.55, ...assetProfileDummyData,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Bâloise Holding AG', name: 'Bâloise Holding AG',
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 136.6 unitPriceInAssetProfileCurrency: 136.6
} }
@ -217,17 +220,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), assetProfile: {
feeInAssetProfileCurrency: 1.55, ...assetProfileDummyData,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Bâloise Holding AG', name: 'Bâloise Holding AG',
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 135.0 unitPriceInAssetProfileCurrency: 135.0
} }
@ -257,17 +260,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), assetProfile: {
feeInAssetProfileCurrency: 1.55, ...assetProfileDummyData,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Bâloise Holding AG', name: 'Bâloise Holding AG',
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 135.0 unitPriceInAssetProfileCurrency: 135.0
} }

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

@ -1,7 +1,7 @@
import { import {
activityDummyData, activityDummyData,
assetProfileDummyData,
loadExportFile, loadExportFile,
symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -76,6 +76,9 @@ describe('PortfolioCalculator', () => {
}); });
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
@ -87,7 +90,7 @@ describe('PortfolioCalculator', () => {
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null); redisCacheService = new RedisCacheService(null, null);
@ -107,17 +110,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = exportResponse.activities.map( const activities: Activity[] = exportResponse.activities.map(
(activity) => ({ (activity) => ({
...activityDummyData, ...activityDummyData,
...activity, assetProfile: {
date: parseDate(activity.date), ...assetProfileDummyData,
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 3.94,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: activity.dataSource, dataSource: activity.dataSource,
name: 'Bitcoin', name: 'Bitcoin',
symbol: activity.symbol symbol: activity.symbol
}, },
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 3.94,
unitPriceInAssetProfileCurrency: 44558.42 unitPriceInAssetProfileCurrency: 44558.42
}) })
); );

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

@ -1,7 +1,7 @@
import { import {
activityDummyData, activityDummyData,
assetProfileDummyData,
loadExportFile, loadExportFile,
symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => {
}); });
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
@ -75,7 +78,7 @@ describe('PortfolioCalculator', () => {
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null); redisCacheService = new RedisCacheService(null, null);
@ -95,17 +98,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = exportResponse.activities.map( const activities: Activity[] = exportResponse.activities.map(
(activity) => ({ (activity) => ({
...activityDummyData, ...activityDummyData,
...activity, assetProfile: {
date: parseDate(activity.date), ...assetProfileDummyData,
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: activity.dataSource, dataSource: activity.dataSource,
name: 'Bitcoin', name: 'Bitcoin',
symbol: activity.symbol symbol: activity.symbol
}, },
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 4.46,
unitPriceInAssetProfileCurrency: 44558.42 unitPriceInAssetProfileCurrency: 44558.42
}) })
); );
@ -145,7 +148,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0,
netWorth: 0, netWorth: 0,
totalAccountBalance: 0, totalCashInBaseCurrency: 0,
totalInvestment: 0, totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0, totalInvestmentValueWithCurrencyEffect: 0,
value: 0, value: 0,
@ -163,7 +166,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412 netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
netPerformanceWithCurrencyEffect: 5535.42, netPerformanceWithCurrencyEffect: 5535.42,
netWorth: 50098.3, // 1 * 50098.3 = 50098.3 netWorth: 50098.3, // 1 * 50098.3 = 50098.3
totalAccountBalance: 0, totalCashInBaseCurrency: 0,
totalInvestment: 44558.42, totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42, totalInvestmentValueWithCurrencyEffect: 44558.42,
value: 50098.3, // 1 * 50098.3 = 50098.3 value: 50098.3, // 1 * 50098.3 = 50098.3
@ -182,7 +185,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712, netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712,
netPerformanceWithCurrencyEffect: -1463.18, netPerformanceWithCurrencyEffect: -1463.18,
netWorth: 43099.7, netWorth: 43099.7,
totalAccountBalance: 0, totalCashInBaseCurrency: 0,
totalInvestment: 44558.42, totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42, totalInvestmentValueWithCurrencyEffect: 44558.42,
value: 43099.7, value: 43099.7,

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -66,6 +66,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
@ -77,7 +80,7 @@ describe('PortfolioCalculator', () => {
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null); redisCacheService = new RedisCacheService(null, null);
@ -98,33 +101,33 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2015-01-01'), assetProfile: {
feeInAssetProfileCurrency: 0, ...assetProfileDummyData,
feeInBaseCurrency: 0,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Bitcoin USD', name: 'Bitcoin USD',
symbol: 'BTCUSD' symbol: 'BTCUSD'
}, },
date: new Date('2015-01-01'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 2,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 320.43 unitPriceInAssetProfileCurrency: 320.43
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2017-12-31'), assetProfile: {
feeInAssetProfileCurrency: 0, ...assetProfileDummyData,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Bitcoin USD', name: 'Bitcoin USD',
symbol: 'BTCUSD' symbol: 'BTCUSD'
}, },
date: new Date('2017-12-31'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'SELL', type: 'SELL',
unitPriceInAssetProfileCurrency: 14156.4 unitPriceInAssetProfileCurrency: 14156.4
} }

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

@ -1,7 +1,7 @@
import { import {
activityDummyData, activityDummyData,
assetProfileDummyData,
loadExportFile, loadExportFile,
symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => {
}); });
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
@ -75,7 +78,7 @@ describe('PortfolioCalculator', () => {
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null); redisCacheService = new RedisCacheService(null, null);
@ -96,16 +99,16 @@ describe('PortfolioCalculator', () => {
(activity) => ({ (activity) => ({
...activityDummyData, ...activityDummyData,
...activity, ...activity,
date: parseDate(activity.date), assetProfile: {
feeInAssetProfileCurrency: activity.fee, ...assetProfileDummyData,
feeInBaseCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: activity.dataSource, dataSource: activity.dataSource,
name: 'Bitcoin', name: 'Bitcoin',
symbol: activity.symbol symbol: activity.symbol
}, },
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
unitPriceInAssetProfileCurrency: activity.unitPrice unitPriceInAssetProfileCurrency: activity.unitPrice
}) })
); );

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

@ -1,7 +1,7 @@
import { import {
activityDummyData, activityDummyData,
assetProfileDummyData,
loadExportFile, loadExportFile,
symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => {
}); });
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
@ -75,7 +78,7 @@ describe('PortfolioCalculator', () => {
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null); redisCacheService = new RedisCacheService(null, null);
@ -96,16 +99,16 @@ describe('PortfolioCalculator', () => {
(activity) => ({ (activity) => ({
...activityDummyData, ...activityDummyData,
...activity, ...activity,
date: parseDate(activity.date), assetProfile: {
feeInAssetProfileCurrency: 4.46, ...assetProfileDummyData,
feeInBaseCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: activity.dataSource, dataSource: activity.dataSource,
name: 'Bitcoin', name: 'Bitcoin',
symbol: activity.symbol symbol: activity.symbol
}, },
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 4.46,
unitPriceInAssetProfileCurrency: 44558.42 unitPriceInAssetProfileCurrency: 44558.42
}) })
); );
@ -145,7 +148,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0,
netWorth: 0, netWorth: 0,
totalAccountBalance: 0, totalCashInBaseCurrency: 0,
totalInvestment: 0, totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0, totalInvestmentValueWithCurrencyEffect: 0,
value: 0, value: 0,
@ -163,7 +166,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412 netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
netPerformanceWithCurrencyEffect: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42 netPerformanceWithCurrencyEffect: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
netWorth: 50098.3, // 1 * 50098.3 = 50098.3 netWorth: 50098.3, // 1 * 50098.3 = 50098.3
totalAccountBalance: 0, totalCashInBaseCurrency: 0,
totalInvestment: 44558.42, totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42, totalInvestmentValueWithCurrencyEffect: 44558.42,
value: 50098.3, // 1 * 50098.3 = 50098.3 value: 50098.3, // 1 * 50098.3 = 50098.3
@ -182,7 +185,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712, netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712,
netPerformanceWithCurrencyEffect: -1463.18, netPerformanceWithCurrencyEffect: -1463.18,
netWorth: 43099.7, netWorth: 43099.7,
totalAccountBalance: 0, totalCashInBaseCurrency: 0,
totalInvestment: 44558.42, totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42, totalInvestmentValueWithCurrencyEffect: 44558.42,
value: 43099.7, value: 43099.7,

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

@ -1,7 +1,11 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import {
activityDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
@ -19,6 +23,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { eachDayOfInterval } from 'date-fns';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
@ -72,6 +77,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -91,6 +99,7 @@ describe('PortfolioCalculator', () => {
accountBalanceService, accountBalanceService,
null, null,
exchangeRateDataService, exchangeRateDataService,
null,
null null
); );
@ -116,14 +125,17 @@ describe('PortfolioCalculator', () => {
accountBalanceService, accountBalanceService,
accountService, accountService,
null, null,
null,
dataProviderService, dataProviderService,
null, null,
exchangeRateDataService, exchangeRateDataService,
null, null,
null,
null,
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory( portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService, configurationService,
@ -146,17 +158,25 @@ describe('PortfolioCalculator', () => {
balances: [ balances: [
{ {
accountId, accountId,
id: randomUUID(),
date: parseDate('2023-12-31'), date: parseDate('2023-12-31'),
id: randomUUID(),
value: 1000, value: 1000,
valueInBaseCurrency: 850 valueInBaseCurrency: 850
}, },
{ {
accountId, accountId,
id: randomUUID(),
date: parseDate('2024-12-31'), date: parseDate('2024-12-31'),
id: randomUUID(),
value: 2000, value: 2000,
valueInBaseCurrency: 1800 valueInBaseCurrency: 1800
},
{
// Ignored future account balance
accountId,
date: parseDate('2050-12-31'),
id: randomUUID(),
value: 0,
valueInBaseCurrency: 0
} }
] ]
}); });
@ -169,7 +189,6 @@ describe('PortfolioCalculator', () => {
createdAt: parseDate('2023-12-31'), createdAt: parseDate('2023-12-31'),
currency: 'USD', currency: 'USD',
id: accountId, id: accountId,
isExcluded: false,
name: 'USD', name: 'USD',
platformId: null, platformId: null,
updatedAt: parseDate('2023-12-31'), updatedAt: parseDate('2023-12-31'),
@ -244,7 +263,6 @@ describe('PortfolioCalculator', () => {
'0.08211603004634809014' '0.08211603004634809014'
), ),
grossPerformanceWithCurrencyEffect: new Big(70), grossPerformanceWithCurrencyEffect: new Big(70),
includeInTotalAssetValue: false,
investment: new Big(1820), investment: new Big(1820),
investmentWithCurrencyEffect: new Big(1750), investmentWithCurrencyEffect: new Big(1750),
marketPrice: 1, marketPrice: 1,
@ -279,11 +297,317 @@ describe('PortfolioCalculator', () => {
}); });
expect(portfolioSnapshot).toMatchObject({ expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big(1820),
hasErrors: false,
totalCashInBaseCurrency: new Big(1820),
totalFeesWithCurrencyEffect: new Big(0),
totalInterestWithCurrencyEffect: new Big(0),
totalInvestment: new Big(1820),
totalLiabilitiesWithCurrencyEffect: new Big(0)
});
/**
* Value with currency effect: 2000 USD * 0.91 = 1820 CHF
* Net worth: 1820 CHF (the cash is included in the value and therefore
* not added on top of it again)
* Cash in base currency: 2000 USD * 0.91 = 1820 CHF (the whole portfolio
* consists of cash, hence it matches the value)
* Net performance with currency effect: 70 CHF / 852.45 CHF 8.21 %
*/
expect(portfolioSnapshot.historicalData.at(-1)).toEqual({
date: '2025-01-01',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0.08211603004634808,
netPerformanceWithCurrencyEffect: 70,
netWorth: 1820,
totalCashInBaseCurrency: 1820,
totalInvestment: 1820,
totalInvestmentValueWithCurrencyEffect: 1750,
value: 1820,
valueWithCurrencyEffect: 1820
});
});
it('should exclude cash in the base currency from the performance calculation', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime());
const accountId = randomUUID();
jest
.spyOn(accountBalanceService, 'getAccountBalances')
.mockResolvedValue({
balances: [
{
accountId,
date: parseDate('2023-12-31'),
id: randomUUID(),
value: 1000,
valueInBaseCurrency: 1000
},
{
accountId,
date: parseDate('2024-12-31'),
id: randomUUID(),
value: 2000,
valueInBaseCurrency: 2000
}
]
});
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({
accounts: [
{
balance: 2000,
comment: null,
createdAt: parseDate('2023-12-31'),
currency: 'CHF',
id: accountId,
name: 'CHF',
platformId: null,
updatedAt: parseDate('2023-12-31'),
userId: userDummyData.id
}
],
balanceInBaseCurrency: 2000
});
jest
.spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO);
jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({
activities: [],
count: 0
});
const { activities } =
await activitiesService.getActivitiesForPortfolioCalculator({
userCurrency: 'CHF',
userId: userDummyData.id,
withCash: true
});
jest.spyOn(currentRateService, 'getValues').mockResolvedValue({
dataProviderInfos: [],
errors: [],
values: []
});
const accountBalanceItems =
await accountBalanceService.getAccountBalanceItems({
userCurrency: 'CHF',
userId: userDummyData.id
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
accountBalanceItems,
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const position = portfolioSnapshot.positions.find(({ symbol }) => {
return symbol === 'CHF';
});
/**
* The holding itself keeps its investment and value so that it remains
* visible in the holdings table
*/
expect(position).toMatchObject<Partial<TimelinePosition>>({
currency: 'CHF',
grossPerformance: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
investment: new Big(2000),
investmentWithCurrencyEffect: new Big(2000),
netPerformance: new Big(0),
quantity: new Big(2000),
symbol: 'CHF',
valueInBaseCurrency: new Big(2000)
});
/**
* Total investment: 0 CHF (cash in the base currency cannot generate a
* currency effect and would only dilute the performance)
* Current value in base currency: 2000 CHF (the cash still counts
* towards the net worth)
*/
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big(2000),
hasErrors: false, hasErrors: false,
totalCashInBaseCurrency: new Big(2000),
totalFeesWithCurrencyEffect: new Big(0), totalFeesWithCurrencyEffect: new Big(0),
totalInterestWithCurrencyEffect: new Big(0), totalInterestWithCurrencyEffect: new Big(0),
totalInvestment: new Big(0),
totalLiabilitiesWithCurrencyEffect: new Big(0) totalLiabilitiesWithCurrencyEffect: new Big(0)
}); });
/**
* Value: 0 CHF (the cash is excluded from the performance calculation
* and therefore from the value it is measured against)
* Net worth: 2000 CHF (the cash still counts towards the net worth)
*/
expect(portfolioSnapshot.historicalData.at(-1)).toEqual({
date: '2025-01-01',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 2000,
totalCashInBaseCurrency: 2000,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
});
it('should add cash in the base currency to the net worth of a portfolio with holdings', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime());
const accountId = randomUUID();
jest
.spyOn(accountBalanceService, 'getAccountBalances')
.mockResolvedValue({
balances: [
{
accountId,
date: parseDate('2023-12-31'),
id: randomUUID(),
value: 2000,
valueInBaseCurrency: 2000
}
]
});
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({
accounts: [
{
balance: 2000,
comment: null,
createdAt: parseDate('2023-12-31'),
currency: 'CHF',
id: accountId,
name: 'CHF',
platformId: null,
updatedAt: parseDate('2023-12-31'),
userId: userDummyData.id
}
],
balanceInBaseCurrency: 2000
});
jest
.spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO);
jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({
activities: [
{
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Novartis AG',
symbol: 'NOVN.SW'
},
date: parseDate('2023-12-31'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 2,
type: 'BUY',
unitPriceInAssetProfileCurrency: 100
}
],
count: 1
});
const { activities } =
await activitiesService.getActivitiesForPortfolioCalculator({
userCurrency: 'CHF',
userId: userDummyData.id,
withCash: true
});
// The cash symbol has no market data, the holding is quoted at a
// constant price so that it does not generate any performance on its own
jest
.spyOn(currentRateService, 'getValues')
.mockImplementation(({ dataGatheringItems, dateQuery }) => {
const values = [];
for (const date of eachDayOfInterval({
end: dateQuery.lt,
start: dateQuery.gte
})) {
for (const { dataSource, symbol } of dataGatheringItems) {
if (symbol === 'NOVN.SW') {
values.push({ date, dataSource, marketPrice: 100, symbol });
}
}
}
return Promise.resolve({
values,
dataProviderInfos: [],
errors: []
});
});
const accountBalanceItems =
await accountBalanceService.getAccountBalanceItems({
userCurrency: 'CHF',
userId: userDummyData.id
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
accountBalanceItems,
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
/**
* Total assets: 2000 CHF cash + 2 * 100 CHF holding = 2200 CHF
* Total investment: 200 CHF (only the holding, the cash is excluded)
*/
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big(2200),
hasErrors: false,
totalCashInBaseCurrency: new Big(2000),
totalInvestment: new Big(200)
});
/**
* Value: 200 CHF (the holding only, the cash is excluded from the
* performance calculation)
* Net worth: 2200 CHF (the value plus the cash, counted exactly once)
*/
expect(portfolioSnapshot.historicalData.at(-1)).toEqual({
date: '2025-01-01',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 2200,
totalCashInBaseCurrency: 2000,
totalInvestment: 200,
totalInvestmentValueWithCurrencyEffect: 200,
value: 200,
valueWithCurrencyEffect: 200
});
}); });
}); });
}); });

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
@ -65,7 +68,7 @@ describe('PortfolioCalculator', () => {
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null); redisCacheService = new RedisCacheService(null, null);
@ -85,17 +88,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-09-01'), assetProfile: {
feeInAssetProfileCurrency: 49, ...assetProfileDummyData,
feeInBaseCurrency: 49,
quantity: 0,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'MANUAL', dataSource: 'MANUAL',
name: 'Account Opening Fee', name: 'Account Opening Fee',
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141' symbol: '2c463fb3-af07-486e-adb0-8301b3d72141'
}, },
date: new Date('2021-09-01'),
feeInAssetProfileCurrency: 49,
feeInBaseCurrency: 49,
quantity: 0,
type: 'FEE', type: 'FEE',
unitPriceInAssetProfileCurrency: 0 unitPriceInAssetProfileCurrency: 0
} }
@ -113,7 +116,7 @@ describe('PortfolioCalculator', () => {
expect(portfolioSnapshot).toMatchObject({ expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'), currentValueInBaseCurrency: new Big('0'),
errors: [], errors: [],
hasErrors: true, hasErrors: false,
positions: [], positions: [],
totalFeesWithCurrencyEffect: new Big('49'), totalFeesWithCurrencyEffect: new Big('49'),
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -66,6 +66,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
@ -77,7 +80,7 @@ describe('PortfolioCalculator', () => {
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null); redisCacheService = new RedisCacheService(null, null);
@ -97,17 +100,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2023-01-03'), assetProfile: {
feeInAssetProfileCurrency: 1, ...assetProfileDummyData,
feeInBaseCurrency: 0.9238,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Alphabet Inc.', name: 'Alphabet Inc.',
symbol: 'GOOGL' symbol: 'GOOGL'
}, },
date: new Date('2023-01-03'),
feeInAssetProfileCurrency: 1,
feeInBaseCurrency: 0.9238,
quantity: 1,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 89.12 unitPriceInAssetProfileCurrency: 89.12
} }

383
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts

@ -1,190 +1,193 @@
import { import {
activityDummyData, activityDummyData,
loadExportFile, assetProfileDummyData,
symbolProfileDummyData, loadExportFile,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; import { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'node:path'; import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
CurrentRateService: jest.fn().mockImplementation(() => { CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock; return CurrentRateServiceMock;
}) })
}; };
}); });
jest.mock( jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => { () => {
return { return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => { PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock; return PortfolioSnapshotServiceMock;
}) })
}; };
} }
); );
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return { return {
RedisCacheService: jest.fn().mockImplementation(() => { RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock; return RedisCacheServiceMock;
}) })
}; };
}); });
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let exportResponse: ExportResponse; let exportResponse: ExportResponse;
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory; let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService; let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeAll(() => { beforeAll(() => {
exportResponse = loadExportFile( exportResponse = loadExportFile(
join( join(
__dirname, __dirname,
'../../../../../../../test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json' '../../../../../../../test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json'
) )
); );
}); });
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService(); PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
currentRateService = new CurrentRateService(null, null, null, null);
configurationService = new ConfigurationService();
exchangeRateDataService = new ExchangeRateDataService(
null, currentRateService = new CurrentRateService(null, null, null, null);
null,
null, exchangeRateDataService = new ExchangeRateDataService(
null null,
); null,
null,
portfolioSnapshotService = new PortfolioSnapshotService(null); null
);
redisCacheService = new RedisCacheService(null, null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService, redisCacheService = new RedisCacheService(null, null);
currentRateService,
exchangeRateDataService, portfolioCalculatorFactory = new PortfolioCalculatorFactory(
portfolioSnapshotService, configurationService,
redisCacheService currentRateService,
); exchangeRateDataService,
}); portfolioSnapshotService,
redisCacheService
describe('get current positions', () => { );
it.only('with JNUG buy and sell', async () => { });
jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime());
describe('get current positions', () => {
const activities: Activity[] = exportResponse.activities.map( it.only('with JNUG buy and sell', async () => {
(activity) => ({ jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime());
...activityDummyData,
...activity, const activities: Activity[] = exportResponse.activities.map(
date: parseDate(activity.date), (activity) => ({
feeInAssetProfileCurrency: activity.fee, ...activityDummyData,
feeInBaseCurrency: activity.fee, ...activity,
SymbolProfile: { assetProfile: {
...symbolProfileDummyData, ...assetProfileDummyData,
currency: activity.currency, currency: activity.currency,
dataSource: activity.dataSource, dataSource: activity.dataSource,
name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares', name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares',
symbol: activity.symbol symbol: activity.symbol
}, },
unitPriceInAssetProfileCurrency: activity.unitPrice date: parseDate(activity.date),
}) feeInAssetProfileCurrency: activity.fee,
); feeInBaseCurrency: activity.fee,
unitPriceInAssetProfileCurrency: activity.unitPrice
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ })
activities, );
calculationType: PerformanceCalculationType.ROAI,
currency: exportResponse.user.settings.currency, const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
userId: userDummyData.id activities,
}); calculationType: PerformanceCalculationType.ROAI,
currency: exportResponse.user.settings.currency,
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); userId: userDummyData.id
});
const investments = portfolioCalculator.getInvestments();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData, const investments = portfolioCalculator.getInvestments();
groupBy: 'month'
}); const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ groupBy: 'month'
data: portfolioSnapshot.historicalData, });
groupBy: 'year'
}); const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
expect(portfolioSnapshot).toMatchObject({ groupBy: 'year'
currentValueInBaseCurrency: new Big('0'), });
errors: [],
hasErrors: false, expect(portfolioSnapshot).toMatchObject({
positions: [ currentValueInBaseCurrency: new Big('0'),
{ errors: [],
activitiesCount: 4, hasErrors: false,
averagePrice: new Big('0'), positions: [
currency: 'USD', {
dataSource: 'YAHOO', activitiesCount: 4,
dateOfFirstActivity: '2025-12-11', averagePrice: new Big('0'),
dividend: new Big('0'), currency: 'USD',
dividendInBaseCurrency: new Big('0'), dataSource: 'YAHOO',
fee: new Big('4'), dateOfFirstActivity: '2025-12-11',
feeInBaseCurrency: new Big('4'), dividend: new Big('0'),
grossPerformance: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) dividendInBaseCurrency: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) fee: new Big('4'),
investment: new Big('0'), feeInBaseCurrency: new Big('4'),
investmentWithCurrencyEffect: new Big('0'), grossPerformance: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
netPerformance: new Big('39.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4 grossPerformanceWithCurrencyEffect: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
netPerformanceWithCurrencyEffectMap: { investment: new Big('0'),
max: new Big('39.95') // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4 investmentWithCurrencyEffect: new Big('0'),
}, netPerformance: new Big('39.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
marketPrice: 237.8000030517578, netPerformanceWithCurrencyEffectMap: {
marketPriceInBaseCurrency: 237.8000030517578, max: new Big('39.95') // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
quantity: new Big('0'), },
symbol: 'JNUG', marketPrice: 237.8000030517578,
tags: [], marketPriceInBaseCurrency: 237.8000030517578,
valueInBaseCurrency: new Big('0') quantity: new Big('0'),
} symbol: 'JNUG',
], tags: [],
totalFeesWithCurrencyEffect: new Big('4'), valueInBaseCurrency: new Big('0')
totalInterestWithCurrencyEffect: new Big('0'), }
totalInvestment: new Big('0'), ],
totalInvestmentWithCurrencyEffect: new Big('0'), totalFeesWithCurrencyEffect: new Big('4'),
totalLiabilitiesWithCurrencyEffect: new Big('0') totalInterestWithCurrencyEffect: new Big('0'),
}); totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
expect(investments).toEqual([ totalLiabilitiesWithCurrencyEffect: new Big('0')
{ date: '2025-12-11', investment: new Big('1885.05') }, });
{ date: '2025-12-18', investment: new Big('2041.1') },
{ date: '2025-12-28', investment: new Big('0') } expect(investments).toEqual([
]); { date: '2025-12-11', investment: new Big('1885.05') },
{ date: '2025-12-18', investment: new Big('2041.1') },
expect(investmentsByMonth).toEqual([ { date: '2025-12-28', investment: new Big('0') }
{ date: '2025-12-01', investment: 0 } ]);
]);
expect(investmentsByMonth).toEqual([
expect(investmentsByYear).toEqual([ { date: '2025-12-01', investment: 0 }
{ date: '2025-01-01', investment: 0 } ]);
]);
}); expect(investmentsByYear).toEqual([
}); { date: '2025-01-01', investment: 0 }
}); ]);
});
});
});

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
@ -65,7 +68,7 @@ describe('PortfolioCalculator', () => {
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null); redisCacheService = new RedisCacheService(null, null);
@ -85,17 +88,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2023-01-01'), // Date in future assetProfile: {
feeInAssetProfileCurrency: 0, ...assetProfileDummyData,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'MANUAL', dataSource: 'MANUAL',
name: 'Loan', name: 'Loan',
symbol: '55196015-1365-4560-aa60-8751ae6d18f8' symbol: '55196015-1365-4560-aa60-8751ae6d18f8'
}, },
date: new Date('2023-01-01'), // Date in future
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'LIABILITY', type: 'LIABILITY',
unitPriceInAssetProfileCurrency: 3000 unitPriceInAssetProfileCurrency: 3000
} }

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -52,6 +52,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -60,7 +63,7 @@ describe('PortfolioCalculator', () => {
null, null,
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null); redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory( portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService, configurationService,
@ -78,49 +81,49 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2024-03-08'), assetProfile: {
feeInAssetProfileCurrency: 0, ...assetProfileDummyData,
feeInBaseCurrency: 0,
quantity: 0.3333333333333333,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Microsoft Inc.', name: 'Microsoft Inc.',
symbol: 'MSFT' symbol: 'MSFT'
}, },
date: new Date('2024-03-08'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 0.3333333333333333,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 408 unitPriceInAssetProfileCurrency: 408
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2024-03-13'), assetProfile: {
feeInAssetProfileCurrency: 0, ...assetProfileDummyData,
feeInBaseCurrency: 0,
quantity: 0.6666666666666666,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Microsoft Inc.', name: 'Microsoft Inc.',
symbol: 'MSFT' symbol: 'MSFT'
}, },
date: new Date('2024-03-13'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 0.6666666666666666,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 400 unitPriceInAssetProfileCurrency: 400
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2024-03-14'), assetProfile: {
feeInAssetProfileCurrency: 0, ...assetProfileDummyData,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Microsoft Inc.', name: 'Microsoft Inc.',
symbol: 'MSFT' symbol: 'MSFT'
}, },
date: new Date('2024-03-14'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'SELL', type: 'SELL',
unitPriceInAssetProfileCurrency: 411 unitPriceInAssetProfileCurrency: 411
} }

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
@ -65,7 +68,7 @@ describe('PortfolioCalculator', () => {
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null); redisCacheService = new RedisCacheService(null, null);
@ -85,33 +88,33 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-09-16'), assetProfile: {
feeInAssetProfileCurrency: 19, ...assetProfileDummyData,
feeInBaseCurrency: 19,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Microsoft Inc.', name: 'Microsoft Inc.',
symbol: 'MSFT' symbol: 'MSFT'
}, },
date: new Date('2021-09-16'),
feeInAssetProfileCurrency: 19,
feeInBaseCurrency: 19,
quantity: 1,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 298.58 unitPriceInAssetProfileCurrency: 298.58
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-16'), assetProfile: {
feeInAssetProfileCurrency: 0, ...assetProfileDummyData,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Microsoft Inc.', name: 'Microsoft Inc.',
symbol: 'MSFT' symbol: 'MSFT'
}, },
date: new Date('2021-11-16'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'DIVIDEND', type: 'DIVIDEND',
unitPriceInAssetProfileCurrency: 0.62 unitPriceInAssetProfileCurrency: 0.62
} }

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

@ -49,6 +49,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
@ -60,7 +63,7 @@ describe('PortfolioCalculator', () => {
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null); redisCacheService = new RedisCacheService(null, null);
@ -74,7 +77,7 @@ describe('PortfolioCalculator', () => {
}); });
describe('get current positions', () => { describe('get current positions', () => {
it('with no orders', async () => { it('with no activities', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({

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

@ -1,7 +1,7 @@
import { import {
activityDummyData, activityDummyData,
assetProfileDummyData,
loadExportFile, loadExportFile,
symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -67,6 +67,9 @@ describe('PortfolioCalculator', () => {
}); });
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
@ -78,7 +81,7 @@ describe('PortfolioCalculator', () => {
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null); redisCacheService = new RedisCacheService(null, null);
@ -99,16 +102,16 @@ describe('PortfolioCalculator', () => {
(activity) => ({ (activity) => ({
...activityDummyData, ...activityDummyData,
...activity, ...activity,
date: parseDate(activity.date), assetProfile: {
feeInAssetProfileCurrency: activity.fee, ...assetProfileDummyData,
feeInBaseCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency, currency: activity.currency,
dataSource: activity.dataSource, dataSource: activity.dataSource,
name: 'Novartis AG', name: 'Novartis AG',
symbol: activity.symbol symbol: activity.symbol
}, },
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
unitPriceInAssetProfileCurrency: activity.unitPrice unitPriceInAssetProfileCurrency: activity.unitPrice
}) })
); );

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

@ -1,264 +1,267 @@
import { import {
activityDummyData, activityDummyData,
loadExportFile, assetProfileDummyData,
symbolProfileDummyData, loadExportFile,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; import { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'node:path'; import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
CurrentRateService: jest.fn().mockImplementation(() => { CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock; return CurrentRateServiceMock;
}) })
}; };
}); });
jest.mock( jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => { () => {
return { return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => { PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock; return PortfolioSnapshotServiceMock;
}) })
}; };
} }
); );
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return { return {
RedisCacheService: jest.fn().mockImplementation(() => { RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock; return RedisCacheServiceMock;
}) })
}; };
}); });
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let exportResponse: ExportResponse; let exportResponse: ExportResponse;
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory; let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService; let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeAll(() => { beforeAll(() => {
exportResponse = loadExportFile( exportResponse = loadExportFile(
join( join(
__dirname, __dirname,
'../../../../../../../test/import/ok/novn-buy-and-sell.json' '../../../../../../../test/import/ok/novn-buy-and-sell.json'
) )
); );
}); });
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService(); PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
currentRateService = new CurrentRateService(null, null, null, null);
configurationService = new ConfigurationService();
exchangeRateDataService = new ExchangeRateDataService(
null, currentRateService = new CurrentRateService(null, null, null, null);
null,
null, exchangeRateDataService = new ExchangeRateDataService(
null null,
); null,
null,
portfolioSnapshotService = new PortfolioSnapshotService(null); null
);
redisCacheService = new RedisCacheService(null, null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService, redisCacheService = new RedisCacheService(null, null);
currentRateService,
exchangeRateDataService, portfolioCalculatorFactory = new PortfolioCalculatorFactory(
portfolioSnapshotService, configurationService,
redisCacheService currentRateService,
); exchangeRateDataService,
}); portfolioSnapshotService,
redisCacheService
describe('get current positions', () => { );
it.only('with NOVN.SW buy and sell', async () => { });
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
describe('get current positions', () => {
const activities: Activity[] = exportResponse.activities.map( it.only('with NOVN.SW buy and sell', async () => {
(activity) => ({ jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
...activityDummyData,
...activity, const activities: Activity[] = exportResponse.activities.map(
date: parseDate(activity.date), (activity) => ({
feeInAssetProfileCurrency: activity.fee, ...activityDummyData,
feeInBaseCurrency: activity.fee, ...activity,
SymbolProfile: { assetProfile: {
...symbolProfileDummyData, ...assetProfileDummyData,
currency: activity.currency, currency: activity.currency,
dataSource: activity.dataSource, dataSource: activity.dataSource,
name: 'Novartis AG', name: 'Novartis AG',
symbol: activity.symbol symbol: activity.symbol
}, },
unitPriceInAssetProfileCurrency: activity.unitPrice date: parseDate(activity.date),
}) feeInAssetProfileCurrency: activity.fee,
); feeInBaseCurrency: activity.fee,
unitPriceInAssetProfileCurrency: activity.unitPrice
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ })
activities, );
calculationType: PerformanceCalculationType.ROAI,
currency: exportResponse.user.settings.currency, const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
userId: userDummyData.id activities,
}); calculationType: PerformanceCalculationType.ROAI,
currency: exportResponse.user.settings.currency,
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); userId: userDummyData.id
});
const investments = portfolioCalculator.getInvestments();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData, const investments = portfolioCalculator.getInvestments();
groupBy: 'month'
}); const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ groupBy: 'month'
data: portfolioSnapshot.historicalData, });
groupBy: 'year'
}); const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
expect(portfolioSnapshot.historicalData[0]).toEqual({ groupBy: 'year'
date: '2022-03-06', });
investmentValueWithCurrencyEffect: 0,
netPerformance: 0, expect(portfolioSnapshot.historicalData[0]).toEqual({
netPerformanceInPercentage: 0, date: '2022-03-06',
netPerformanceInPercentageWithCurrencyEffect: 0, investmentValueWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0, netPerformance: 0,
netWorth: 0, netPerformanceInPercentage: 0,
totalAccountBalance: 0, netPerformanceInPercentageWithCurrencyEffect: 0,
totalInvestment: 0, netPerformanceWithCurrencyEffect: 0,
totalInvestmentValueWithCurrencyEffect: 0, netWorth: 0,
value: 0, totalCashInBaseCurrency: 0,
valueWithCurrencyEffect: 0 totalInvestment: 0,
}); totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
/** valueWithCurrencyEffect: 0
* Closing price on 2022-03-07 is unknown, });
* hence it uses the last unit price (2022-04-11): 87.8
*/ /**
expect(portfolioSnapshot.historicalData[1]).toEqual({ * Closing price on 2022-03-07 is unknown,
date: '2022-03-07', * hence it uses the last unit price (2022-04-11): 87.8
investmentValueWithCurrencyEffect: 151.6, */
netPerformance: 24, // 2 * (87.8 - 75.8) = 24 expect(portfolioSnapshot.historicalData[1]).toEqual({
netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438 date: '2022-03-07',
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438 investmentValueWithCurrencyEffect: 151.6,
netPerformanceWithCurrencyEffect: 24, netPerformance: 24, // 2 * (87.8 - 75.8) = 24
netWorth: 175.6, // 2 * 87.8 = 175.6 netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
totalAccountBalance: 0, netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
totalInvestment: 151.6, netPerformanceWithCurrencyEffect: 24,
totalInvestmentValueWithCurrencyEffect: 151.6, netWorth: 175.6, // 2 * 87.8 = 175.6
value: 175.6, // 2 * 87.8 = 175.6 totalCashInBaseCurrency: 0,
valueWithCurrencyEffect: 175.6 totalInvestment: 151.6,
}); totalInvestmentValueWithCurrencyEffect: 151.6,
value: 175.6, // 2 * 87.8 = 175.6
expect( valueWithCurrencyEffect: 175.6
portfolioSnapshot.historicalData[ });
portfolioSnapshot.historicalData.length - 1
] expect(
).toEqual({ portfolioSnapshot.historicalData[
date: '2022-04-11', portfolioSnapshot.historicalData.length - 1
investmentValueWithCurrencyEffect: 0, ]
netPerformance: 19.86, ).toEqual({
netPerformanceInPercentage: 0.13100263852242744, date: '2022-04-11',
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, investmentValueWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 19.86, netPerformance: 19.86,
netWorth: 0, netPerformanceInPercentage: 0.13100263852242744,
totalAccountBalance: 0, netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
totalInvestment: 0, netPerformanceWithCurrencyEffect: 19.86,
totalInvestmentValueWithCurrencyEffect: 0, netWorth: 0,
value: 0, totalCashInBaseCurrency: 0,
valueWithCurrencyEffect: 0 totalInvestment: 0,
}); totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
expect(portfolioSnapshot).toMatchObject({ valueWithCurrencyEffect: 0
currentValueInBaseCurrency: new Big('0'), });
errors: [],
hasErrors: false, expect(portfolioSnapshot).toMatchObject({
positions: [ currentValueInBaseCurrency: new Big('0'),
{ errors: [],
activitiesCount: 2, hasErrors: false,
averagePrice: new Big('0'), positions: [
currency: 'CHF', {
dataSource: 'YAHOO', activitiesCount: 2,
dateOfFirstActivity: '2022-03-07', averagePrice: new Big('0'),
dividend: new Big('0'), currency: 'CHF',
dividendInBaseCurrency: new Big('0'), dataSource: 'YAHOO',
fee: new Big('0'), dateOfFirstActivity: '2022-03-07',
feeInBaseCurrency: new Big('0'), dividend: new Big('0'),
grossPerformance: new Big('19.86'), dividendInBaseCurrency: new Big('0'),
grossPerformancePercentage: new Big('0.13100263852242744063'), fee: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big( feeInBaseCurrency: new Big('0'),
'0.13100263852242744063' grossPerformance: new Big('19.86'),
), grossPerformancePercentage: new Big('0.13100263852242744063'),
grossPerformanceWithCurrencyEffect: new Big('19.86'), grossPerformancePercentageWithCurrencyEffect: new Big(
investment: new Big('0'), '0.13100263852242744063'
investmentWithCurrencyEffect: new Big('0'), ),
netPerformance: new Big('19.86'), grossPerformanceWithCurrencyEffect: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'), investment: new Big('0'),
netPerformancePercentageWithCurrencyEffectMap: { investmentWithCurrencyEffect: new Big('0'),
max: new Big('0.13100263852242744063') netPerformance: new Big('19.86'),
}, netPerformancePercentage: new Big('0.13100263852242744063'),
netPerformanceWithCurrencyEffectMap: { netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('19.86') max: new Big('0.13100263852242744063')
}, },
marketPrice: 87.8, netPerformanceWithCurrencyEffectMap: {
marketPriceInBaseCurrency: 87.8, max: new Big('19.86')
quantity: new Big('0'), },
symbol: 'NOVN.SW', marketPrice: 87.8,
tags: [], marketPriceInBaseCurrency: 87.8,
timeWeightedInvestment: new Big('151.6'), quantity: new Big('0'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), symbol: 'NOVN.SW',
valueInBaseCurrency: new Big('0') tags: [],
} timeWeightedInvestment: new Big('151.6'),
], timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
totalFeesWithCurrencyEffect: new Big('0'), valueInBaseCurrency: new Big('0')
totalInterestWithCurrencyEffect: new Big('0'), }
totalInvestment: new Big('0'), ],
totalInvestmentWithCurrencyEffect: new Big('0'), totalFeesWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0') totalInterestWithCurrencyEffect: new Big('0'),
}); totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( totalLiabilitiesWithCurrencyEffect: new Big('0')
expect.objectContaining({ });
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744063, expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, expect.objectContaining({
netPerformanceWithCurrencyEffect: 19.86, netPerformance: 19.86,
totalInvestment: 0, netPerformanceInPercentage: 0.13100263852242744063,
totalInvestmentValueWithCurrencyEffect: 0 netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063,
}) netPerformanceWithCurrencyEffect: 19.86,
); totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0
expect(investments).toEqual([ })
{ date: '2022-03-07', investment: new Big('151.6') }, );
{ date: '2022-04-08', investment: new Big('0') }
]); expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
expect(investmentsByMonth).toEqual([ { date: '2022-04-08', investment: new Big('0') }
{ date: '2022-03-01', investment: 151.6 }, ]);
{ date: '2022-04-01', investment: -151.6 }
]); expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: 151.6 },
expect(investmentsByYear).toEqual([ { date: '2022-04-01', investment: -151.6 }
{ date: '2022-01-01', investment: 0 } ]);
]);
}); expect(investmentsByYear).toEqual([
}); { date: '2022-01-01', investment: 0 }
}); ]);
});
});
});

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
@ -65,7 +68,7 @@ describe('PortfolioCalculator', () => {
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null); redisCacheService = new RedisCacheService(null, null);
@ -85,17 +88,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2022-01-01'), assetProfile: {
feeInAssetProfileCurrency: 0, ...assetProfileDummyData,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'MANUAL', dataSource: 'MANUAL',
name: 'Penthouse Apartment', name: 'Penthouse Apartment',
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde' symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
}, },
date: new Date('2022-01-01'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 500000 unitPriceInAssetProfileCurrency: 500000
} }

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

@ -1,4 +1,5 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { PortfolioCalculatorPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-position.interface';
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface'; import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
@ -7,11 +8,10 @@ import {
AssetProfileIdentifier, AssetProfileIdentifier,
SymbolMetrics SymbolMetrics
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { import {
addMilliseconds, addMilliseconds,
@ -27,7 +27,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
private chartDates: string[]; private chartDates: string[];
protected calculateOverallPerformance( protected calculateOverallPerformance(
positions: TimelinePosition[] positions: PortfolioCalculatorPosition[]
): PortfolioSnapshot { ): PortfolioSnapshot {
let currentValueInBaseCurrency = new Big(0); let currentValueInBaseCurrency = new Big(0);
let grossPerformance = new Big(0); let grossPerformance = new Big(0);
@ -41,17 +41,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
let totalTimeWeightedInvestment = new Big(0); let totalTimeWeightedInvestment = new Big(0);
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions.filter( for (const currentPosition of positions) {
({ includeInTotalAssetValue }) => {
return includeInTotalAssetValue;
}
)) {
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency
);
}
if (currentPosition.valueInBaseCurrency) { if (currentPosition.valueInBaseCurrency) {
currentValueInBaseCurrency = currentValueInBaseCurrency.plus( currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
currentPosition.valueInBaseCurrency currentPosition.valueInBaseCurrency
@ -60,6 +50,16 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
hasErrors = true; hasErrors = true;
} }
if (!currentPosition.includeInPerformance) {
continue;
}
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency
);
}
if (currentPosition.investment) { if (currentPosition.investment) {
totalInvestment = totalInvestment.plus(currentPosition.investment); totalInvestment = totalInvestment.plus(currentPosition.investment);
@ -96,9 +96,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
currentPosition.timeWeightedInvestmentWithCurrencyEffect currentPosition.timeWeightedInvestmentWithCurrencyEffect
); );
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
Logger.warn( this.logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`
'PortfolioCalculator'
); );
hasErrors = true; hasErrors = true;
@ -119,6 +118,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
createdAt: new Date(), createdAt: new Date(),
errors: [], errors: [],
historicalData: [], historicalData: [],
totalCashInBaseCurrency: new Big(0),
totalLiabilitiesWithCurrencyEffect: new Big(0) totalLiabilitiesWithCurrencyEffect: new Big(0)
}; };
} }
@ -194,12 +194,12 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
// Clone orders to keep the original values in this.orders // Clone orders to keep the original values in this.orders
let orders: PortfolioOrderItem[] = cloneDeep( let orders: PortfolioOrderItem[] = cloneDeep(
this.activities.filter(({ SymbolProfile }) => { this.activities.filter(({ assetProfile }) => {
return SymbolProfile.symbol === symbol; return assetProfile.symbol === symbol;
}) })
); );
const isCash = orders[0]?.SymbolProfile?.assetSubClass === 'CASH'; const isCash = orders[0]?.assetProfile?.assetSubClass === 'CASH';
if (orders.length <= 0) { if (orders.length <= 0) {
return { return {
@ -238,6 +238,36 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
}; };
} }
// The dividends, the interest and the liabilities are derived from the
// activities only. Accumulate them upfront so that they survive the bail
// out for symbols without a market price below.
for (const order of orders) {
const exchangeRateAtOrderDate = exchangeRates[order.date];
if (order.type === 'DIVIDEND') {
const dividend = order.quantity.mul(order.unitPrice);
totalDividend = totalDividend.plus(dividend);
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
dividend.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'INTEREST') {
const interest = order.quantity.mul(order.unitPrice);
totalInterest = totalInterest.plus(interest);
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
interest.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'LIABILITY') {
const liabilities = order.quantity.mul(order.unitPrice);
totalLiabilities = totalLiabilities.plus(liabilities);
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus(
liabilities.mul(exchangeRateAtOrderDate ?? 1)
);
}
}
const dateOfFirstTransaction = new Date(orders[0].date); const dateOfFirstTransaction = new Date(orders[0].date);
const endDateString = format(end, DATE_FORMAT); const endDateString = format(end, DATE_FORMAT);
@ -265,7 +295,20 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
!unitPriceAtEndDate || !unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start)) (!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
) { ) {
// A missing market price can only affect the units which are held. The
// dividends, the interest and the liabilities do not hold any units and
// are therefore not in error.
const hasActivitiesWithUnits = orders.some(({ type }) => {
return ['BUY', 'SELL'].includes(type);
});
return { return {
totalDividend,
totalDividendInBaseCurrency,
totalInterest,
totalInterestInBaseCurrency,
totalLiabilities,
totalLiabilitiesInBaseCurrency,
currentValues: {}, currentValues: {},
currentValuesWithCurrencyEffect: {}, currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0), feesWithCurrencyEffect: new Big(0),
@ -273,7 +316,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
grossPerformancePercentage: new Big(0), grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0), grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0), grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: true, hasErrors: hasActivitiesWithUnits,
initialValue: new Big(0), initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0), initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {}, investmentValuesAccumulated: {},
@ -290,43 +333,35 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
timeWeightedInvestmentValuesWithCurrencyEffect: {}, timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0), timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0), totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0), totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0), totalInvestmentWithCurrencyEffect: new Big(0)
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0)
}; };
} }
const assetProfile: PortfolioOrderItem['assetProfile'] = {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
};
// Add a synthetic order at the start and the end date // Add a synthetic order at the start and the end date
orders.push({ orders.push({
assetProfile,
date: startDateString, date: startDateString,
fee: new Big(0), fee: new Big(0),
feeInBaseCurrency: new Big(0), feeInBaseCurrency: new Big(0),
itemType: 'start', itemType: 'start',
quantity: new Big(0), quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
type: 'BUY', type: 'BUY',
unitPrice: unitPriceAtStartDate unitPrice: unitPriceAtStartDate
}); });
orders.push({ orders.push({
assetProfile,
date: endDateString, date: endDateString,
fee: new Big(0), fee: new Big(0),
feeInBaseCurrency: new Big(0), feeInBaseCurrency: new Big(0),
itemType: 'end', itemType: 'end',
SymbolProfile: {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
quantity: new Big(0), quantity: new Big(0),
type: 'BUY', type: 'BUY',
unitPrice: unitPriceAtEndDate unitPrice: unitPriceAtEndDate
@ -359,15 +394,11 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
} }
} else { } else {
orders.push({ orders.push({
assetProfile,
date: dateString, date: dateString,
fee: new Big(0), fee: new Big(0),
feeInBaseCurrency: new Big(0), feeInBaseCurrency: new Big(0),
quantity: new Big(0), quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
type: 'BUY', type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice, unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,
unitPriceFromMarketData: unitPriceFromMarketData:
@ -423,29 +454,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
const exchangeRateAtOrderDate = exchangeRates[order.date]; const exchangeRateAtOrderDate = exchangeRates[order.date];
if (order.type === 'DIVIDEND') {
const dividend = order.quantity.mul(order.unitPrice);
totalDividend = totalDividend.plus(dividend);
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
dividend.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'INTEREST') {
const interest = order.quantity.mul(order.unitPrice);
totalInterest = totalInterest.plus(interest);
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
interest.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'LIABILITY') {
const liabilities = order.quantity.mul(order.unitPrice);
totalLiabilities = totalLiabilities.plus(liabilities);
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus(
liabilities.mul(exchangeRateAtOrderDate ?? 1)
);
}
if (order.itemType === 'start') { if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no // Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date // orders of this symbol before the start date

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

@ -51,13 +51,13 @@ export class CurrentRateService {
const values: GetValueObject[] = []; const values: GetValueObject[] = [];
if (includesToday) { if (includesToday) {
const quotesBySymbol = await this.dataProviderService.getQuotes({ const quotes = await this.dataProviderService.getQuotes({
items: dataGatheringItems, items: dataGatheringItems,
user: this.request?.user user: this.request?.user
}); });
for (const { dataSource, symbol } of dataGatheringItems) { for (const { dataSource, symbol } of dataGatheringItems) {
const quote = quotesBySymbol[symbol]; const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })];
if (quote?.dataProviderInfo) { if (quote?.dataProviderInfo) {
dataProviderInfos.push(quote.dataProviderInfo); dataProviderInfos.push(quote.dataProviderInfo);

7
apps/api/src/app/portfolio/errors/portfolio-snapshot-computation.error.ts

@ -0,0 +1,7 @@
export class PortfolioSnapshotComputationError extends Error {
public constructor(message: string) {
super(message);
this.name = 'PortfolioSnapshotComputationError';
}
}

12
apps/api/src/app/portfolio/get-details.dto.ts

@ -0,0 +1,12 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsBoolean } from 'class-validator';
export class GetDetailsDto extends DateRangeFilterDto {
@IsBoolean()
@Transform(({ value }: TransformFnParams) => {
return value === 'true';
})
withMarkets? = false;
}

10
apps/api/src/app/portfolio/get-dividends.dto.ts

@ -0,0 +1,10 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { GroupBy } from '@ghostfolio/common/types';
import { IsIn, IsOptional } from 'class-validator';
export class GetDividendsDto extends DateRangeFilterDto {
@IsIn(['month', 'year'] as GroupBy[])
@IsOptional()
groupBy?: GroupBy;
}

14
apps/api/src/app/portfolio/get-holdings.dto.ts

@ -0,0 +1,14 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { HoldingType } from '@ghostfolio/common/types';
import { IsIn, IsOptional, IsString } from 'class-validator';
export class GetHoldingsDto extends DateRangeFilterDto {
@IsIn(['ACTIVE', 'CLOSED'] as HoldingType[])
@IsOptional()
holdingType?: HoldingType;
@IsOptional()
@IsString()
query?: string;
}

10
apps/api/src/app/portfolio/get-investments.dto.ts

@ -0,0 +1,10 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { GroupBy } from '@ghostfolio/common/types';
import { IsIn, IsOptional } from 'class-validator';
export class GetInvestmentsDto extends DateRangeFilterDto {
@IsIn(['month', 'year'] as GroupBy[])
@IsOptional()
groupBy?: GroupBy;
}

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

Loading…
Cancel
Save