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
.github/instructions/nx.instructions.md
.nx/cache
.nx/migrate-runs
.nx/polygraph
.nx/self-healing
.nx/workspace-data

1
.npmrc

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

12
.vscode/launch.json

@ -18,12 +18,20 @@
"autoAttachChildProcesses": true,
"console": "integratedTerminal",
"cwd": "${workspaceFolder}/apps/api",
"envFile": "${workspaceFolder}/.env",
"env": {
"GHOSTFOLIO_ENV_FILE": "${workspaceFolder}/.env"
},
"name": "Debug API",
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
"program": "${workspaceFolder}/apps/api/src/main.ts",
"request": "launch",
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
"runtimeArgs": [
"--nolazy",
"-r",
"ts-node/register",
"-r",
"${workspaceFolder}/tools/load-env.ts"
],
"skipFiles": [
"${workspaceFolder}/node_modules/**/*.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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
## 3.44.0 - 2026-08-07
### Added
- 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
### 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
- 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)
- 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
- Improved the language localization for German (`de`)
- 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.*/"`
### NestJS
#### Upgrade (minor versions)
1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@nestjs.*/"`
### Nx
#### 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`.
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">
[<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
| Name | Type | Default Value | Description |
| --------------------------- | --------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL. 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`) |
| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. |
#### OpenID Connect OIDC (Experimental)
| Name | Type | Default Value | Description |
| --------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL. 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`) |
| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. |
| `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)
| 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>" }`)
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)
#### 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
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
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.
@ -326,10 +379,6 @@ If you like to support this project, get [**Ghostfolio Premium**](https://ghostf
</a>
</div>
## Analytics
![Alt](https://repobeats.axiom.co/api/embed/281a80b2d0c4af1162866c24c803f1f18e5ed60e.svg 'Repobeats analytics image')
## License
© 2021 - 2026 [Ghostfolio](https://ghostfol.io)

4
apps/api/jest.config.ts

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

26
apps/api/project.json

@ -7,32 +7,10 @@
"generators": {},
"targets": {
"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": {
"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"
}
]
}
"production": {}
},
"outputs": ["{options.outputPath}"]
"outputs": ["{workspaceRoot}/dist/apps/api"]
},
"copy-assets": {
"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 { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
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 type { RequestWithUser } from '@ghostfolio/common/types';
@ -46,13 +46,14 @@ export class AccessController {
});
return accessesWithGranteeUser.map(
({ alias, granteeUser, id, permissions }) => {
({ alias, granteeUser, id, permissions, settings }) => {
if (granteeUser) {
return {
alias,
id,
permissions,
grantee: granteeUser?.id,
settings: settings as AccessSettings,
type: 'PRIVATE'
};
}
@ -62,6 +63,7 @@ export class AccessController {
id,
permissions,
grantee: 'Public',
settings: settings as AccessSettings,
type: 'PUBLIC'
};
}
@ -76,7 +78,7 @@ export class AccessController {
): Promise<AccessModel> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Basic
this.request.user.subscription?.type === SubscriptionType.Basic
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@ -85,12 +87,13 @@ export class AccessController {
}
try {
return this.accessService.createAccess({
return await this.accessService.createAccess({
alias: data.alias || undefined,
granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: undefined,
permissions: data.permissions,
settings: this.accessService.buildSettings(data.filters),
user: { connect: { id: this.request.user.id } }
});
} catch {
@ -131,7 +134,7 @@ export class AccessController {
): Promise<AccessModel> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Basic
this.request.user.subscription?.type === SubscriptionType.Basic
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@ -152,13 +155,14 @@ export class AccessController {
}
try {
return this.accessService.updateAccess({
return await this.accessService.updateAccess({
data: {
alias: data.alias,
granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: { disconnect: true },
permissions: data.permissions
permissions: data.permissions,
settings: this.accessService.buildSettings(data.filters)
},
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 { AccessSettings, Filter } from '@ghostfolio/common/interfaces';
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
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> {
return this.prismaService.access.create({
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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
@ -10,7 +11,7 @@ import { AccountBalanceService } from './account-balance.service';
@Module({
controllers: [AccountBalanceController],
exports: [AccountBalanceService],
imports: [ExchangeRateDataModule, PrismaModule],
imports: [ExchangeRateDataModule, PrismaModule, TagModule],
providers: [AccountBalanceService, AccountService]
})
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 {
isAccountBalanceInFuture,
WHERE_ACCOUNT_NOT_EXCLUDED
} from '@ghostfolio/api/helper/account.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -14,7 +18,8 @@ import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AccountBalance, Prisma } from '@prisma/client';
import { Big } from 'big.js';
import { format, parseISO } from 'date-fns';
import { endOfToday, format, parseISO } from 'date-fns';
import { groupBy } from 'lodash';
@Injectable()
export class AccountBalanceService {
@ -112,8 +117,13 @@ export class AccountBalanceService {
const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } =
{};
const lastBalancesByAccount: { [accountId: string]: Big } = {};
const endOfTodayDate = endOfToday();
for (const { accountId, date, valueInBaseCurrency } of balances) {
if (isAccountBalanceInFuture({ date, endOfTodayDate })) {
continue;
}
const formattedDate = format(date, DATE_FORMAT);
lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency);
@ -144,16 +154,16 @@ export class AccountBalanceService {
}): Promise<AccountBalancesResponse> {
const where: Prisma.AccountBalanceWhereInput = { userId };
const accountFilter = filters?.find(({ type }) => {
return type === 'ACCOUNT';
const { ACCOUNT: [filterByAccount] = [] } = groupBy(filters, ({ type }) => {
return type;
});
if (accountFilter) {
where.accountId = accountFilter.id;
if (filterByAccount) {
where.accountId = filterByAccount.id;
}
if (withExcludedAccounts === false) {
where.account = { isExcluded: false };
where.account = WHERE_ACCOUNT_NOT_EXCLUDED;
}
const balances = await this.prismaService.accountBalance.findMany({
@ -176,7 +186,7 @@ export class AccountBalanceService {
accountId: balance.account.id,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
balance.value,
balance.account.currency,
balance.account.currency ?? 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 { 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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
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 impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@Delete(':id')
@ -137,11 +139,14 @@ export class AccountController {
): Promise<AccountBalancesResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userId = impersonationUserId || this.request.user.id;
const { settings } = await this.userService.user({ id: userId });
return this.accountBalanceService.getAccountBalances({
userId,
filters: [{ id, type: 'ACCOUNT' }],
userCurrency: this.request.user.settings.settings.baseCurrency,
userId: impersonationUserId || this.request.user.id
userCurrency: settings.settings.baseCurrency
});
}
@ -151,28 +156,34 @@ export class AccountController {
public async createAccount(
@Body() data: CreateAccountDto
): Promise<AccountModel> {
if (data.platformId) {
const platformId = data.platformId;
delete data.platformId;
const { balance, tags: tagIds, ...accountData } = data;
if (accountData.platformId) {
const platformId = accountData.platformId;
delete accountData.platformId;
return this.accountService.createAccount(
{
...data,
return this.accountService.createAccount({
balance,
tagIds,
data: {
...accountData,
platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
userId: this.request.user.id
});
} else {
delete data.platformId;
delete accountData.platformId;
return this.accountService.createAccount(
{
...data,
return this.accountService.createAccount({
balance,
tagIds,
data: {
...accountData,
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 platformId = data.platformId;
delete data.platformId;
return this.accountService.updateAccount(
{
data: {
...data,
platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
const { balance, tags: tagIds, ...accountData } = data;
if (accountData.platformId) {
const platformId = accountData.platformId;
delete accountData.platformId;
return this.accountService.updateAccount({
balance,
tagIds,
data: {
...accountData,
platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
userId: this.request.user.id,
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
});
} else {
// platformId is null, remove it
delete data.platformId;
return this.accountService.updateAccount(
{
data: {
...data,
platform: originalAccount.platformId
? { disconnect: true }
: undefined,
user: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
delete accountData.platformId;
return this.accountService.updateAccount({
balance,
tagIds,
data: {
...accountData,
platform: originalAccount.platformId
? { disconnect: true }
: undefined,
user: { connect: { id: 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 { 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 { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
@ -23,7 +25,9 @@ import { AccountService } from './account.service';
ImpersonationModule,
PortfolioModule,
PrismaModule,
RedactValuesInResponseModule
RedactValuesInResponseModule,
TagModule,
UserModule
],
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 { 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 { 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 { Filter } from '@ghostfolio/common/interfaces';
import { AccountWithBalance } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
@ -13,11 +20,12 @@ import {
Order,
Platform,
Prisma,
SymbolProfile
SymbolProfile,
Tag
} from '@prisma/client';
import { Big } from 'big.js';
import { format } from 'date-fns';
import { groupBy } from 'lodash';
import { endOfToday, format } from 'date-fns';
import { groupBy, isNil } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface';
@ -27,17 +35,35 @@ export class AccountService {
private readonly accountBalanceService: AccountBalanceService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService
private readonly prismaService: PrismaService,
private readonly tagService: TagService
) {}
public async account({
id_userId
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
const [account] = await this.accounts({
where: id_userId
}: Prisma.AccountWhereUniqueInput): Promise<AccountWithBalance | null> {
const account = await this.prismaService.account.findUnique({
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(
@ -62,21 +88,40 @@ export class AccountService {
where?: Prisma.AccountWhereInput;
orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise<
(Account & {
(AccountWithBalance & {
activities?: (Order & { SymbolProfile?: SymbolProfile })[];
balances?: AccountBalance[];
platform?: Platform;
tags?: Tag[];
})[]
> {
const { include = {}, skip, take, cursor, where, orderBy } = params;
const isBalancesIncluded = !!include.balances;
const isTagsIncluded = !!include.tags;
include.balances = {
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({
cursor,
include,
@ -86,31 +131,72 @@ export class AccountService {
where
});
const endOfTodayDate = endOfToday();
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) {
delete account.balances;
delete result.balances;
}
return account;
if (!isTagsIncluded) {
delete result.tags;
}
return result;
});
}
public async createAccount(
data: Prisma.AccountCreateInput,
aUserId: string
): Promise<Account> {
public async createAccount({
balance,
data,
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({
data
data: {
...data,
tags: tagIds
? {
create: tagIds.map((tagId) => {
return {
tag: { connect: { id: tagId } }
};
})
}
: undefined
}
});
await this.accountBalanceService.createOrUpdateAccountBalance({
accountId: account.id,
balance: data.balance,
date: format(new Date(), DATE_FORMAT),
userId: aUserId
});
if (!isNil(balance)) {
await this.accountBalanceService.createOrUpdateAccountBalance({
balance,
userId,
accountId: account.id,
date: format(new Date(), DATE_FORMAT)
});
}
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
@ -139,11 +225,12 @@ export class AccountService {
return account;
}
public async getAccounts(aUserId: string): Promise<Account[]> {
public async getAccounts(aUserId: string): Promise<AccountWithBalance[]> {
const accounts = await this.accounts({
include: {
activities: true,
platform: true
platform: true,
tags: true
},
orderBy: { name: 'asc' },
where: { userId: aUserId }
@ -184,14 +271,14 @@ export class AccountService {
};
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;
});
if (filtersByAccount?.length > 0) {
if (filtersByAccount.length > 0) {
where.id = {
in: filtersByAccount.map(({ id }) => {
return id;
@ -217,27 +304,47 @@ export class AccountService {
};
}
public async updateAccount(
params: {
where: Prisma.AccountWhereUniqueInput;
data: Prisma.AccountUpdateInput;
},
aUserId: string
): Promise<Account> {
const { data, where } = params;
await this.accountBalanceService.createOrUpdateAccountBalance({
accountId: data.id as string,
balance: data.balance as number,
date: format(new Date(), DATE_FORMAT),
userId: aUserId
});
public async updateAccount({
balance,
data,
tagIds,
userId,
where
}: {
balance?: number;
data: Prisma.AccountUpdateInput;
tagIds?: string[];
userId: string;
where: Prisma.AccountWhereUniqueInput;
}): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId });
const account = await this.prismaService.account.update({
data,
data: {
...data,
tags: tagIds
? {
create: tagIds.map((tagId) => {
return {
tag: { connect: { id: tagId } }
};
}),
deleteMany: {}
}
: undefined
},
where
});
if (!isNil(balance)) {
await this.accountBalanceService.createOrUpdateAccountBalance({
balance,
userId,
accountId: account.id,
date: format(new Date(), DATE_FORMAT)
});
}
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
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 {
accounts: Account[];
accounts: AccountWithBalance[];
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
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@ -37,17 +37,15 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
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 { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ActivitiesFilterDto } from './activities-filter.dto';
import { ActivitiesService } from './activities.service';
import { GetActivitiesDto } from './get-activities.dto';
@Controller([
'activities',
/** @deprecated */
'order'
])
@Controller('activities')
export class ActivitiesController {
public constructor(
private readonly activitiesService: ActivitiesService,
@ -63,22 +61,47 @@ export class ActivitiesController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteActivities(
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query()
{
accounts,
activityTypes,
assetClasses,
dataSource,
range,
symbol,
tags
}: ActivitiesFilterDto
): 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({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
filterByAccounts: accounts,
filterByAssetClasses: assetClasses,
filterByDataSource: dataSource,
filterBySymbol: symbol,
filterByTags: tags
});
return this.activitiesService.deleteActivities({
endDate,
filters,
startDate,
types: activityTypes,
userId: this.request.user.id
});
}
@ -111,51 +134,54 @@ export class ActivitiesController {
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange?: DateRange,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('take') take?: number
@Query()
{
accounts,
activityTypes,
assetClasses,
dataSource,
range,
skip,
sortColumn,
sortDirection,
symbol,
tags,
take
}: GetActivitiesDto
): Promise<ActivitiesResponse> {
let endDate: Date;
let startDate: Date;
if (dateRange) {
({ endDate, startDate } = getIntervalFromDateRange({ dateRange }));
if (range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
filterByAccounts: accounts,
filterByAssetClasses: assetClasses,
filterByDataSource: dataSource,
filterBySymbol: symbol,
filterByTags: tags
});
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const types = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities, count } = await this.activitiesService.getActivities({
endDate,
filters,
skip,
sortColumn,
sortDirection,
startDate,
types,
take,
userCurrency,
includeDrafts: true,
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take,
types: activityTypes,
userId: impersonationUserId || this.request.user.id,
withExcludedAccountsAndActivities: true
});
@ -318,11 +344,13 @@ export class ActivitiesController {
data: {
...data,
date,
account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
account: accountId
? {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
}
: { disconnect: true },
SymbolProfile: {
connect: {
dataSource_symbol: {
@ -341,6 +369,7 @@ export class ActivitiesController {
}),
user: { connect: { id: this.request.user.id } }
},
userId: this.request.user.id,
where: {
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 { 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 { 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 { 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 { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.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';
@ -23,15 +26,18 @@ import { ActivitiesService } from './activities.service';
exports: [ActivitiesService],
imports: [
ApiModule,
BenchmarkModule,
CacheModule,
DataGatheringQueueModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
MarketDataModule,
PrismaModule,
RedactValuesInResponseModule,
RedisCacheModule,
SymbolProfileModule,
TagModule,
TransformDataSourceInRequestModule,
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 { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-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 { 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 { TagService } from '@ghostfolio/api/services/tag/tag.service';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
ghostfolioPrefix,
NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
canDeleteAssetProfile,
getAssetProfileIdentifier,
isValidCustomAssetProfileSymbol
} from '@ghostfolio/common/helper';
import {
ActivitiesResponse,
Activity,
AssetProfileIdentifier,
EnhancedSymbolProfile,
EnhancedAssetProfile,
Filter
} from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
@ -38,7 +49,6 @@ import {
Type as ActivityType
} from '@prisma/client';
import { Big } from 'big.js';
import { isUUID } from 'class-validator';
import { endOfToday, isAfter } from 'date-fns';
import { groupBy, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto';
@ -48,20 +58,67 @@ export class ActivitiesService {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
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({
dataSource,
symbol,
tags,
userId
}: { tags: Tag[]; userId: string } & AssetProfileIdentifier) {
await this.tagService.validateTagIds({
userId,
tagIds: tags.map(({ id }) => {
return id;
})
});
const activities = await this.prismaService.order.findMany({
where: {
userId,
@ -108,6 +165,15 @@ export class ActivitiesService {
userId: string;
}
): Promise<Order> {
const tags = data.tags ?? [];
await this.tagService.validateTagIds({
tagIds: tags.map(({ id }) => {
return id;
}),
userId: data.userId
});
let account: Prisma.AccountCreateNestedOneWithoutActivitiesInput;
if (data.accountId) {
@ -122,12 +188,11 @@ export class ActivitiesService {
}
const accountId = data.accountId;
const tags = data.tags ?? [];
const updateAccountBalance = data.updateAccountBalance ?? false;
const userId = data.userId;
if (
['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) ||
NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) ||
(data.SymbolProfile.connectOrCreate.create.dataSource === 'MANUAL' &&
data.type === 'BUY')
) {
@ -139,10 +204,9 @@ export class ActivitiesService {
let symbol: string;
if (
data.SymbolProfile.connectOrCreate.create.symbol.startsWith(
`${ghostfolioPrefix}_`
) ||
isUUID(data.SymbolProfile.connectOrCreate.create.symbol)
isValidCustomAssetProfileSymbol(
data.SymbolProfile.connectOrCreate.create.symbol
)
) {
// Connect custom asset profile (clone)
symbol = data.SymbolProfile.connectOrCreate.create.symbol;
@ -197,7 +261,7 @@ export class ActivitiesService {
const orderData: Prisma.OrderCreateInput = data;
const isDraft = ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type)
const isDraft = NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type)
? false
: isAfter(data.date as Date, endOfToday());
@ -213,7 +277,7 @@ export class ActivitiesService {
include: { SymbolProfile: true }
});
if (updateAccountBalance === true) {
if (accountId && updateAccountBalance === true) {
let amount = new Big(data.unitPrice).mul(data.quantity);
if (['BUY', 'FEE'].includes(data.type)) {
@ -262,7 +326,26 @@ export class ActivitiesService {
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);
}
@ -277,14 +360,23 @@ export class ActivitiesService {
}
public async deleteActivities({
endDate,
filters,
startDate,
types,
userId
}: {
endDate?: Date;
filters?: Filter[];
startDate?: Date;
types?: ActivityType[];
userId: string;
}): Promise<number> {
const { activities } = await this.getActivities({
endDate,
filters,
startDate,
types,
userId,
includeDrafts: true,
userCurrency: undefined,
@ -308,8 +400,31 @@ export class ActivitiesService {
})
);
for (const { activitiesCount, id } of symbolProfiles) {
if (activitiesCount === 0) {
const benchmarkAssetProfiles =
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);
}
}
@ -344,17 +459,7 @@ export class ActivitiesService {
userCurrency: string;
userId: string;
}): Promise<ActivitiesResponse> {
const filtersByAssetClass = filters.filter(({ type }) => {
return type === 'ASSET_CLASS';
});
if (
filtersByAssetClass.length > 0 &&
!filtersByAssetClass.find(({ id }) => {
return id === AssetClass.LIQUIDITY;
})
) {
// If asset class filters are present and none of them is liquidity, return an empty response
if (this.areCashActivitiesExcludedByFilters(filters)) {
return {
activities: [],
count: 0
@ -362,6 +467,7 @@ export class ActivitiesService {
}
const activities: Activity[] = [];
const endOfTodayDate = endOfToday();
for (const account of cashDetails.accounts) {
const { balances } = await this.accountBalanceService.getAccountBalances({
@ -374,21 +480,20 @@ export class ActivitiesService {
let currentBalanceInBaseCurrency = 0;
for (const balanceItem of balances) {
if (
isAccountBalanceInFuture({
endOfTodayDate,
date: balanceItem.date
})
) {
continue;
}
const syntheticActivityTemplate: Activity = {
userId,
accountId: account.id,
accountUserId: account.userId,
comment: account.name,
createdAt: new Date(balanceItem.date),
currency: account.currency,
date: new Date(balanceItem.date),
fee: 0,
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
id: balanceItem.id,
isDraft: false,
quantity: 1,
SymbolProfile: {
assetProfile: {
activitiesCount: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
@ -405,6 +510,16 @@ export class ActivitiesService {
symbol: account.currency,
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,
type: ActivityType.BUY,
unitPrice: 1,
@ -492,41 +607,29 @@ export class ActivitiesService {
{ date: 'asc' }
];
const where: Prisma.OrderWhereInput = { userId };
const andConditions: Prisma.OrderWhereInput[] = [];
const where: Prisma.OrderWhereInput = { userId, AND: andConditions };
if (endDate || startDate) {
where.AND = [];
if (endDate) {
where.AND.push({ date: { lte: endDate } });
}
if (endDate) {
andConditions.push({ date: { lte: endDate } });
}
if (startDate) {
where.AND.push({ date: { gt: startDate } });
}
if (startDate) {
andConditions.push({ date: { gt: startDate } });
}
const {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag
ACCOUNT: filtersByAccount = [],
ASSET_CLASS: filtersByAssetClass = [],
DATA_SOURCE: [filterByDataSource] = [],
SEARCH_QUERY: [filterBySearchQuery] = [],
SYMBOL: [filterBySymbol] = [],
TAG: filtersByTag = []
} = groupBy(filters, ({ type }) => {
return type;
});
const filterByDataSource = filters?.find(({ type }) => {
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) {
if (filtersByAccount.length > 0) {
where.accountId = {
in: filtersByAccount.map(({ id }) => {
return id;
@ -538,7 +641,7 @@ export class ActivitiesService {
where.isDraft = false;
}
if (filtersByAssetClass?.length > 0) {
if (filtersByAssetClass.length > 0) {
where.SymbolProfile = {
OR: [
{
@ -550,14 +653,14 @@ export class ActivitiesService {
},
{
OR: [
{ SymbolProfileOverrides: { is: null } },
{ SymbolProfileOverrides: { assetClass: null } }
{ assetProfileOverrides: { is: null } },
{ assetProfileOverrides: { assetClass: null } }
]
}
]
},
{
SymbolProfileOverrides: {
assetProfileOverrides: {
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
@ -574,8 +677,8 @@ export class ActivitiesService {
where.SymbolProfile,
{
AND: [
{ dataSource: filterByDataSource as DataSource },
{ symbol: filterBySymbol }
{ dataSource: filterByDataSource.id as DataSource },
{ symbol: filterBySymbol.id }
]
}
]
@ -583,19 +686,19 @@ export class ActivitiesService {
} else {
where.SymbolProfile = {
AND: [
{ dataSource: filterByDataSource as DataSource },
{ symbol: filterBySymbol }
{ dataSource: filterByDataSource.id as DataSource },
{ symbol: filterBySymbol.id }
]
};
}
}
if (searchQuery) {
if (filterBySearchQuery) {
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [
{ id: { mode: 'insensitive', startsWith: searchQuery } },
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
{ 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 (where.SymbolProfile) {
@ -614,14 +717,31 @@ export class ActivitiesService {
}
}
if (filtersByTag?.length > 0) {
where.tags = {
some: {
OR: filtersByTag.map(({ id }) => {
return { id };
})
}
};
if (filtersByTag.length > 0) {
andConditions.push({
OR: [
{
tags: {
some: {
OR: filtersByTag.map(({ id }) => {
return { id };
})
}
}
},
{
account: {
tags: {
some: {
OR: filtersByTag.map(({ id }) => {
return { tagId: id };
})
}
}
}
}
]
});
}
if (sortColumn) {
@ -633,13 +753,9 @@ export class ActivitiesService {
}
if (withExcludedAccountsAndActivities === false) {
where.OR = [
{ account: null },
{ account: { NOT: { isExcluded: true } } }
];
where.OR = [{ account: null }, { account: WHERE_ACCOUNT_NOT_EXCLUDED }];
where.tags = {
...where.tags,
none: {
id: TAG_ID_EXCLUDE_FROM_ANALYSIS
}
@ -654,7 +770,12 @@ export class ActivitiesService {
include: {
account: {
include: {
platform: true
platform: true,
tags: {
include: {
tag: true
}
}
}
},
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -666,6 +787,16 @@ export class ActivitiesService {
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(
orders.map(({ SymbolProfile }) => {
return {
@ -697,10 +828,10 @@ export class ActivitiesService {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
const [
feeInAssetProfileCurrency,
feeInBaseCurrency,
unitPriceInAssetProfileCurrency,
valueInBaseCurrency
feeInAssetProfileCurrency = 0,
feeInBaseCurrency = 0,
unitPriceInAssetProfileCurrency = 0,
valueInBaseCurrency = 0
] = await Promise.all([
this.exchangeRateDataService.toCurrencyAtDate(
order.fee,
@ -730,12 +861,12 @@ export class ActivitiesService {
return {
...order,
assetProfile,
feeInAssetProfileCurrency,
feeInBaseCurrency,
unitPriceInAssetProfileCurrency,
value,
valueInBaseCurrency,
SymbolProfile: assetProfile
valueInBaseCurrency
};
})
);
@ -770,7 +901,7 @@ export class ActivitiesService {
withExcludedAccountsAndActivities: false // TODO
});
if (withCash) {
if (withCash && !this.areCashActivitiesExcludedByFilters(filters)) {
const cashDetails = await this.accountService.getCashDetails({
filters,
userId,
@ -792,10 +923,10 @@ export class ActivitiesService {
}
public async getStatisticsByCurrency(
currency: EnhancedSymbolProfile['currency']
currency: EnhancedAssetProfile['currency']
): Promise<{
activitiesCount: EnhancedSymbolProfile['activitiesCount'];
dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
activitiesCount: EnhancedAssetProfile['activitiesCount'];
dateOfFirstActivity: EnhancedAssetProfile['dateOfFirstActivity'];
}> {
const { _count, _min } = await this.prismaService.order.aggregate({
_count: true,
@ -821,6 +952,7 @@ export class ActivitiesService {
public async updateActivity({
data,
userId,
where
}: {
data: Prisma.OrderUpdateInput & {
@ -831,25 +963,29 @@ export class ActivitiesService {
tags?: { id: string }[];
type?: ActivityType;
};
userId: string;
where: Prisma.OrderWhereUniqueInput;
}): Promise<Order> {
const tags = data.tags ?? [];
await this.tagService.validateTagIds({
userId,
tagIds: tags.map(({ id }) => {
return id;
})
});
if (!data.comment) {
data.comment = null;
}
const tags = data.tags ?? [];
let isDraft = false;
if (
['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) ||
NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) ||
(data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' &&
data.type === 'BUY')
) {
if (data.account?.connect?.id_userId?.id === null) {
data.account = { disconnect: true };
}
delete data.SymbolProfile.connect;
delete data.SymbolProfile.update.name;
} else {
@ -878,19 +1014,13 @@ export class ActivitiesService {
delete data.symbol;
delete data.tags;
// Remove existing tags
await this.prismaService.order.update({
where,
data: { tags: { set: [] } }
});
const activity = await this.prismaService.order.update({
where,
data: {
...data,
isDraft,
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 { 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 { 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 { DemoService } from '@ghostfolio/api/services/demo/demo.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 {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
@ -16,19 +17,21 @@ import {
UpdateAssetProfileDto,
UpdatePropertyDto
} from '@ghostfolio/common/dtos';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
canDeleteAssetProfile,
getAssetProfileIdentifier
} from '@ghostfolio/common/helper';
import {
AdminData,
AdminMarketData,
AdminUserResponse,
AdminUsersResponse,
EnhancedSymbolProfile,
EnhancedAssetProfile,
ScraperConfiguration
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type {
DateRange,
MarketDataPreset,
PropertyKey,
RequestWithUser
} from '@ghostfolio/common/types';
@ -41,6 +44,7 @@ import {
Inject,
Logger,
Param,
ParseIntPipe,
Patch,
Post,
Put,
@ -55,16 +59,20 @@ import { isDate, parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service';
import { PropertyKeyPipe } from './pipes/property-key.pipe';
@Controller('admin')
export class AdminController {
private readonly logger = new Logger(AdminController.name);
public constructor(
private readonly adminService: AdminService,
private readonly apiService: ApiService,
private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService,
private readonly demoService: DemoService,
private readonly manualService: ManualService,
@Inject(REQUEST) private readonly request: RequestWithUser
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly symbolProfileService: SymbolProfileService
) {}
@Get()
@ -84,8 +92,8 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl)
@Post('gather')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gather7Days(): Promise<void> {
this.dataGatheringService.gather7Days();
public async gatherRecentMarketData(): Promise<void> {
this.dataGatheringService.gatherRecentMarketData();
}
@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)
@Post('market-data/:dataSource/:symbol/test')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -260,7 +239,7 @@ export class AdminController {
`Could not parse the market price for ${symbol} (${dataSource})`
);
} catch (error) {
Logger.error(error, 'AdminController');
this.logger.error(error);
throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
}
@ -288,6 +267,33 @@ export class AdminController {
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): 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 });
}
@ -298,7 +304,7 @@ export class AdminController {
@Body() assetProfile: UpdateAssetProfileDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<EnhancedSymbolProfile> {
): Promise<EnhancedAssetProfile> {
return this.adminService.patchAssetProfileData(
{ dataSource, symbol },
assetProfile
@ -309,7 +315,7 @@ export class AdminController {
@Put('settings/:key')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateProperty(
@Param('key') key: string,
@Param('key', PropertyKeyPipe) key: PropertyKey,
@Body() data: UpdatePropertyDto
) {
return this.adminService.putSetting(key, data.value);
@ -319,12 +325,12 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getUsers(
@Query('skip') skip?: number,
@Query('take') take?: number
@Query('skip', new ParseIntPipe({ optional: true })) skip?: number,
@Query('take', new ParseIntPipe({ optional: true })) take?: number
): Promise<AdminUsersResponse> {
return this.adminService.getUsers({
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take
skip,
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 { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -20,8 +18,6 @@ import { QueueModule } from './queue/queue.module';
@Module({
imports: [
ActivitiesModule,
ApiModule,
BenchmarkModule,
ConfigurationModule,
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 { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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';
@ -9,28 +7,24 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
ghostfolioPrefix,
PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config';
import {
applyAssetProfileOverrides,
getAssetProfileIdentifier,
getCurrencyFromSymbol,
isCurrency
hasGhostfolioPrefix
} from '@ghostfolio/common/helper';
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
AdminUserResponse,
AdminUsersResponse,
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter
AssetProfileIdentifier
} from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { MarketDataPreset } from '@ghostfolio/common/types';
import { PropertyKey } from '@ghostfolio/common/types';
import {
BadRequestException,
@ -48,13 +42,11 @@ import {
} from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { groupBy } from 'lodash';
import { randomUUID } from 'node:crypto';
@Injectable()
export class AdminService {
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
@ -73,6 +65,12 @@ export class AdminService {
> {
try {
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({
currency,
dataSource,
@ -84,14 +82,17 @@ export class AdminService {
{ dataSource, symbol }
]);
if (!assetProfiles[symbol]?.currency) {
const assetProfile =
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })];
if (!assetProfile?.currency) {
throw new BadRequestException(
`Asset profile not found for ${symbol} (${dataSource})`
);
}
return this.symbolProfileService.add(
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
assetProfile as Prisma.SymbolProfileCreateInput
);
} catch (error) {
if (
@ -114,8 +115,11 @@ export class AdminService {
await this.marketDataService.deleteMany({ dataSource, 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)) {
const updatedCustomCurrencies = customCurrencies.filter(
@ -188,332 +192,24 @@ export class AdminService {
};
}
public async getMarketData({
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<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;
})
}
}
public async getUser(id: string): Promise<AdminUserResponse> {
const [user] = await this.getUsersWithAnalytics({
where: { id }
});
const lastMarketPriceMap = new Map<string, number>();
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));
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
const [[assetProfile], marketData] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]),
this.marketDataService.marketDataItems({
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscriptions = await this.prismaService.subscription.findMany({
orderBy: {
date: 'asc'
expiresAt: 'desc'
},
where: {
dataSource,
symbol
userId: id
}
})
]);
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;
@ -545,6 +241,7 @@ export class AdminService {
comment,
countries,
currency,
dataGatheringFrequency,
dataSource: newDataSource,
holdings,
isActive,
@ -556,16 +253,25 @@ export class AdminService {
url
}: Prisma.SymbolProfileUpdateInput
) {
const isConversionToManualDataSource =
newDataSource === DataSource.MANUAL && dataSource !== DataSource.MANUAL;
if (isConversionToManualDataSource && !newSymbol) {
newSymbol = randomUUID();
}
if (
newSymbol &&
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([
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
newAssetProfileIdentifier
]);
if (assetProfile) {
@ -575,47 +281,85 @@ export class AdminService {
);
}
try {
Promise.all([
await this.symbolProfileService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
const operations: Prisma.PrismaPromise<unknown>[] = [
this.symbolProfileService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
newAssetProfileIdentifier
),
this.marketDataService.updateAssetProfileIdentifier(
{
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,
symbol
},
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
assetClass: currentAssetProfileWithOverrides.assetClass,
assetSubClass: currentAssetProfileWithOverrides.assetSubClass,
countries:
currentAssetProfileWithOverrides.countries ?? undefined,
holdings: currentAssetProfileWithOverrides.holdings ?? undefined,
name: currentAssetProfileWithOverrides.name,
sectors: currentAssetProfileWithOverrides.sectors ?? undefined,
url: currentAssetProfileWithOverrides.url
}
)
]);
);
}
return this.symbolProfileService.getSymbolProfiles([
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
])?.[0];
try {
await this.prismaService.$transaction(operations);
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
const [updatedAssetProfile] =
await this.symbolProfileService.getSymbolProfiles([
newAssetProfileIdentifier
]);
return updatedAssetProfile;
} else {
const symbolProfileOverrides = {
const assetProfileOverrides = {
assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass,
countries: countries as Prisma.JsonArray,
holdings: holdings as Prisma.JsonArray,
name: name as string,
sectors: sectors as Prisma.JsonArray,
url: url as string
@ -624,22 +368,16 @@ export class AdminService {
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = {
comment,
currency,
dataGatheringFrequency,
dataSource,
holdings,
isActive,
scraperConfiguration,
symbol,
symbolMapping,
...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, countries, name, sectors, url }
: {
SymbolProfileOverrides: {
upsert: {
create: symbolProfileOverrides,
update: symbolProfileOverrides
}
}
})
...this.symbolProfileService.getAssetProfileUpdateInput(
{ dataSource, symbol },
assetProfileOverrides
)
};
await this.symbolProfileService.updateSymbolProfile(
@ -650,22 +388,30 @@ export class AdminService {
updatedSymbolProfile
);
return this.symbolProfileService.getSymbolProfiles([
{
dataSource: dataSource as DataSource,
symbol: symbol as string
}
])?.[0];
const [updatedAssetProfile] =
await this.symbolProfileService.getSymbolProfiles([
{
dataSource: dataSource as DataSource,
symbol: symbol as string
}
]);
return updatedAssetProfile;
}
}
public async putSetting(key: string, value: string) {
public async putSetting(key: PropertyKey, value: string) {
let response: Property;
if (value) {
response = await this.propertyService.put({ key, value });
response = await this.propertyService.put({
key,
value
});
} else {
response = await this.propertyService.delete({ key });
response = await this.propertyService.delete({
key
});
}
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({
skip,
take,
@ -876,7 +490,7 @@ export class AdminService {
activityCount: true,
country: true,
dataProviderGhostfolioDailyRequests: true,
updatedAt: true
lastRequestAt: true
}
},
createdAt: true,
@ -922,7 +536,7 @@ export class AdminService {
activityCount: _count.activities || 0,
country: analytics?.country,
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({
limit = 1000,
limit = 5000,
status = QUEUE_JOB_STATUS_LIST
}: {
limit?: number;

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

@ -1,7 +1,10 @@
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 { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 {
BULL_BOARD_ROUTE,
DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES
THROTTLE_DEFAULT_LIMIT,
THROTTLE_DEFAULT_TTL
} from '@ghostfolio/common/config';
import { ExpressAdapter } from '@bull-board/express';
import { BullBoardModule } from '@bull-board/nestjs';
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
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 { AccessModule } from './access/access.module';
@ -38,6 +44,7 @@ import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module';
import { AiModule } from './endpoints/ai/ai.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 { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
@ -69,6 +76,7 @@ import { UserModule } from './user/user.module';
ActivitiesModule,
AiModule,
ApiKeysModule,
AssetProfilesModule,
AssetModule,
AssetsModule,
AuthDeviceModule,
@ -93,12 +101,13 @@ import { UserModule } from './user/user.module';
middleware: BullBoardAuthMiddleware,
route: BULL_BOARD_ROUTE
}),
BullModule.forRoot({
redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10),
host: process.env.REDIS_HOST,
password: process.env.REDIS_PASSWORD,
port: parseInt(process.env.REDIS_PORT ?? '6379', 10)
BullModule.forRootAsync({
imports: [ConfigurationModule],
inject: [ConfigurationService],
useFactory: (configurationService: ConfigurationService) => {
return {
redis: getRedisConnectionOptions(configurationService)
};
}
}),
CacheModule,
@ -134,27 +143,7 @@ import { UserModule } from './user/user.module';
'/api/*wildcard',
'/sitemap.xml'
],
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;
}
}
}
rootPath: join(__dirname, '..', 'client')
}),
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'client', '.well-known'),
@ -164,10 +153,46 @@ import { UserModule } from './user/user.module';
SubscriptionModule,
SymbolModule,
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,
WatchlistModule
],
providers: [I18nService]
providers: [
I18nService,
{
provide: APP_FILTER,
useClass: PortfolioSnapshotComputationExceptionFilter
}
]
})
export class AppModule implements NestModule {
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 { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import type { AssetResponse } from '@ghostfolio/common/interfaces';
@ -9,7 +9,9 @@ import { pick } from 'lodash';
@Controller('asset')
export class AssetController {
public constructor(private readonly adminService: AdminService) {}
public constructor(
private readonly assetProfilesService: AssetProfilesService
) {}
@Get(':dataSource/:symbol')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@ -18,11 +20,15 @@ export class AssetController {
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AssetResponse> {
const { assetProfile, marketData } =
await this.adminService.getMarketDataBySymbol({ dataSource, symbol });
const { assetProfile, marketData, splits } =
await this.assetProfilesService.getAssetProfile({
dataSource,
symbol
});
return {
marketData,
splits,
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 { 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({
controllers: [AssetController],
imports: [
AdminModule,
AssetProfilesModule,
TransformDataSourceInRequestModule,
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({
create: { user: { connect: { id: user.id } } },
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 { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
@ -13,7 +14,6 @@ import {
Controller,
Get,
HttpException,
Param,
Post,
Req,
Res,
@ -35,26 +35,8 @@ export class AuthController {
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')
@UseGuards(CustomThrottlerGuard)
public async accessTokenLogin(
@Body() body: { accessToken: string }
): Promise<OAuthResponse> {
@ -135,6 +117,7 @@ export class AuthController {
}
@Post('webauthn/generate-authentication-options')
@UseGuards(CustomThrottlerGuard)
public async generateAuthenticationOptions(
@Body() body: { deviceId: string }
) {
@ -156,6 +139,7 @@ export class AuthController {
}
@Post('webauthn/verify-authentication')
@UseGuards(CustomThrottlerGuard)
public async verifyAuthentication(
@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 { 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 { UserModule } from '@ghostfolio/api/app/user/user.module';
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 { 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 { 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 { JwtModule } from '@nestjs/jwt';
@ -22,13 +27,17 @@ import { OidcStrategy } from './oidc.strategy';
@Module({
controllers: [AuthController],
imports: [
ApiModule,
ConfigurationModule,
FetchModule,
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' }
}),
PortfolioSnapshotQueueModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SubscriptionModule,
UserModule
],
@ -40,12 +49,15 @@ import { OidcStrategy } from './oidc.strategy';
GoogleStrategy,
JwtStrategy,
{
inject: [AuthService, ConfigurationService],
inject: [AuthService, ConfigurationService, FetchService],
provide: OidcStrategy,
useFactory: async (
authService: AuthService,
configurationService: ConfigurationService
configurationService: ConfigurationService,
fetchService: FetchService
) => {
const logger = new Logger('OidcStrategy');
const isOidcEnabled = configurationService.get(
'ENABLE_FEATURE_AUTH_OIDC'
);
@ -81,7 +93,7 @@ import { OidcStrategy } from './oidc.strategy';
} else {
// Fetch OIDC configuration from discovery endpoint
try {
const response = await fetch(
const response = await fetchService.fetch(
`${issuer}/.well-known/openid-configuration`
);
@ -97,7 +109,7 @@ import { OidcStrategy } from './oidc.strategy';
tokenURL = manualTokenUrl || config.token_endpoint;
userInfoURL = manualUserInfoUrl || config.userinfo_endpoint;
} catch (error) {
Logger.error(error, 'OidcStrategy');
logger.error(error);
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()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
private readonly logger = new Logger(GoogleStrategy.name);
public constructor(
private readonly authService: AuthService,
configurationService: ConfigurationService
@ -40,7 +42,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
done(null, { jwt });
} catch (error) {
Logger.error(error, 'GoogleStrategy');
this.logger.error(error);
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 =
countriesAndTimezones.getCountryForTimezone(timezone)?.id;

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

@ -15,6 +15,8 @@ import { OidcStateStore } from './oidc-state.store';
@Injectable()
export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
private readonly logger = new Logger(OidcStrategy.name);
private static readonly stateStore = new OidcStateStore();
public constructor(
@ -52,9 +54,8 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
});
if (!thirdPartyId) {
Logger.error(
`Missing subject identifier in OIDC response from ${issuer}`,
'OidcStrategy'
this.logger.error(
`Missing subject identifier in OIDC response from ${issuer}`
);
throw new Error('Missing subject identifier in OIDC response');
@ -62,7 +63,7 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
return { jwt };
} catch (error) {
Logger.error(error, 'OidcStrategy');
this.logger.error(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 { 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 { ApiService } from '@ghostfolio/api/services/api/api.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 {
AssertionCredentialJSON,
@ -29,14 +38,20 @@ import {
VerifyRegistrationResponseOpts
} from '@simplewebauthn/server';
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
import { isPast } from 'date-fns';
import ms from 'ms';
@Injectable()
export class WebAuthService {
private readonly logger = new Logger(WebAuthService.name);
public constructor(
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly deviceService: AuthDeviceService,
private readonly jwtService: JwtService,
private readonly portfolioSnapshotService: PortfolioSnapshotService,
private readonly redisCacheService: RedisCacheService,
private readonly userService: UserService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@ -103,7 +118,7 @@ export class WebAuthService {
verification = await verifyRegistrationResponse(opts);
} catch (error) {
Logger.error(error, 'WebAuthService');
this.logger.error(error);
throw new InternalServerErrorException(error.message);
}
@ -153,6 +168,9 @@ export class WebAuthService {
throw new Error('Device not found');
}
// Compute in the background during the biometric authentication
void this.warmUpPortfolioSnapshot({ userId: device.userId });
const opts: GenerateAuthenticationOptionsOpts = {
allowCredentials: [],
rpID: this.rpID,
@ -210,7 +228,7 @@ export class WebAuthService {
verification = await verifyAuthenticationResponse(opts);
} catch (error) {
Logger.error(error, 'WebAuthService');
this.logger.error(error);
throw new InternalServerErrorException({ error: error.message });
}
@ -231,4 +249,57 @@ export class WebAuthService {
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 { FilterDto } from '@ghostfolio/api/dtos/filter.dto';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { AiPromptResponse } from '@ghostfolio/common/interfaces';
@ -31,18 +32,15 @@ export class AiController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPrompt(
@Param('mode') mode: AiPromptMode,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
@Query()
{ accounts, assetClasses, dataSource, symbol, tags }: FilterDto
): Promise<AiPromptResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
filterByAccounts: accounts,
filterByAssetClasses: assetClasses,
filterByDataSource: dataSource,
filterBySymbol: symbol,
filterByTags: tags
});
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 { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.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';
@ -44,6 +45,7 @@ import { AiService } from './ai.service';
PropertyModule,
RedisCacheModule,
SymbolProfileModule,
TagModule,
UserModule
],
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
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@ -34,6 +34,7 @@ import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { BenchmarksService } from './benchmarks.service';
import { GetBenchmarkMarketDataDto } from './get-benchmark-market-data.dto';
@Controller('benchmarks')
export class BenchmarksController {
@ -118,38 +119,39 @@ export class BenchmarksController {
@Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string,
@Param('symbol') symbol: string,
@Query('range') dateRange: DateRange = 'max',
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
@Query()
{
accounts,
assetClasses,
dataSource: filterByDataSource,
range,
symbol: filterBySymbol,
tags,
withExcludedAccounts
}: GetBenchmarkMarketDataDto
): Promise<BenchmarkMarketDataDetailsResponse> {
const { endDate, startDate } = getIntervalFromDateRange({
dateRange,
dateRange: range,
startDate: new Date(startDateString)
});
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
filterByAccounts: accounts,
filterByAssetClasses: assetClasses,
filterByTags: tags
});
const withExcludedAccounts = withExcludedAccountsParam === 'true';
return this.benchmarksService.getMarketDataForUser({
dataSource,
dateRange,
endDate,
filters,
impersonationId,
startDate,
symbol,
withExcludedAccounts,
dateRange: range,
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 { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.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';
@ -46,6 +47,7 @@ import { BenchmarksService } from './benchmarks.service';
RedisCacheModule,
SymbolModule,
SymbolProfileModule,
TagModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule,
UserModule

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

@ -17,6 +17,8 @@ import { isNumber } from 'lodash';
@Injectable()
export class BenchmarksService {
private readonly logger = new Logger(BenchmarksService.name);
public constructor(
private readonly benchmarkService: BenchmarkService,
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 =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
startDate,
@ -96,12 +106,11 @@ export class BenchmarksService {
})?.marketPrice;
if (!marketPriceAtStartDate) {
Logger.error(
this.logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
startDate,
DATE_FORMAT
)}`,
'BenchmarkService'
)}`
);
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,
HistoricalResponse,
LookupResponse,
MarketDataOfMarketsResponse,
QuotesResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
@ -19,6 +20,7 @@ import {
HttpException,
Inject,
Param,
ParseIntPipe,
Query,
UseGuards,
Version
@ -49,7 +51,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -88,12 +90,12 @@ export class GhostfolioController {
@Version('2')
public async getDividends(
@Param('symbol') symbol: string,
@Query() query: GetDividendsDto
@Query() { from, granularity, to }: GetDividendsDto
): Promise<DividendsResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -103,10 +105,10 @@ export class GhostfolioController {
try {
const dividends = await this.ghostfolioService.getDividends({
granularity,
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
from: parseDate(from),
to: parseDate(to)
});
await this.ghostfolioService.incrementDailyRequests({
@ -128,12 +130,12 @@ export class GhostfolioController {
@Version('2')
public async getHistorical(
@Param('symbol') symbol: string,
@Query() query: GetHistoricalDto
@Query() { from, granularity, to }: GetHistoricalDto
): Promise<HistoricalResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -143,10 +145,10 @@ export class GhostfolioController {
try {
const historicalData = await this.ghostfolioService.getHistorical({
granularity,
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
from: parseDate(from),
to: parseDate(to)
});
await this.ghostfolioService.incrementDailyRequests({
@ -174,7 +176,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) {
throw new HttpException(
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')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async getQuotes(
@Query() query: GetQuotesDto
@Query() { symbols }: GetQuotesDto
): Promise<QuotesResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -223,7 +262,7 @@ export class GhostfolioController {
try {
const quotes = await this.ghostfolioService.getQuotes({
symbols: query.symbols
symbols
});
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 { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
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 { 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 { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
@ -27,10 +29,12 @@ import { GhostfolioService } from './ghostfolio.service';
imports: [
CryptocurrencyModule,
DataProviderModule,
FetchModule,
MarketDataModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolModule,
SymbolProfileModule
],
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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service';
@ -8,6 +9,7 @@ import {
GetQuotesParams,
GetSearchParams
} 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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
@ -15,6 +17,10 @@ import {
DERIVED_CURRENCIES
} 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 {
DataProviderGhostfolioAssetProfileResponse,
DataProviderHistoricalResponse,
@ -23,6 +29,7 @@ import {
HistoricalResponse,
LookupItem,
LookupResponse,
MarketDataOfMarketsResponse,
QuotesResponse
} from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
@ -30,14 +37,19 @@ import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import { isEmpty } from 'lodash';
@Injectable()
export class GhostfolioService {
private readonly logger = new Logger(GhostfolioService.name);
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly fetchService: FetchService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
private readonly propertyService: PropertyService,
private readonly symbolService: SymbolService
) {}
public async getAssetProfile({ symbol }: GetAssetProfileParams) {
@ -56,7 +68,13 @@ export class GhostfolioService {
}
])
.then(async (assetProfiles) => {
const assetProfile = assetProfiles[symbol];
const assetProfile =
assetProfiles[
getAssetProfileIdentifier({
symbol,
dataSource: dataProviderService.getName()
})
];
const dataSourceOrigin = DataSource.GHOSTFOLIO;
if (assetProfile) {
@ -97,7 +115,7 @@ export class GhostfolioService {
return result;
} catch (error) {
Logger.error(error, 'GhostfolioService');
this.logger.error(error);
throw error;
}
@ -139,7 +157,7 @@ export class GhostfolioService {
return result;
} catch (error) {
Logger.error(error, 'GhostfolioService');
this.logger.error(error);
throw error;
}
@ -156,7 +174,7 @@ export class GhostfolioService {
try {
const promises: Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
[date: string]: DataProviderHistoricalResponse;
}>[] = [];
for (const dataProviderService of this.getDataProviderServices()) {
@ -170,7 +188,7 @@ export class GhostfolioService {
to
})
.then((historicalData) => {
result.historicalData = historicalData[symbol];
result.historicalData = historicalData;
return historicalData;
})
@ -181,7 +199,34 @@ export class GhostfolioService {
return result;
} 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;
}
@ -269,7 +314,7 @@ export class GhostfolioService {
return results;
} catch (error) {
Logger.error(error, 'GhostfolioService');
this.logger.error(error);
throw error;
}
@ -284,8 +329,12 @@ export class GhostfolioService {
}
public async incrementDailyRequests({ userId }: { userId: string }) {
await this.prismaService.analytics.update({
data: {
await this.prismaService.analytics.upsert({
create: {
dataProviderGhostfolioDailyRequests: 1,
user: { connect: { id: userId } }
},
update: {
dataProviderGhostfolioDailyRequests: { increment: 1 }
},
where: { userId }
@ -298,7 +347,9 @@ export class GhostfolioService {
}: GetSearchParams): Promise<LookupResponse> {
const results: LookupResponse = { items: [] };
if (!query) {
query = query?.trim();
if (!isValidSearchQuery(query)) {
return results;
}
@ -306,10 +357,6 @@ export class GhostfolioService {
let lookupItems: LookupItem[] = [];
const promises: Promise<{ items: LookupItem[] }>[] = [];
if (query?.length < 2) {
return { items: lookupItems };
}
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
dataProviderService.search({
@ -346,7 +393,7 @@ export class GhostfolioService {
return results;
} catch (error) {
Logger.error(error, 'GhostfolioService');
this.logger.error(error);
throw error;
}
@ -355,6 +402,7 @@ export class GhostfolioService {
private getDataProviderInfo(): DataProviderInfo {
const ghostfolioDataProviderService = new GhostfolioDataProviderService(
this.configurationService,
this.fetchService,
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
ghostfolioFearAndGreedIndexDataSourceStocks,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks
} from '@ghostfolio/common/config';
import { UpdateBulkMarketDataDto } from '@ghostfolio/common/dtos';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import {
MarketDataDetailsResponse,
MarketDataOfMarketsResponse
} from '@ghostfolio/common/interfaces';
import { MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
@ -28,10 +16,10 @@ import {
HttpException,
Inject,
Param,
ParseIntPipe,
Post,
Query,
UseGuards,
UseInterceptors
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -42,7 +30,6 @@ import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@Controller('market-data')
export class MarketDataController {
public constructor(
private readonly adminService: AdminService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly symbolProfileService: SymbolProfileService,
@ -53,81 +40,12 @@ export class MarketDataController {
@HasPermission(permissions.readMarketDataOfMarkets)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketDataOfMarkets(
@Query('includeHistoricalData') includeHistoricalData = 0
@Query('includeHistoricalData', new ParseIntPipe({ optional: true }))
includeHistoricalData = 0
): Promise<MarketDataOfMarketsResponse> {
const [
marketDataFearAndGreedIndexCryptocurrencies,
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 });
return this.symbolService.getMarketDataOfMarkets({
includeHistoricalData
});
}
@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 { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -11,13 +8,6 @@ import { MarketDataController } from './market-data.controller';
@Module({
controllers: [MarketDataController],
imports: [
AdminModule,
MarketDataServiceModule,
SymbolModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
]
imports: [MarketDataServiceModule, SymbolModule, SymbolProfileModule]
})
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 { SubscriptionType } from '@ghostfolio/common/enums';
import { getSum } from '@ghostfolio/common/helper';
import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
AccessSettings,
PublicPortfolioResponse
} from '@ghostfolio/common/interfaces';
import {
Controller,
Get,
HttpException,
Inject,
Param,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Type as ActivityType } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
Type as ActivityType
} from '@prisma/client';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -33,7 +37,6 @@ export class PublicController {
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@ -43,7 +46,10 @@ export class PublicController {
public async getPublicPortfolio(
@Param('accessId') accessId: string
): Promise<PublicPortfolioResponse> {
const access = await this.accessService.access({ id: accessId });
const access = await this.accessService.access({
granteeUserId: null,
id: accessId
});
if (!access) {
throw new HttpException(
@ -59,9 +65,11 @@ export class PublicController {
});
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 [
{ createdAt, holdings, markets },
{ performance: performance1d },
@ -69,6 +77,7 @@ export class PublicController {
{ performance: performanceYtd }
] = await Promise.all([
this.portfolioService.getDetails({
filters,
impersonationId: access.userId,
userId: user.id,
withMarkets: true
@ -76,6 +85,7 @@ export class PublicController {
...['1d', 'max', 'ytd'].map((dateRange) => {
return this.portfolioService.getPerformance({
dateRange,
filters,
impersonationId: undefined,
userId: user.id
});
@ -83,11 +93,12 @@ export class PublicController {
]);
const { activities } = await this.activitiesService.getActivities({
filters,
sortColumn: 'date',
sortDirection: 'desc',
take: 10,
types: [ActivityType.BUY, ActivityType.SELL],
userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY,
userCurrency: user?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY,
userId: user.id,
withExcludedAccountsAndActivities: false
});
@ -99,22 +110,22 @@ export class PublicController {
? []
: activities.map(
({
assetProfile,
currency,
date,
fee,
quantity,
SymbolProfile,
type,
unitPrice,
value,
valueInBaseCurrency
}) => {
return {
assetProfile,
currency,
date,
fee,
quantity,
SymbolProfile,
type,
unitPrice,
value,
@ -156,8 +167,7 @@ export class PublicController {
this.exchangeRateDataService.toCurrency(
quantity * marketPrice,
assetProfile.currency,
this.request.user?.settings?.settings.baseCurrency ??
DEFAULT_CURRENCY
user?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY
)
);
})
@ -167,19 +177,46 @@ export class PublicController {
publicPortfolioResponse.holdings[symbol] = {
allocationInPercentage:
portfolioPosition.valueInBaseCurrency / totalValue,
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
assetProfile: hasDetails ? portfolioPosition.assetProfile : undefined,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
dataSource: portfolioPosition.dataSource,
assetProfile: {
...portfolioPosition.assetProfile,
assetClass:
hasDetails ||
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,
markets: hasDetails ? portfolioPosition.markets : undefined,
name: portfolioPosition.name,
netPerformancePercentWithCurrencyEffect:
portfolioPosition.netPerformancePercentWithCurrencyEffect,
sectors: hasDetails ? portfolioPosition.sectors : [],
symbol: portfolioPosition.symbol,
url: portfolioPosition.url,
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 { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.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';
@ -38,6 +39,7 @@ import { PublicController } from './public.controller';
PrismaModule,
RedisCacheModule,
SymbolProfileModule,
TagModule,
TransformDataSourceInRequestModule,
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 { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { isSystemTag } from '@ghostfolio/common/helper';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
@ -69,7 +70,7 @@ export class TagsController {
id
});
if (!originalTag) {
if (!originalTag || isSystemTag(originalTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -83,7 +84,7 @@ export class TagsController {
@HasPermission(permissions.readTags)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() {
return this.tagService.getTagsWithActivityCount();
return this.tagService.getTagsWithAccountAndActivityCount();
}
@HasPermission(permissions.updateTag)
@ -94,7 +95,7 @@ export class TagsController {
id
});
if (!originalTag) {
if (!originalTag || isSystemTag(originalTag)) {
throw new HttpException(
getReasonPhrase(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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.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 { DataSource, Prisma } from '@prisma/client';
import { Prisma } from '@prisma/client';
@Injectable()
export class WatchlistService {
@ -24,11 +28,7 @@ export class WatchlistService {
dataSource,
symbol,
userId
}: {
dataSource: DataSource;
symbol: string;
userId: string;
}): Promise<void> {
}: { userId: string } & AssetProfileIdentifier): Promise<void> {
const symbolProfile = await this.prismaService.symbolProfile.findUnique({
where: {
dataSource_symbol: { dataSource, symbol }
@ -40,14 +40,17 @@ export class WatchlistService {
{ dataSource, symbol }
]);
if (!assetProfiles[symbol]?.currency) {
const assetProfile =
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })];
if (!assetProfile?.currency) {
throw new BadRequestException(
`Asset profile not found for ${symbol} (${dataSource})`
);
}
await this.symbolProfileService.add(
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
assetProfile as Prisma.SymbolProfileCreateInput
);
}
@ -72,11 +75,7 @@ export class WatchlistService {
dataSource,
symbol,
userId
}: {
dataSource: DataSource;
symbol: string;
userId: string;
}) {
}: { userId: string } & AssetProfileIdentifier) {
await this.prismaService.user.update({
data: {
watchlist: {
@ -127,7 +126,8 @@ export class WatchlistService {
const performancePercent =
this.benchmarkService.calculateChangeInPercentage(
allTimeHigh?.marketPrice,
quotes[symbol]?.marketPrice
quotes[getAssetProfileIdentifier({ dataSource, symbol })]
?.marketPrice
);
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 { 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 { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { ExportResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -15,9 +16,9 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Type as ActivityType } from '@prisma/client';
import { ExportService } from './export.service';
import { GetExportDto } from './get-export.dto';
@Controller('export')
export class ExportController {
@ -32,29 +33,41 @@ export class ExportController {
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async export(
@Query('accounts') filterByAccounts?: string,
@Query('activityIds') filterByActivityIds?: string,
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
@Query()
{
accounts,
activityIds,
activityTypes,
assetClasses,
dataSource,
range,
symbol,
tags
}: GetExportDto
): Promise<ExportResponse> {
const activityIds = filterByActivityIds?.split(',') ?? [];
const activityTypes = (filterByTypes?.split(',') as ActivityType[]) ?? [];
let endDate: Date;
let startDate: Date;
if (range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
filterByAccounts: accounts,
filterByAssetClasses: assetClasses,
filterByDataSource: dataSource,
filterBySymbol: symbol,
filterByTags: tags
});
return this.exportService.export({
activityIds,
activityTypes,
endDate,
filters,
startDate,
userId: this.request.user.id,
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({
activityIds,
activityTypes,
endDate,
filters,
startDate,
userId,
userSettings
}: {
activityIds?: string[];
activityTypes?: ActivityType[];
endDate?: Date;
filters?: Filter[];
startDate?: Date;
userId: string;
userSettings: UserSettings;
}): Promise<ExportResponse> {
const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => {
const { ACCOUNT: filtersByAccount = [] } = groupBy(filters, ({ type }) => {
return type;
});
const platformsMap: { [platformId: string]: Platform } = {};
let { activities } = await this.activitiesService.getActivities({
endDate,
filters,
startDate,
userId,
includeDrafts: true,
sortColumn: 'date',
@ -59,7 +65,7 @@ export class ExportService {
const where: Prisma.AccountWhereInput = { userId };
if (filtersByAccount?.length > 0) {
if (filtersByAccount.length > 0) {
where.id = {
in: filtersByAccount.map(({ 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 = (
await this.accountService.accounts({
where,
include: {
balances: true,
platform: true
platform: true,
tags: true
},
orderBy: {
name: 'asc'
@ -80,7 +94,7 @@ export class ExportService {
})
)
.filter(({ id }) => {
return activityIds?.length > 0
return isFilteredExport
? activities.some(({ accountId }) => {
return accountId === id;
})
@ -88,39 +102,39 @@ export class ExportService {
})
.map(
({
balance,
balances,
comment,
currency,
id,
isExcluded,
name,
platform,
platformId
}) => {
platformId,
tags
}): ExportResponse['accounts'][number] => {
if (platformId) {
platformsMap[platformId] = platform;
}
return {
balance,
balances: balances.map(({ date, value }) => {
return { date: date.toISOString(), value };
}),
comment,
currency,
id,
isExcluded,
name,
platformId
platformId,
tags: tags.map(({ id: tagId }) => {
return tagId;
})
};
}
);
const customAssetProfiles = uniqBy(
activities
.map(({ SymbolProfile }) => {
return SymbolProfile;
.map(({ assetProfile }) => {
return assetProfile;
})
.filter(({ userId: assetProfileUserId }) => {
return assetProfileUserId === userId;
@ -151,11 +165,14 @@ export class ExportService {
.filter(({ id, isUsed }) => {
return (
isUsed &&
activities.some((activity) => {
return activity.tags.some(({ id: tagId }) => {
return tagId === id;
});
})
(accounts.some(({ tags: tagIds }) => {
return tagIds.includes(id);
}) ||
activities.some((activity) => {
return activity.tags.some(({ id: tagId }) => {
return tagId === id;
});
}))
);
})
.map(({ id, name }) => {
@ -216,13 +233,13 @@ export class ExportService {
activities: activities.map(
({
accountId,
assetProfile,
comment,
currency,
date,
fee,
id,
quantity,
SymbolProfile,
tags: currentTags,
type,
unitPrice
@ -235,10 +252,10 @@ export class ExportService {
quantity,
type,
unitPrice,
currency: currency ?? SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
currency: currency ?? assetProfile.currency,
dataSource: assetProfile.dataSource,
date: date.toISOString(),
symbol: SymbolProfile.symbol,
symbol: assetProfile.symbol,
tags: currentTags.map(({ id: 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')
export class HealthController {
private readonly logger = new Logger(HealthController.name);
public constructor(
private readonly aiService: AiService,
private readonly healthService: HealthService
@ -61,7 +63,7 @@ export class HealthController {
.json({ status: getReasonPhrase(StatusCodes.OK) });
}
} catch (error) {
Logger.error(error, 'HealthController');
this.logger.error(error);
}
return response

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

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

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

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

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

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

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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import {
CreateAssetProfileDto,
CreateAccountDto,
CreateOrderDto
} from '@ghostfolio/common/dtos';
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
ghostfolioPrefix,
NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos';
import {
getAssetProfileIdentifier,
isValidCustomAssetProfileSymbol,
parseDate
} from '@ghostfolio/common/helper';
import {
@ -69,8 +71,7 @@ export class ImportService {
const holding = await this.portfolioService.getHolding({
dataSource,
symbol,
userId,
impersonationId: undefined
userId
});
if (!holding) {
@ -127,11 +128,11 @@ export class ImportService {
const isDuplicate = activities.some((activity) => {
return (
activity.accountId === account?.id &&
activity.SymbolProfile.currency === assetProfile.currency &&
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
activity.assetProfile.currency === assetProfile.currency &&
activity.assetProfile.dataSource === assetProfile.dataSource &&
isSameSecond(activity.date, date) &&
activity.quantity === quantity &&
activity.SymbolProfile.symbol === assetProfile.symbol &&
activity.assetProfile.symbol === assetProfile.symbol &&
activity.type === 'DIVIDEND' &&
activity.unitPrice === marketPrice
);
@ -143,6 +144,7 @@ export class ImportService {
return {
account,
assetProfile,
date,
error,
quantity,
@ -157,7 +159,6 @@ export class ImportService {
feeInBaseCurrency: 0,
id: assetProfile.id,
isDraft: false,
SymbolProfile: assetProfile,
symbolProfileId: assetProfile.id,
type: 'DIVIDEND',
unitPrice: marketPrice,
@ -179,6 +180,7 @@ export class ImportService {
assetProfilesWithMarketDataDto,
isDryRun = false,
maxActivitiesToImport,
platformsDto,
tagsDto,
user
}: {
@ -187,14 +189,143 @@ export class ImportService {
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
isDryRun?: boolean;
maxActivitiesToImport: number;
platformsDto: ImportDataDto['platforms'];
tagsDto: ImportDataDto['tags'];
user: UserWithSettings;
}): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {};
const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {};
const platformIdMapping: { [oldPlatformId: string]: string } = {};
const tagIdMapping: { [oldTagId: string]: string } = {};
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) {
const [existingAccounts, existingPlatforms] = await Promise.all([
this.accountService.accounts({
@ -209,6 +340,12 @@ export class ImportService {
this.platformService.getPlatforms()
]);
const existingTagIds = new Set(
existingTagsOfUser.map(({ id }) => {
return id;
})
);
for (const accountWithBalances of accountsWithBalancesDto) {
// Check if there is any existing account with the same ID
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 (!accountWithSameId || accountWithSameId.userId !== user.id) {
const account: CreateAccountDto = omit(
accountWithBalances,
'balances'
);
const account = omit(accountWithBalances, [
'balance',
'balances',
'isExcluded',
'tags'
]);
let oldAccountId: string;
const platformId = account.platformId;
const platformId =
platformIdMapping[account.platformId] ?? account.platformId;
delete account.platformId;
@ -232,6 +372,24 @@ export class ImportService {
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 = {
...account,
balances: {
@ -251,10 +409,12 @@ export class ImportService {
};
}
const newAccount = await this.accountService.createAccount(
accountObject,
user.id
);
const newAccount = await this.accountService.createAccount({
tagIds,
balance: accountWithBalances.balance,
data: accountObject,
userId: user.id
});
// Store the new to old account ID mappings for updating activities
if (accountWithSameId && oldAccountId) {
@ -264,115 +424,117 @@ export class ImportService {
}
}
if (!isDryRun && assetProfilesWithMarketDataDto?.length) {
const existingAssetProfiles =
await this.symbolProfileService.getSymbolProfiles(
assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
if (assetProfilesWithMarketDataDto?.length) {
const customAssetProfileNames = assetProfilesWithMarketDataDto
.filter(({ dataSource, name }) => {
return dataSource === DataSource.MANUAL && Boolean(name);
})
.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) {
let symbol = assetProfileWithMarketData.symbol;
// Check if there is any existing asset profile
const existingAssetProfile = existingAssetProfiles.find(
({ dataSource, symbol }) => {
(assetProfile) => {
return (
dataSource === assetProfileWithMarketData.dataSource &&
symbol === assetProfileWithMarketData.symbol
assetProfile.dataSource ===
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) {
const assetProfile: CreateAssetProfileDto = omit(
assetProfileWithMarketData,
'marketData'
);
// Check if the user has a custom asset profile with the same name.
// Skip asset profiles with a legacy free-text symbol as they would
// 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;
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
const assetProfileObject: Prisma.SymbolProfileCreateInput = {
...assetProfile,
user: { connect: { id: user.id } }
};
if (symbol !== assetProfileWithMarketData.symbol) {
assetProfileSymbolMapping[assetProfileWithMarketData.symbol] =
symbol;
await this.symbolProfileService.add(assetProfileObject);
// Keep the asset profile in sync with the activities to validate
assetProfileWithMarketData.symbol = symbol;
}
}
// Insert or update market data
const marketDataObjects = assetProfileWithMarketData.marketData.map(
(marketData) => {
if (!isDryRun) {
// Insert or update market data
const marketDataObjects = (
assetProfileWithMarketData.marketData ?? []
).map((marketData) => {
return {
...marketData,
dataSource: assetProfileWithMarketData.dataSource,
symbol: assetProfileWithMarketData.symbol
symbol,
dataSource: assetProfileWithMarketData.dataSource
} 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) {
tagIdMapping[oldTagId] = newTag.id;
}
}
await this.marketDataService.updateMany({ data: marketDataObjects });
}
}
}
for (const activity of activitiesDto) {
if (!activity.dataSource) {
if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) {
activity.dataSource = DataSource.MANUAL;
} else {
activity.dataSource =
this.dataProviderService.getDataSourceForImport();
}
// If an asset profile is created or reused, then update the symbol in all activities
if (assetProfileSymbolMapping[activity.symbol]) {
activity.symbol = assetProfileSymbolMapping[activity.symbol];
}
if (!isDryRun) {
@ -381,11 +543,6 @@ export class ImportService {
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
activity.tags = (activity.tags ?? []).map((tagId) => {
return tagIdMapping[tagId] ?? tagId;
@ -446,19 +603,18 @@ export class ImportService {
const error = activity.error;
const fee = activity.fee;
const quantity = activity.quantity;
const SymbolProfile = activity.SymbolProfile;
const tagIds = activity.tagIds ?? [];
const type = activity.type;
const unitPrice = activity.unitPrice;
const assetProfile = assetProfiles[
getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
dataSource: activity.assetProfile.dataSource,
symbol: activity.assetProfile.symbol
})
] ?? {
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
dataSource: activity.assetProfile.dataSource,
symbol: activity.assetProfile.symbol
};
const {
assetClass,
@ -536,6 +692,8 @@ export class ImportService {
url,
comment: assetProfile.comment,
currency: assetProfile.currency,
dataGatheringFrequency:
assetProfile.dataGatheringFrequency ?? 'DAILY',
userId: dataSource === 'MANUAL' ? user.id : undefined
},
symbolProfileId: undefined,
@ -590,20 +748,21 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber();
const valueInBaseCurrency = this.exchangeRateDataService.toCurrencyAtDate(
value,
currency ?? assetProfile.currency,
userCurrency,
date
);
const valueInBaseCurrency =
(await this.exchangeRateDataService.toCurrencyAtDate(
value,
currency ?? assetProfile.currency,
userCurrency,
date
)) ?? 0;
activities.push({
...order,
// @ts-ignore
assetProfile,
error,
value,
valueInBaseCurrency: await valueInBaseCurrency,
// @ts-ignore
SymbolProfile: assetProfile
valueInBaseCurrency
});
}
@ -613,19 +772,19 @@ export class ImportService {
if (!isDryRun) {
// Gather symbol data in the background, if not dry run
const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => {
const uniqueActivities = uniqBy(activities, ({ assetProfile }) => {
return getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
dataSource: assetProfile.dataSource,
symbol: assetProfile.symbol
});
});
this.dataGatheringService.gatherSymbols({
dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => {
dataGatheringItems: uniqueActivities.map(({ assetProfile, date }) => {
return {
date,
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
dataSource: assetProfile.dataSource,
symbol: assetProfile.symbol
};
}),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
@ -672,12 +831,12 @@ export class ImportService {
activity.accountId === accountId &&
activity.comment === comment &&
(activity.currency === currency ||
activity.SymbolProfile.currency === currency) &&
activity.SymbolProfile.dataSource === dataSource &&
activity.assetProfile.currency === currency) &&
activity.assetProfile.dataSource === dataSource &&
isSameSecond(activity.date, date) &&
activity.fee === fee &&
activity.quantity === quantity &&
activity.SymbolProfile.symbol === symbol &&
activity.assetProfile.symbol === symbol &&
activity.type === type &&
activity.unitPrice === unitPrice
);
@ -697,7 +856,7 @@ export class ImportService {
quantity,
type,
unitPrice,
SymbolProfile: {
assetProfile: {
dataSource,
symbol,
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 { 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 { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.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,
signOptions: { expiresIn: '30 days' }
}),
MarketDataModule,
PlatformModule,
PropertyModule,
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 { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DEFAULT_CURRENCY,
ghostfolioFearAndGreedIndexSymbolStocks,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID,
PROPERTY_DOCKER_HUB_PULLS,
@ -14,15 +17,14 @@ import {
PROPERTY_GITHUB_STARGAZERS,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_UPTIME,
ghostfolioFearAndGreedIndexDataSourceStocks
PROPERTY_UPTIME
} from '@ghostfolio/common/config';
import { encodeDataSource } from '@ghostfolio/common/helper';
import { InfoItem, Statistics } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { MarketData } from '@prisma/client';
import { subDays } from 'date-fns';
import { isNil } from 'lodash';
@ -33,8 +35,10 @@ export class InfoService {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly jwtService: JwtService,
private readonly marketDataService: MarketDataService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService,
private readonly subscriptionService: SubscriptionService,
@ -44,6 +48,7 @@ export class InfoService {
public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {};
let isReadOnlyMode: boolean;
let latestFearAndGreedStocksMarketDataPromise: Promise<MarketData>;
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_SUBSCRIPTION')) {
info.fearAndGreedDataSource = encodeDataSource(
ghostfolioFearAndGreedIndexDataSourceStocks
);
} else {
info.fearAndGreedDataSource =
ghostfolioFearAndGreedIndexDataSourceStocks;
}
latestFearAndGreedStocksMarketDataPromise =
this.marketDataService.getLatest({
dataSource:
this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(),
symbol: ghostfolioFearAndGreedIndexSymbolStocks
});
globalPermissions.push(permissions.enableFearAndGreedIndex);
}
@ -99,12 +102,14 @@ export class InfoService {
benchmarks,
demoAuthToken,
isUserSignupEnabled,
latestFearAndGreedStocksMarketData,
statistics,
subscriptionOffer
] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(),
this.propertyService.isUserSignupEnabled(),
latestFearAndGreedStocksMarketDataPromise,
this.getStatistics(),
this.subscriptionService.getSubscriptionOffer({ key: 'default' })
]);
@ -122,7 +127,9 @@ export class InfoService {
statistics,
subscriptionOffer,
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 { Response } from 'express';
import { GetLogoDto } from './get-logo.dto';
import { LogoService } from './logo.service';
@Controller('logo')
@ -41,7 +42,7 @@ export class LogoController {
@Get()
public async getLogoByUrl(
@Query('url') url: string,
@Query() { url }: GetLogoDto,
@Res() response: Response
) {
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 { 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 { Module } from '@nestjs/common';
@ -11,6 +12,7 @@ import { LogoService } from './logo.service';
controllers: [LogoController],
imports: [
ConfigurationModule,
FetchModule,
SymbolProfileModule,
TransformDataSourceInRequestModule
],

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

@ -1,4 +1,5 @@
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 { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
@ -10,6 +11,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
export class LogoService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService,
private readonly symbolProfileService: SymbolProfileService
) {}
@ -43,15 +45,17 @@ export class LogoService {
}
private async getBuffer(aUrl: string) {
const blob = await fetch(
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
{
headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
).then((res) => res.blob());
const blob = await this.fetchService
.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(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
)
.then((res) => res.blob());
return {
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
};
export const symbolProfileDummyData = {
export const assetProfileDummyData = {
activitiesCount: undefined,
assetClass: 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 { 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 { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface';
@ -34,7 +36,7 @@ import {
ResponseError,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { GroupBy } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
@ -51,6 +53,8 @@ import {
format,
isAfter,
isBefore,
isFuture,
isPast,
isWithinInterval,
min,
startOfDay,
@ -62,6 +66,10 @@ import { isNumber, sortBy, sum, uniqBy } from 'lodash';
export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false;
private static readonly MAX_INITIALIZATION_ATTEMPTS = 3;
protected readonly logger = new Logger(PortfolioCalculator.name);
protected accountBalanceItems: HistoricalDataItem[];
protected activities: PortfolioOrder[];
@ -119,11 +127,11 @@ export abstract class PortfolioCalculator {
this.activities = activities
.map(
({
assetProfile,
date,
feeInAssetProfileCurrency,
feeInBaseCurrency,
quantity,
SymbolProfile,
tags = [],
type,
unitPriceInAssetProfileCurrency
@ -132,14 +140,14 @@ export abstract class PortfolioCalculator {
dateOfFirstActivity = date;
}
if (isAfter(date, new Date())) {
if (isFuture(date)) {
// Adapt date to today if activity is in future (e.g. liability)
// to include it in the interval
date = endOfDay(new Date());
}
return {
SymbolProfile,
assetProfile,
tags,
type,
date: format(date, DATE_FORMAT),
@ -169,10 +177,15 @@ export abstract class PortfolioCalculator {
this.computeTransactionPoints();
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(
positions: TimelinePosition[]
positions: PortfolioCalculatorPosition[]
): PortfolioSnapshot;
@LogPerformance
@ -192,6 +205,7 @@ export abstract class PortfolioCalculator {
hasErrors: false,
historicalData: [],
positions: [],
totalCashInBaseCurrency: new Big(0),
totalFeesWithCurrencyEffect: new Big(0),
totalInterestWithCurrencyEffect: 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 dataGatheringItems: DataGatheringItem[] = [];
let firstIndex = transactionPoints.length;
let firstTransactionPoint: TransactionPoint = null;
let totalCashInBaseCurrency = new Big(0);
let totalInterestWithCurrencyEffect = new Big(0);
let totalLiabilitiesWithCurrencyEffect = new Big(0);
@ -305,20 +321,19 @@ export abstract class PortfolioCalculator {
const errors: ResponseError['errors'] = [];
let hasAnySymbolMetricsErrors = false;
const positions: (TimelinePosition & {
includeInHoldings: boolean;
})[] = [];
const positions: PortfolioCalculatorPosition[] = [];
const accumulatedValuesByDate: {
[date: string]: {
investmentValueWithCurrencyEffect: Big;
totalAccountBalanceWithCurrencyEffect: Big;
totalCashValueWithCurrencyEffect: Big;
totalCurrentValue: Big;
totalCurrentValueWithCurrencyEffect: Big;
totalInvestmentValue: Big;
totalInvestmentValueWithCurrencyEffect: Big;
totalNetPerformanceValue: Big;
totalNetPerformanceValueWithCurrencyEffect: Big;
totalNetWorthValueWithCurrencyEffect: Big;
totalTimeWeightedInvestmentValue: Big;
totalTimeWeightedInvestmentValueWithCurrencyEffect: Big;
};
@ -333,6 +348,7 @@ export abstract class PortfolioCalculator {
investmentValuesWithCurrencyEffect: { [date: string]: Big };
netPerformanceValues: { [date: string]: Big };
netPerformanceValuesWithCurrencyEffect: { [date: string]: Big };
netWorthValuesWithCurrencyEffect: { [date: string]: Big };
timeWeightedInvestmentValues: { [date: string]: Big };
timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big };
};
@ -347,6 +363,13 @@ export abstract class PortfolioCalculator {
] ?? 1
);
const valueInBaseCurrency = marketPriceInBaseCurrency.mul(item.quantity);
const isCashInBaseCurrency =
item.assetSubClass === AssetSubClass.CASH &&
item.currency === this.currency &&
item.symbol === this.currency;
const {
currentValues,
currentValuesWithCurrencyEffect,
@ -387,25 +410,37 @@ export abstract class PortfolioCalculator {
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
const includeInTotalAssetValue =
item.assetSubClass !== AssetSubClass.CASH;
if (includeInTotalAssetValue) {
valuesBySymbol[item.symbol] = {
currentValues,
currentValuesWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect
};
}
// Cash in the base currency cannot generate a currency effect and thus
// contributes nothing but its balance to the performance calculation. It
// is therefore excluded from the value and the investment, while still
// contributing to the net worth.
valuesBySymbol[item.symbol] = isCashInBaseCurrency
? {
currentValues: {},
currentValuesWithCurrencyEffect: {},
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
netWorthValuesWithCurrencyEffect: currentValuesWithCurrencyEffect,
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {}
}
: {
currentValues,
currentValuesWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect,
netWorthValuesWithCurrencyEffect: currentValuesWithCurrencyEffect
};
positions.push({
includeInTotalAssetValue,
timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect,
activitiesCount: item.activitiesCount,
@ -428,6 +463,7 @@ export abstract class PortfolioCalculator {
? (grossPerformanceWithCurrencyEffect ?? null)
: null,
includeInHoldings: item.includeInHoldings,
includeInPerformance: !isCashInBaseCurrency,
investment: totalInvestment,
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
marketPrice:
@ -446,11 +482,16 @@ export abstract class PortfolioCalculator {
quantity: item.quantity,
symbol: item.symbol,
tags: item.tags,
valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul(
item.quantity
)
valueInBaseCurrency
});
if (item.assetSubClass === AssetSubClass.CASH) {
cashSymbols.add(item.symbol);
totalCashInBaseCurrency =
totalCashInBaseCurrency.plus(valueInBaseCurrency);
}
totalInterestWithCurrencyEffect = totalInterestWithCurrencyEffect.plus(
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) {
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)) {
const symbolValues = valuesBySymbol[symbol];
@ -521,6 +541,10 @@ export abstract class PortfolioCalculator {
symbolValues.netPerformanceValuesWithCurrencyEffect?.[dateString] ??
new Big(0);
const netWorthValueWithCurrencyEffect =
symbolValues.netWorthValuesWithCurrencyEffect?.[dateString] ??
new Big(0);
const timeWeightedInvestmentValue =
symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0);
@ -534,7 +558,14 @@ export abstract class PortfolioCalculator {
accumulatedValuesByDate[dateString]
?.investmentValueWithCurrencyEffect ?? new Big(0)
).add(investmentValueWithCurrencyEffect),
totalAccountBalanceWithCurrencyEffect: accountBalanceMap[dateString],
totalCashValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalCashValueWithCurrencyEffect ?? new Big(0)
).add(
cashSymbols.has(symbol)
? netWorthValueWithCurrencyEffect
: new Big(0)
),
totalCurrentValue: (
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
).add(currentValue),
@ -558,6 +589,10 @@ export abstract class PortfolioCalculator {
accumulatedValuesByDate[dateString]
?.totalNetPerformanceValueWithCurrencyEffect ?? new Big(0)
).add(netPerformanceValueWithCurrencyEffect),
totalNetWorthValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalNetWorthValueWithCurrencyEffect ?? new Big(0)
).add(netWorthValueWithCurrencyEffect),
totalTimeWeightedInvestmentValue: (
accumulatedValuesByDate[dateString]
?.totalTimeWeightedInvestmentValue ?? new Big(0)
@ -575,13 +610,14 @@ export abstract class PortfolioCalculator {
).map(([date, values]) => {
const {
investmentValueWithCurrencyEffect,
totalAccountBalanceWithCurrencyEffect,
totalCashValueWithCurrencyEffect,
totalCurrentValue,
totalCurrentValueWithCurrencyEffect,
totalInvestmentValue,
totalInvestmentValueWithCurrencyEffect,
totalNetPerformanceValue,
totalNetPerformanceValueWithCurrencyEffect,
totalNetWorthValueWithCurrencyEffect,
totalTimeWeightedInvestmentValue,
totalTimeWeightedInvestmentValueWithCurrencyEffect
} = values;
@ -608,10 +644,8 @@ export abstract class PortfolioCalculator {
netPerformance: totalNetPerformanceValue.toNumber(),
netPerformanceWithCurrencyEffect:
totalNetPerformanceValueWithCurrencyEffect.toNumber(),
netWorth: totalCurrentValueWithCurrencyEffect
.plus(totalAccountBalanceWithCurrencyEffect)
.toNumber(),
totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(),
netWorth: totalNetWorthValueWithCurrencyEffect.toNumber(),
totalCashInBaseCurrency: totalCashValueWithCurrencyEffect.toNumber(),
totalInvestment: totalInvestmentValue.toNumber(),
totalInvestmentValueWithCurrencyEffect:
totalInvestmentValueWithCurrencyEffect.toNumber(),
@ -627,7 +661,7 @@ export abstract class PortfolioCalculator {
return includeInHoldings;
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(({ includeInHoldings, ...rest }) => {
.map(({ includeInHoldings, includeInPerformance, ...rest }) => {
return rest;
});
@ -635,6 +669,7 @@ export abstract class PortfolioCalculator {
...overall,
errors,
historicalData,
totalCashInBaseCurrency,
totalInterestWithCurrencyEffect,
totalLiabilitiesWithCurrencyEffect,
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors,
@ -772,11 +807,6 @@ export abstract class PortfolioCalculator {
? 0
: netPerformanceWithCurrencyEffectSinceStartDate /
timeWeightedInvestmentValue
// TODO: Add net worth
// netWorth: totalCurrentValueWithCurrencyEffect
// .plus(totalAccountBalanceWithCurrencyEffect)
// .toNumber()
// netWorth: 0
});
}
}
@ -794,25 +824,39 @@ export abstract class PortfolioCalculator {
let firstAccountBalanceDate: Date;
let firstActivityDate: Date;
try {
const firstAccountBalanceDateString = this.accountBalanceItems[0]?.date;
firstAccountBalanceDate = firstAccountBalanceDateString
? parseDate(firstAccountBalanceDateString)
: new Date();
} catch (error) {
firstAccountBalanceDate = new Date();
if (this.accountBalanceItems?.length > 0) {
try {
const firstAccountBalanceDateString = this.accountBalanceItems[0].date;
firstAccountBalanceDate = firstAccountBalanceDateString
? parseDate(firstAccountBalanceDateString)
: new Date();
} catch (error) {
firstAccountBalanceDate = new Date();
}
}
try {
const firstActivityDateString = this.transactionPoints[0].date;
firstActivityDate = firstActivityDateString
? parseDate(firstActivityDateString)
: new Date();
} catch (error) {
firstActivityDate = new Date();
if (this.transactionPoints?.length > 0) {
try {
const firstActivityDateString = this.transactionPoints[0].date;
firstActivityDate = firstActivityDateString
? parseDate(firstActivityDateString)
: 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({
@ -932,23 +976,23 @@ export abstract class PortfolioCalculator {
let lastTransactionPoint: TransactionPoint = null;
for (const {
assetProfile,
date,
fee,
feeInBaseCurrency,
quantity,
SymbolProfile,
tags,
type,
unitPrice
} of this.activities) {
let currentTransactionPointItem: TransactionPointSymbol;
const assetSubClass = SymbolProfile.assetSubClass;
const currency = SymbolProfile.currency;
const dataSource = SymbolProfile.dataSource;
const assetSubClass = assetProfile.assetSubClass;
const currency = assetProfile.currency;
const dataSource = assetProfile.dataSource;
const factor = getFactor(type);
const skipErrors = !!SymbolProfile.userId; // Skip errors for custom asset profiles
const symbol = SymbolProfile.symbol;
const skipErrors = !!assetProfile.userId; // Skip errors for custom asset profiles
const symbol = assetProfile.symbol;
const oldAccumulatedSymbol = symbols[symbol];
@ -1032,12 +1076,12 @@ export abstract class PortfolioCalculator {
'id'
);
symbols[SymbolProfile.symbol] = currentTransactionPointItem;
symbols[symbol] = currentTransactionPointItem;
const items = lastTransactionPoint?.items ?? [];
const newItems = items.filter(({ symbol }) => {
return symbol !== SymbolProfile.symbol;
return symbol !== assetProfile.symbol;
});
newItems.push(currentTransactionPointItem);
@ -1088,20 +1132,23 @@ export abstract class PortfolioCalculator {
}
@LogPerformance
private async initialize() {
private async initialize(attempt = 1) {
const startTimeTotal = performance.now();
let cachedPortfolioSnapshot: PortfolioSnapshot;
let isCachedPortfolioSnapshotExpired = false;
const jobId = this.userId;
const portfolioSnapshotKey = this.redisCacheService.getPortfolioSnapshotKey(
{
filters: this.filters,
userId: this.userId
}
);
const jobId = portfolioSnapshotKey;
try {
const cachedPortfolioSnapshotValue = await this.redisCacheService.get(
this.redisCacheService.getPortfolioSnapshotKey({
filters: this.filters,
userId: this.userId
})
);
const cachedPortfolioSnapshotValue =
await this.redisCacheService.get(portfolioSnapshotKey);
const { expiration, portfolioSnapshot }: PortfolioSnapshotValue =
JSON.parse(cachedPortfolioSnapshotValue);
@ -1111,7 +1158,7 @@ export abstract class PortfolioCalculator {
portfolioSnapshot
);
if (isAfter(new Date(), new Date(expiration))) {
if (isPast(new Date(expiration))) {
isCachedPortfolioSnapshotExpired = true;
}
} catch {}
@ -1119,12 +1166,11 @@ export abstract class PortfolioCalculator {
if (cachedPortfolioSnapshot) {
this.snapshot = cachedPortfolioSnapshot;
Logger.debug(
this.logger.debug(
`Fetched portfolio snapshot from cache in ${(
(performance.now() - startTimeTotal) /
1000
).toFixed(3)} seconds`,
'PortfolioCalculator'
).toFixed(3)} seconds`
);
if (isCachedPortfolioSnapshotExpired) {
@ -1145,6 +1191,12 @@ export abstract class PortfolioCalculator {
});
}
} 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
await this.portfolioSnapshotService.addJobToQueue({
data: {
@ -1167,7 +1219,7 @@ export abstract class PortfolioCalculator {
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 {
activityDummyData,
symbolProfileDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
@ -65,7 +68,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);
@ -85,33 +88,33 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY',
unitPriceInAssetProfileCurrency: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 2,
type: 'BUY',
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 {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and sell in two activities', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'SELL',
unitPriceInAssetProfileCurrency: 136.6
},
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'SELL',
unitPriceInAssetProfileCurrency: 136.6
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 3,
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dateOfFirstActivity: '2021-11-22',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.04408677396780965649'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.0552834149755073478')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('-15.8')
},
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'),
symbol: 'BALN.SW',
tags: [],
timeWeightedInvestment: new Big('285.80000000000000396627'),
timeWeightedInvestmentWithCurrencyEffect: new Big(
'285.80000000000000396627'
),
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('3.2'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: -15.8,
netPerformanceInPercentage: -0.05528341497550734703,
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(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: 0 },
{ date: '2021-12-01', investment: 0 }
]);
expect(investmentsByYear).toEqual([
{ date: '2021-01-01', investment: 0 }
]);
});
});
});
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 { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and sell in two activities', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY',
unitPriceInAssetProfileCurrency: 142.9
},
{
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 1,
type: 'SELL',
unitPriceInAssetProfileCurrency: 136.6
},
{
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'SELL',
unitPriceInAssetProfileCurrency: 136.6
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 3,
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dateOfFirstActivity: '2021-11-22',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.04408677396780965649'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.0552834149755073478')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('-15.8')
},
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'),
symbol: 'BALN.SW',
tags: [],
timeWeightedInvestment: new Big('285.80000000000000396627'),
timeWeightedInvestmentWithCurrencyEffect: new Big(
'285.80000000000000396627'
),
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('3.2'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: -15.8,
netPerformanceInPercentage: -0.05528341497550734703,
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(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: 0 },
{ date: '2021-12-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 {
activityDummyData,
symbolProfileDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
@ -65,7 +68,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);
@ -85,33 +88,33 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY',
unitPriceInAssetProfileCurrency: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 2,
type: 'SELL',
unitPriceInAssetProfileCurrency: 136.6
}

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

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

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

@ -1,7 +1,7 @@
import {
activityDummyData,
assetProfileDummyData,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => {
});
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
@ -75,7 +78,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);
@ -95,17 +98,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 4.46,
unitPriceInAssetProfileCurrency: 44558.42
})
);
@ -145,7 +148,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalCashInBaseCurrency: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
@ -163,7 +166,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
netPerformanceWithCurrencyEffect: 5535.42,
netWorth: 50098.3, // 1 * 50098.3 = 50098.3
totalAccountBalance: 0,
totalCashInBaseCurrency: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,
value: 50098.3, // 1 * 50098.3 = 50098.3
@ -182,7 +185,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712,
netPerformanceWithCurrencyEffect: -1463.18,
netWorth: 43099.7,
totalAccountBalance: 0,
totalCashInBaseCurrency: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,
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 {
activityDummyData,
symbolProfileDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -66,6 +66,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
@ -77,7 +80,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);
@ -98,33 +101,33 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2015-01-01'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Bitcoin USD',
symbol: 'BTCUSD'
},
date: new Date('2015-01-01'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 2,
type: 'BUY',
unitPriceInAssetProfileCurrency: 320.43
},
{
...activityDummyData,
date: new Date('2017-12-31'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Bitcoin USD',
symbol: 'BTCUSD'
},
date: new Date('2017-12-31'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'SELL',
unitPriceInAssetProfileCurrency: 14156.4
}

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

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

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

@ -1,7 +1,7 @@
import {
activityDummyData,
assetProfileDummyData,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => {
});
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
@ -75,7 +78,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);
@ -96,16 +99,16 @@ describe('PortfolioCalculator', () => {
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 4.46,
unitPriceInAssetProfileCurrency: 44558.42
})
);
@ -145,7 +148,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalCashInBaseCurrency: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
@ -163,7 +166,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
netPerformanceWithCurrencyEffect: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
netWorth: 50098.3, // 1 * 50098.3 = 50098.3
totalAccountBalance: 0,
totalCashInBaseCurrency: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,
value: 50098.3, // 1 * 50098.3 = 50098.3
@ -182,7 +185,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712,
netPerformanceWithCurrencyEffect: -1463.18,
netWorth: 43099.7,
totalAccountBalance: 0,
totalCashInBaseCurrency: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,
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 { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
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 { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
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 { Big } from 'big.js';
import { eachDayOfInterval } from 'date-fns';
import { randomUUID } from 'node:crypto';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
@ -72,6 +77,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
exchangeRateDataService = new ExchangeRateDataService(
@ -91,6 +99,7 @@ describe('PortfolioCalculator', () => {
accountBalanceService,
null,
exchangeRateDataService,
null,
null
);
@ -116,14 +125,17 @@ describe('PortfolioCalculator', () => {
accountBalanceService,
accountService,
null,
null,
dataProviderService,
null,
exchangeRateDataService,
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
@ -146,17 +158,25 @@ describe('PortfolioCalculator', () => {
balances: [
{
accountId,
id: randomUUID(),
date: parseDate('2023-12-31'),
id: randomUUID(),
value: 1000,
valueInBaseCurrency: 850
},
{
accountId,
id: randomUUID(),
date: parseDate('2024-12-31'),
id: randomUUID(),
value: 2000,
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'),
currency: 'USD',
id: accountId,
isExcluded: false,
name: 'USD',
platformId: null,
updatedAt: parseDate('2023-12-31'),
@ -244,7 +263,6 @@ describe('PortfolioCalculator', () => {
'0.08211603004634809014'
),
grossPerformanceWithCurrencyEffect: new Big(70),
includeInTotalAssetValue: false,
investment: new Big(1820),
investmentWithCurrencyEffect: new Big(1750),
marketPrice: 1,
@ -279,11 +297,317 @@ describe('PortfolioCalculator', () => {
});
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,
totalCashInBaseCurrency: new Big(2000),
totalFeesWithCurrencyEffect: new Big(0),
totalInterestWithCurrencyEffect: new Big(0),
totalInvestment: 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 {
activityDummyData,
symbolProfileDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
@ -65,7 +68,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);
@ -85,17 +88,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-09-01'),
feeInAssetProfileCurrency: 49,
feeInBaseCurrency: 49,
quantity: 0,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'MANUAL',
name: 'Account Opening Fee',
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141'
},
date: new Date('2021-09-01'),
feeInAssetProfileCurrency: 49,
feeInBaseCurrency: 49,
quantity: 0,
type: 'FEE',
unitPriceInAssetProfileCurrency: 0
}
@ -113,7 +116,7 @@ describe('PortfolioCalculator', () => {
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: true,
hasErrors: false,
positions: [],
totalFeesWithCurrencyEffect: new Big('49'),
totalInterestWithCurrencyEffect: new Big('0'),

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

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

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

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

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

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

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

@ -1,6 +1,6 @@
import {
activityDummyData,
symbolProfileDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
@ -65,7 +68,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);
@ -85,33 +88,33 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-09-16'),
feeInAssetProfileCurrency: 19,
feeInBaseCurrency: 19,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
date: new Date('2021-09-16'),
feeInAssetProfileCurrency: 19,
feeInBaseCurrency: 19,
quantity: 1,
type: 'BUY',
unitPriceInAssetProfileCurrency: 298.58
},
{
...activityDummyData,
date: new Date('2021-11-16'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
date: new Date('2021-11-16'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'DIVIDEND',
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;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
@ -60,7 +63,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);
@ -74,7 +77,7 @@ describe('PortfolioCalculator', () => {
});
describe('get current positions', () => {
it('with no orders', async () => {
it('with no activities', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
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 {
activityDummyData,
assetProfileDummyData,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -67,6 +67,9 @@ describe('PortfolioCalculator', () => {
});
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
@ -78,7 +81,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);
@ -99,16 +102,16 @@ describe('PortfolioCalculator', () => {
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);

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

@ -1,264 +1,267 @@
import {
activityDummyData,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
exportResponse = loadExportFile(
join(
__dirname,
'../../../../../../../test/import/ok/novn-buy-and-sell.json'
)
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2022-03-06',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 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({
date: '2022-03-07',
investmentValueWithCurrencyEffect: 151.6,
netPerformance: 24, // 2 * (87.8 - 75.8) = 24
netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
netPerformanceWithCurrencyEffect: 24,
netWorth: 175.6, // 2 * 87.8 = 175.6
totalAccountBalance: 0,
totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6,
value: 175.6, // 2 * 87.8 = 175.6
valueWithCurrencyEffect: 175.6
});
expect(
portfolioSnapshot.historicalData[
portfolioSnapshot.historicalData.length - 1
]
).toEqual({
date: '2022-04-11',
investmentValueWithCurrencyEffect: 0,
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
netPerformanceWithCurrencyEffect: 19.86,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 2,
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dateOfFirstActivity: '2022-03-07',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.13100263852242744063'
),
grossPerformanceWithCurrencyEffect: new Big('19.86'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.13100263852242744063')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('19.86')
},
marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8,
quantity: new Big('0'),
symbol: 'NOVN.SW',
tags: [],
timeWeightedInvestment: new Big('151.6'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744063,
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(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: 151.6 },
{ date: '2022-04-01', investment: -151.6 }
]);
expect(investmentsByYear).toEqual([
{ date: '2022-01-01', investment: 0 }
]);
});
});
});
import {
activityDummyData,
assetProfileDummyData,
loadExportFile,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
exportResponse = loadExportFile(
join(
__dirname,
'../../../../../../../test/import/ok/novn-buy-and-sell.json'
)
);
});
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
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());
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
assetProfile: {
...assetProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
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,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2022-03-06',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalCashInBaseCurrency: 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({
date: '2022-03-07',
investmentValueWithCurrencyEffect: 151.6,
netPerformance: 24, // 2 * (87.8 - 75.8) = 24
netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
netPerformanceWithCurrencyEffect: 24,
netWorth: 175.6, // 2 * 87.8 = 175.6
totalCashInBaseCurrency: 0,
totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6,
value: 175.6, // 2 * 87.8 = 175.6
valueWithCurrencyEffect: 175.6
});
expect(
portfolioSnapshot.historicalData[
portfolioSnapshot.historicalData.length - 1
]
).toEqual({
date: '2022-04-11',
investmentValueWithCurrencyEffect: 0,
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
netPerformanceWithCurrencyEffect: 19.86,
netWorth: 0,
totalCashInBaseCurrency: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 2,
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dateOfFirstActivity: '2022-03-07',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.13100263852242744063'
),
grossPerformanceWithCurrencyEffect: new Big('19.86'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.13100263852242744063')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('19.86')
},
marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8,
quantity: new Big('0'),
symbol: 'NOVN.SW',
tags: [],
timeWeightedInvestment: new Big('151.6'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744063,
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(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: 151.6 },
{ date: '2022-04-01', investment: -151.6 }
]);
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 {
activityDummyData,
symbolProfileDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
@ -65,7 +68,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);
@ -85,17 +88,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2022-01-01'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'MANUAL',
name: 'Penthouse Apartment',
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
},
date: new Date('2022-01-01'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'BUY',
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 { PortfolioCalculatorPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-position.interface';
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
@ -7,11 +8,10 @@ import {
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { DateRange } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
import {
addMilliseconds,
@ -27,7 +27,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
private chartDates: string[];
protected calculateOverallPerformance(
positions: TimelinePosition[]
positions: PortfolioCalculatorPosition[]
): PortfolioSnapshot {
let currentValueInBaseCurrency = new Big(0);
let grossPerformance = new Big(0);
@ -41,17 +41,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
let totalTimeWeightedInvestment = new Big(0);
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions.filter(
({ includeInTotalAssetValue }) => {
return includeInTotalAssetValue;
}
)) {
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency
);
}
for (const currentPosition of positions) {
if (currentPosition.valueInBaseCurrency) {
currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
currentPosition.valueInBaseCurrency
@ -60,6 +50,16 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
hasErrors = true;
}
if (!currentPosition.includeInPerformance) {
continue;
}
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency
);
}
if (currentPosition.investment) {
totalInvestment = totalInvestment.plus(currentPosition.investment);
@ -96,9 +96,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
currentPosition.timeWeightedInvestmentWithCurrencyEffect
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
'PortfolioCalculator'
this.logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`
);
hasErrors = true;
@ -119,6 +118,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
createdAt: new Date(),
errors: [],
historicalData: [],
totalCashInBaseCurrency: 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
let orders: PortfolioOrderItem[] = cloneDeep(
this.activities.filter(({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol;
this.activities.filter(({ assetProfile }) => {
return assetProfile.symbol === symbol;
})
);
const isCash = orders[0]?.SymbolProfile?.assetSubClass === 'CASH';
const isCash = orders[0]?.assetProfile?.assetSubClass === 'CASH';
if (orders.length <= 0) {
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 endDateString = format(end, DATE_FORMAT);
@ -265,7 +295,20 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
!unitPriceAtEndDate ||
(!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 {
totalDividend,
totalDividendInBaseCurrency,
totalInterest,
totalInterestInBaseCurrency,
totalLiabilities,
totalLiabilitiesInBaseCurrency,
currentValues: {},
currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0),
@ -273,7 +316,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: true,
hasErrors: hasActivitiesWithUnits,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
@ -290,43 +333,35 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: 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),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0)
totalInvestmentWithCurrencyEffect: new Big(0)
};
}
const assetProfile: PortfolioOrderItem['assetProfile'] = {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
};
// Add a synthetic order at the start and the end date
orders.push({
assetProfile,
date: startDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'start',
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
type: 'BUY',
unitPrice: unitPriceAtStartDate
});
orders.push({
assetProfile,
date: endDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'end',
SymbolProfile: {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
quantity: new Big(0),
type: 'BUY',
unitPrice: unitPriceAtEndDate
@ -359,15 +394,11 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
}
} else {
orders.push({
assetProfile,
date: dateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,
unitPriceFromMarketData:
@ -423,29 +454,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
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') {
// Take the unit price of the order as the market price if there are no
// 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[] = [];
if (includesToday) {
const quotesBySymbol = await this.dataProviderService.getQuotes({
const quotes = await this.dataProviderService.getQuotes({
items: dataGatheringItems,
user: this.request?.user
});
for (const { dataSource, symbol } of dataGatheringItems) {
const quote = quotesBySymbol[symbol];
const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })];
if (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