Browse Source

Merge pull request #86 from dandevaud/merge/MR-Branch-Upstream-in-dockerpush-04-24

Merge/mr branch upstream in dockerpush 04 24
pull/5027/head
dandevaud 1 year ago
committed by GitHub
parent
commit
f83751f2a4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 47
      .github/workflows/docker-image-branch.yml
  2. 330
      CHANGELOG.md
  3. 3
      Dockerfile
  4. 44
      README.md
  5. 1
      apps/api/jest.config.ts
  6. 2
      apps/api/src/app/access/access.controller.ts
  7. 41
      apps/api/src/app/account-balance/account-balance.controller.ts
  8. 3
      apps/api/src/app/account-balance/account-balance.module.ts
  9. 62
      apps/api/src/app/account-balance/account-balance.service.ts
  10. 12
      apps/api/src/app/account-balance/create-account-balance.dto.ts
  11. 80
      apps/api/src/app/account/account.service.ts
  12. 67
      apps/api/src/app/admin/admin.controller.ts
  13. 16
      apps/api/src/app/admin/admin.service.ts
  14. 7
      apps/api/src/app/admin/queue/queue.controller.ts
  15. 5
      apps/api/src/app/admin/queue/queue.service.ts
  16. 10
      apps/api/src/app/admin/update-asset-profile.dto.ts
  17. 6
      apps/api/src/app/app.module.ts
  18. 27
      apps/api/src/app/auth/jwt.strategy.ts
  19. 2
      apps/api/src/app/auth/web-auth.service.ts
  20. 13
      apps/api/src/app/benchmark/benchmark.controller.ts
  21. 54
      apps/api/src/app/benchmark/benchmark.service.ts
  22. 5
      apps/api/src/app/export/export.service.ts
  23. 4
      apps/api/src/app/import/import.controller.ts
  24. 63
      apps/api/src/app/import/import.service.ts
  25. 4
      apps/api/src/app/order/create-order.dto.ts
  26. 10
      apps/api/src/app/order/interfaces/activities.interface.ts
  27. 66
      apps/api/src/app/order/order.controller.ts
  28. 161
      apps/api/src/app/order/order.service.ts
  29. 4
      apps/api/src/app/order/update-order.dto.ts
  30. 7
      apps/api/src/app/platform/create-platform.dto.ts
  31. 7
      apps/api/src/app/platform/update-platform.dto.ts
  32. 302
      apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts
  33. 33
      apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts
  34. 30
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  35. 96
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  36. 1108
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  37. 218
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  38. 124
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts
  39. 105
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts
  40. 124
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  41. 157
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts
  42. 106
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts
  43. 157
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts
  44. 108
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts
  45. 165
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts
  46. 124
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts
  47. 124
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  48. 129
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts
  49. 37
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts
  50. 922
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  51. 18
      apps/api/src/app/portfolio/current-rate.service.mock.ts
  52. 4
      apps/api/src/app/portfolio/current-rate.service.ts
  53. 19
      apps/api/src/app/portfolio/interfaces/current-positions.interface.ts
  54. 4
      apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts
  55. 15
      apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts
  56. 12
      apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts
  57. 5
      apps/api/src/app/portfolio/interfaces/portfolio-positions.interface.ts
  58. 4
      apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts
  59. 6
      apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts
  60. 86
      apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts
  61. 152
      apps/api/src/app/portfolio/portfolio-calculator-novn-baln-buy-and-sell.spec.ts
  62. 2772
      apps/api/src/app/portfolio/portfolio-calculator.ts
  63. 225
      apps/api/src/app/portfolio/portfolio.controller.ts
  64. 4
      apps/api/src/app/portfolio/portfolio.module.ts
  65. 52
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  66. 1552
      apps/api/src/app/portfolio/portfolio.service.ts
  67. 12
      apps/api/src/app/portfolio/rules.service.ts
  68. 13
      apps/api/src/app/redis-cache/redis-cache.service.mock.ts
  69. 12
      apps/api/src/app/redis-cache/redis-cache.service.ts
  70. 2
      apps/api/src/app/subscription/subscription.controller.ts
  71. 4
      apps/api/src/app/symbol/symbol.controller.ts
  72. 20
      apps/api/src/app/symbol/symbol.service.ts
  73. 8
      apps/api/src/app/user/update-user-setting.dto.ts
  74. 15
      apps/api/src/app/user/user.controller.ts
  75. 53
      apps/api/src/app/user/user.service.ts
  76. 2
      apps/api/src/assets/cryptocurrencies/custom.json
  77. 8
      apps/api/src/assets/sitemap.xml
  78. 2
      apps/api/src/environments/environment.prod.ts
  79. 11
      apps/api/src/events/events.module.ts
  80. 15
      apps/api/src/events/portfolio-changed.event.ts
  81. 25
      apps/api/src/events/portfolio-changed.listener.ts
  82. 2
      apps/api/src/helper/object.helper.ts
  83. 91
      apps/api/src/helper/portfolio.helper.ts
  84. 1
      apps/api/src/interceptors/redact-values-in-response.interceptor.ts
  85. 4
      apps/api/src/models/order.ts
  86. 11
      apps/api/src/models/rule.ts
  87. 5
      apps/api/src/models/rules/account-cluster-risk/current-investment.ts
  88. 1
      apps/api/src/models/rules/account-cluster-risk/single-account.ts
  89. 6
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  90. 8
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  91. 1
      apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts
  92. 1
      apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts
  93. 10
      apps/api/src/services/api/api.service.ts
  94. 7
      apps/api/src/services/configuration/configuration.service.ts
  95. 4
      apps/api/src/services/cron.service.ts
  96. 20
      apps/api/src/services/data-gathering/data-gathering.processor.ts
  97. 63
      apps/api/src/services/data-gathering/data-gathering.service.ts
  98. 4
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  99. 9
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  100. 2
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts

47
.github/workflows/docker-image-branch.yml

@ -0,0 +1,47 @@
name: Docker image CD - Branch
on:
push:
branches:
- '*'
jobs:
build_and_push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: dandevaud/ghostfolio
tags: |
type=semver,pattern={{major}}
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: dandevaud/ghostfolio:${{ github.ref_name }}
labels: ${{ steps.meta.output.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

330
CHANGELOG.md

@ -5,12 +5,340 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased ## 2.80.0 - 2024-05-08
### Added
- Added the absolute change column to the holdings table on the home page
### Changed
- Increased the spacing around the floating action buttons (FAB)
- Set the icon column of the activities table to stick at the beginning
- Set the icon column of the holdings table to stick at the beginning
- Increased the number of attempts of queue jobs from `10` to `12` (fail later)
- Upgraded `ionicons` from version `7.3.0` to `7.4.0`
### Fixed
- Fixed the position detail dialog open functionality when searching for a holding in the assistant
## 2.79.0 - 2024-05-04
### Changed
- Moved the holdings table to the holdings tab of the home page
- Improved the performance labels (with and without currency effects) in the position detail dialog
- Optimized the calculations of the portfolio details endpoint
### Fixed
- Fixed an issue with the benchmarks in the markets overview
- Fixed an issue with the _Fear & Greed Index_ (market mood) in the markets overview
## 2.78.0 - 2024-05-02
### Added
- Added a form validation against the DTO in the create or update access dialog
- Added a form validation against the DTO in the asset profile details dialog of the admin control
- Added a form validation against the DTO in the platform management of the admin control panel
- Added a form validation against the DTO in the tag management of the admin control panel
### Changed
- Set the performance column of the holdings table to stick at the end
- Skipped the caching in the portfolio calculator if there are active filters (experimental)
- Improved the `INACTIVE` user role
### Fixed
- Fixed an issue in the calculation of the portfolio summary caused by future liabilities
- Fixed a division by zero error in the dividend yield calculation (experimental)
## 2.77.1 - 2024-04-27
### Added
- Extended the content of the _Self-Hosting_ section by the custom asset instructions on the Frequently Asked Questions (FAQ) page
- Added the caching to the portfolio calculator (experimental)
### Changed
- Migrated the `@ghostfolio/ui` components to control flow
- Updated the browserslist database
- Upgraded `prisma` from version `5.12.1` to `5.13.0`
### Fixed
- Fixed the form submit in the asset profile details dialog of the admin control due to the `url` validation
- Fixed the historical market data gathering for asset profiles with `MANUAL` data source
## 2.76.0 - 2024-04-23
### Changed
- Changed `CASH` to `LIQUIDITY` in the asset class enum
## 2.75.1 - 2024-04-21
### Added
- Added `accountId` and `date` as a unique constraint to the `AccountBalance` database schema
### Changed
- Improved the chart in the account detail dialog
- Improved the account balance management
### Fixed
- Fixed an issue with `totalValueInBaseCurrency` in the value redaction interceptor for the impersonation mode
## 2.74.0 - 2024-04-20
### Added
- Added the date range support to the portfolio holdings page
- Added support to create an account balance
### Changed
- Removed the date range support in the activities table on the portfolio activities page (experimental)
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `17.3.3` to `17.3.5`
- Upgraded `Nx` from version `18.2.3` to `18.3.3`
### Fixed
- Fixed gaps in the portfolio performance charts by considering `BUY` and `SELL` activities
## 2.73.0 - 2024-04-17
### Added
- Added a form validation against the DTO in the create or update account dialog
- Added a form validation against the DTO in the create or update activity dialog
### Changed
- Moved the dividend calculations into the portfolio calculator
- Moved the fee calculations into the portfolio calculator
- Moved the interest calculations into the portfolio calculator
- Moved the liability calculations into the portfolio calculator
- Moved the (wealth) item calculations into the portfolio calculator
- Let queue jobs for asset profile data gathering fail by throwing an error
- Let queue jobs for historical market data gathering fail by throwing an error
- Upgraded `yahoo-finance2` from version `2.11.1` to `2.11.2`
## 2.72.0 - 2024-04-13
### Added
- Added support to immediately execute a queue job from the admin control panel
- Added a priority column to the queue jobs view in the admin control panel
### Changed
- Adapted the priorities of queue jobs
- Upgraded `angular` from version `17.2.4` to `17.3.3`
- Upgraded `Nx` from version `18.1.2` to `18.2.3`
- Upgraded `prisma` from version `5.11.0` to `5.12.1`
- Upgraded `yahoo-finance2` from version `2.11.0` to `2.11.1`
### Fixed
- Fixed an issue in the public page
## 2.71.0 - 2024-04-07
### Added
- Added the dividend yield to the position detail dialog (experimental)
- Added support to override the asset class of an asset profile in the asset profile details dialog of the admin control
- Added support to override the asset sub class of an asset profile in the asset profile details dialog of the admin control
- Added support to override the url of an asset profile in the asset profile details dialog of the admin control
- Added the asset profile icon to the asset profile details dialog of the admin control
- Added the platform icon to the create or update platform dialog of the admin control
- Extended the rules in the _X-ray_ section by a `key`
- Added `currency` to the `Order` database schema as a preparation to set a custom currency
- Extended the content of the _Self-Hosting_ section by the data providers on the Frequently Asked Questions (FAQ) page
### Changed
- Optimized the calculation of allocations by market
- Improved the url validation in the create and update platform endpoint
- Improved the language localization for German (`de`)
### Fixed
- Fixed the missing tags in the portfolio calculations
## 2.70.0 - 2024-04-02
### Added
- Set up the language localization for Chinese (`zh`)
- Added `init: true` to the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`) to avoid zombie processes
- Set up _Webpack Bundle Analyzer_
### Changed
- Disabled the option to update the cash balance of an account if date is not today
- Improved the usability of the date range support by specific years (`2023`, `2022`, `2021`, etc.) in the assistant (experimental)
- Introduced a factory for the portfolio calculations to support different algorithms in future
### Fixed
- Fixed the duplicated tags in the position detail dialog
- Removed `Tini` from the docker image
## 2.69.0 - 2024-03-30
### Added
- Added the date range support in the activities table on the portfolio activities page (experimental)
- Extended the date range support by specific years (`2021`, `2022`, `2023`, etc.) in the assistant (experimental)
- Set up `Tini` to avoid zombie processes and perform signal forwarding in docker image
### Changed
- Improved the usability to delete an asset profile in the historical market data table and the asset profile details dialog of the admin control
### Fixed
- Added missing dates to edit historical market data in the asset profile details dialog of the admin control panel
## 2.68.0 - 2024-03-29
### Added
- Extended the export functionality by the user account’s currency
- Added support to override the name of an asset profile in the asset profile details dialog of the admin control
### Changed
- Optimized the portfolio calculations
### Fixed
- Fixed the chart tooltip of the benchmark comparator
- Fixed an issue with names in the activities table on the portfolio activities page while using symbol profile overrides
## 2.67.0 - 2024-03-26
### Added
- Added support for the cryptocurrency _Toncoin_ (`TON11419-USD`)
### Changed
- Replaced `Math.random()` with `crypto.randomBytes()` for generating cryptographically secure random strings
- Upgraded `ionicons` from version `7.1.0` to `7.3.0`
- Upgraded `yahoo-finance2` from version `2.10.0` to `2.11.0`
- Upgraded `zone.js` from version `0.14.3` to `0.14.4`
## 2.66.3 - 2024-03-23
### Added
- Extended the content of the _SaaS_ and _Self-Hosting_ sections by the backup strategy on the Frequently Asked Questions (FAQ) page
- Added an index for `dataSource` / `symbol` to the market data database table
### Changed
- Improved the chart tooltip of the benchmark comparator by adding the benchmark name
- Upgraded `angular` from version `17.1.3` to `17.2.4`
- Upgraded `Nx` from version `18.0.4` to `18.1.2`
### Fixed
- Fixed the missing portfolio performance chart in the _Presenter View_ / _Zen Mode_
## 2.65.0 - 2024-03-19
### Added
- Added the symbol and ISIN number to the position detail dialog
- Added support to delete an asset profile in the asset profile details dialog of the admin control
### Changed
- Moved the support to grant private access with permissions from experimental to general availability
- Set the meta theme color dynamically to respect the appearance (dark mode)
- Improved the usability to edit market data in the admin control panel
## 2.64.0 - 2024-03-16
### Added
- Added a toggle to switch between active and closed holdings on the portfolio holdings page
- Added support to update the cash balance of an account when adding a fee activity
- Added support to update the cash balance of an account when adding an interest activity
- Extended the content of the _General_ section by the product roadmap on the Frequently Asked Questions (FAQ) page
### Changed
- Improved the usability of the platform management in the admin control panel
- Improved the usability of the tag management in the admin control panel
- Improved the exception handling of various rules in the _X-ray_ section
- Increased the timeout to load benchmarks
- Upgraded `prisma` from version `5.10.2` to `5.11.0`
### Fixed
- Fixed an issue in the dividend calculation of the portfolio holdings
- Fixed the date conversion of the import of historical market data in the admin control panel
## 2.63.2 - 2024-03-12
### Added
- Extended the content of the _Self-Hosting_ section by available home server systems on the Frequently Asked Questions (FAQ) page
- Added support for the cryptocurrency _Real Smurf Cat_ (`SMURFCAT-USD`)
### Changed
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `8.3` to `9.0`
- Upgraded `countries-list` from version `2.6.1` to `3.1.0`
- Upgraded `yahoo-finance2` from version `2.9.1` to `2.10.0`
### Fixed
- Fixed an issue in the performance calculation caused by multiple `SELL` activities on the same day
- Fixed an issue in the calculation on the allocations page caused by liabilities
- Fixed an issue with the currency in the request to get quotes from _EOD Historical Data_
## 2.62.0 - 2024-03-09
### Changed
- Optimized the calculation of the accounts table
- Optimized the calculation of the portfolio holdings
- Integrated dividend into the transaction point concept in the portfolio service
- Removed the environment variable `WEB_AUTH_RP_ID`
### Fixed
- Fixed an issue in the calculation of the portfolio summary caused by future liabilities
- Fixed an issue with removing a linked account from a (wealth) item activity
## 2.61.1 - 2024-03-06
### Fixed
- Fixed an issue in the account value calculation caused by liabilities
## 2.61.0 - 2024-03-04
### Changed ### Changed
- Optimized the calculation of the portfolio summary - Optimized the calculation of the portfolio summary
### Fixed
- Fixed the activities import (query parameter handling)
## 2.60.0 - 2024-03-02 ## 2.60.0 - 2024-03-02
### Added ### Added

3
Dockerfile

@ -57,6 +57,7 @@ RUN apt update && apt install -y \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
WORKDIR /ghostfolio/apps/api WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333} EXPOSE ${PORT:-3333}
CMD [ "yarn", "start:production" ] CMD [ "/ghostfolio/entrypoint.sh" ]

44
README.md

@ -7,14 +7,12 @@
**Open Source Wealth Management Software** **Open Source Wealth Management Software**
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) | [**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_) [**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://twitter.com/ghostfolio_)
[![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio) [![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio)
[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing) [![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing)
[![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) [![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
</div> </div>
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation. **Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
@ -87,23 +85,23 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables ### Supported Environment Variables
| Name | Default Value | Description | | Name | Type | Default Value | Description |
| ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens | | `ACCESS_TOKEN_SALT` | string | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` |   | The _CoinGecko_ Demo API key | | `API_KEY_COINGECKO_DEMO` | string (`optional`) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` |   | The _CoinGecko_ Pro API | | `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API |
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | | `DATABASE_URL` | string | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on | | `HOST` | string (`optional`) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) | | `JWT_SECRET_KEY` | string | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `3333` | The port where the Ghostfolio application will run on | | `PORT` | number (`optional`) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database | | `POSTGRES_DB` | string | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database | | `POSTGRES_PASSWORD` | string | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database | | `POSTGRES_USER` | string | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `0` | The database index of _Redis_ | | `REDIS_DB` | number (`optional`) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | | The host where _Redis_ is running | | `REDIS_HOST` | string | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ | | `REDIS_PASSWORD` | string | | The password of _Redis_ |
| `REDIS_PORT` | | The port where _Redis_ is running | | `REDIS_PORT` | number | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds | | `REQUEST_TIMEOUT` | number (`optional`) | `2000` | The timeout of network requests to data providers in milliseconds |
### Run with Docker Compose ### Run with Docker Compose
@ -144,7 +142,7 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
### Home Server Systems (Community) ### Home Server Systems (Community)
Ghostfolio is available for various home server systems, including [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio). Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Home Assistant](https://github.com/lildude/ha-addon-ghostfolio), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
## Development ## Development
@ -154,7 +152,7 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt
- [Node.js](https://nodejs.org/en/download) (version 18+) - [Node.js](https://nodejs.org/en/download) (version 18+)
- [Yarn](https://yarnpkg.com/en/docs/install) - [Yarn](https://yarnpkg.com/en/docs/install)
- Create a local copy of this Git repository (clone) - Create a local copy of this Git repository (clone)
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`) - Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
### Setup ### Setup
@ -206,7 +204,7 @@ Set the header for each request as follows:
"Authorization": "Bearer eyJh..." "Authorization": "Bearer eyJh..."
``` ```
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ accessToken: <INSERT_SECURITY_TOKEN_OF_ACCOUNT> }`) You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ "accessToken": "<INSERT_SECURITY_TOKEN_OF_ACCOUNT>" }`)
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`. 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>`.

1
apps/api/jest.config.ts

@ -13,7 +13,6 @@ export default {
}, },
moduleFileExtensions: ['ts', 'js', 'html'], moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api', coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000,
testEnvironment: 'node', testEnvironment: 'node',
preset: '../../jest.preset.js' preset: '../../jest.preset.js'
}; };

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

@ -83,7 +83,7 @@ export class AccessController {
} }
try { try {
return await this.accessService.createAccess({ return this.accessService.createAccess({
alias: data.alias || undefined, alias: data.alias || undefined,
GranteeUser: data.granteeUserId GranteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }

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

@ -1,3 +1,4 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -5,6 +6,8 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Body,
Post,
Delete, Delete,
HttpException, HttpException,
Inject, Inject,
@ -17,14 +20,44 @@ import { AccountBalance } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountBalanceService } from './account-balance.service'; import { AccountBalanceService } from './account-balance.service';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Controller('account-balance') @Controller('account-balance')
export class AccountBalanceController { export class AccountBalanceController {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@HasPermission(permissions.createAccountBalance)
@Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createAccountBalance(
@Body() data: CreateAccountBalanceDto
): Promise<AccountBalance> {
const account = await this.accountService.account({
id_userId: {
id: data.accountId,
userId: this.request.user.id
}
});
if (!account) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accountBalanceService.createOrUpdateAccountBalance({
accountId: account.id,
balance: data.balance,
date: data.date,
userId: account.userId
});
}
@HasPermission(permissions.deleteAccountBalance) @HasPermission(permissions.deleteAccountBalance)
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -32,10 +65,11 @@ export class AccountBalanceController {
@Param('id') id: string @Param('id') id: string
): Promise<AccountBalance> { ): Promise<AccountBalance> {
const accountBalance = await this.accountBalanceService.accountBalance({ const accountBalance = await this.accountBalanceService.accountBalance({
id id,
userId: this.request.user.id
}); });
if (!accountBalance || accountBalance.userId !== this.request.user.id) { if (!accountBalance) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -43,7 +77,8 @@ export class AccountBalanceController {
} }
return this.accountBalanceService.deleteAccountBalance({ return this.accountBalanceService.deleteAccountBalance({
id id: accountBalance.id,
userId: accountBalance.userId
}); });
} }
} }

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

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

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

@ -1,15 +1,22 @@
import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor'; import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { resetHours } from '@ghostfolio/common/helper';
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces'; import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AccountBalance, Prisma } from '@prisma/client'; import { AccountBalance, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Injectable() @Injectable()
export class AccountBalanceService { export class AccountBalanceService {
public constructor( public constructor(
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
@ -25,20 +32,63 @@ export class AccountBalanceService {
}); });
} }
public async createAccountBalance( public async createOrUpdateAccountBalance({
data: Prisma.AccountBalanceCreateInput accountId,
): Promise<AccountBalance> { balance,
return this.prismaService.accountBalance.create({ date,
data userId
}: CreateAccountBalanceDto & {
userId: string;
}): Promise<AccountBalance> {
const accountBalance = await this.prismaService.accountBalance.upsert({
create: {
Account: {
connect: {
id_userId: {
userId,
id: accountId
}
}
},
date: resetHours(parseISO(date)),
value: balance
},
update: {
value: balance
},
where: {
accountId_date: {
accountId,
date: resetHours(parseISO(date))
}
}
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId
})
);
return accountBalance;
} }
public async deleteAccountBalance( public async deleteAccountBalance(
where: Prisma.AccountBalanceWhereUniqueInput where: Prisma.AccountBalanceWhereUniqueInput
): Promise<AccountBalance> { ): Promise<AccountBalance> {
return this.prismaService.accountBalance.delete({ const accountBalance = await this.prismaService.accountBalance.delete({
where where
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: <string>where.userId
})
);
return accountBalance;
} }
@LogPerformance @LogPerformance

12
apps/api/src/app/account-balance/create-account-balance.dto.ts

@ -0,0 +1,12 @@
import { IsISO8601, IsNumber, IsUUID } from 'class-validator';
export class CreateAccountBalanceDto {
@IsUUID()
accountId: string;
@IsNumber()
balance: number;
@IsISO8601()
date: string;
}

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

@ -1,11 +1,15 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Account, Order, Platform, Prisma } from '@prisma/client'; import { Account, Order, Platform, Prisma } from '@prisma/client';
import Big from 'big.js'; import { Big } from 'big.js';
import { format } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface'; import { CashDetails } from './interfaces/cash-details.interface';
@ -14,6 +18,7 @@ import { CashDetails } from './interfaces/cash-details.interface';
export class AccountService { export class AccountService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
@ -85,17 +90,20 @@ export class AccountService {
data data
}); });
await this.prismaService.accountBalance.create({ await this.accountBalanceService.createOrUpdateAccountBalance({
data: { accountId: account.id,
Account: { balance: data.balance,
connect: { date: format(new Date(), DATE_FORMAT),
id_userId: { id: account.id, userId: aUserId } userId: aUserId
}
},
value: data.balance
}
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
return account; return account;
} }
@ -103,9 +111,18 @@ export class AccountService {
where: Prisma.AccountWhereUniqueInput, where: Prisma.AccountWhereUniqueInput,
aUserId: string aUserId: string
): Promise<Account> { ): Promise<Account> {
return this.prismaService.account.delete({ const account = await this.prismaService.account.delete({
where where
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
return account;
} }
public async getAccounts(aUserId: string): Promise<Account[]> { public async getAccounts(aUserId: string): Promise<Account[]> {
@ -196,21 +213,26 @@ export class AccountService {
): Promise<Account> { ): Promise<Account> {
const { data, where } = params; const { data, where } = params;
await this.prismaService.accountBalance.create({ await this.accountBalanceService.createOrUpdateAccountBalance({
data: { accountId: <string>data.id,
Account: { balance: <number>data.balance,
connect: { date: format(new Date(), DATE_FORMAT),
id_userId: where.id_userId userId: aUserId
}
},
value: <number>data.balance
}
}); });
return this.prismaService.account.update({ const account = await this.prismaService.account.update({
data, data,
where where
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
return account;
} }
public async updateAccountBalance({ public async updateAccountBalance({
@ -242,17 +264,11 @@ export class AccountService {
); );
if (amountInCurrencyOfAccount) { if (amountInCurrencyOfAccount) {
await this.accountBalanceService.createAccountBalance({ await this.accountBalanceService.createOrUpdateAccountBalance({
date, accountId,
Account: { userId,
connect: { balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber(),
id_userId: { date: date.toISOString()
userId,
id: accountId
}
}
},
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
}); });
} }
} }

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

@ -7,13 +7,12 @@ import { ManualService } from '@ghostfolio/api/services/data-provider/manual/man
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
getAssetProfileIdentifier,
resetHours
} from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
@ -94,7 +93,8 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }) jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
} }
}; };
}) })
@ -119,7 +119,8 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }) jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
} }
}; };
}) })
@ -141,7 +142,8 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }) jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
} }
}); });
} }
@ -325,45 +327,16 @@ export class AdminController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<EnhancedSymbolProfile> { ): Promise<EnhancedSymbolProfile> {
if (dataSource === 'MANUAL') { return this.adminService.patchAssetProfileData({
await this.adminService.patchAssetProfileData({ ...assetProfileData,
dataSource, dataSource,
symbol, symbol,
tags: { tags: {
set: [] connect: assetProfileData.tags?.map(({ id }) => {
} return { id };
}); })
}
return this.adminService.patchAssetProfileData({ });
...assetProfileData,
dataSource,
symbol,
tags: {
connect: assetProfileData.tags?.map(({ id }) => {
return { id };
})
}
});
} else {
await this.adminService.patchAssetProfileData({
dataSource,
symbol,
tags: {
set: []
}
});
return this.adminService.patchAssetProfileData({
...assetProfileData,
dataSource,
symbol,
tags: {
connect: assetProfileData.tags?.map(({ id }) => {
return { id };
})
}
});
}
} }
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@ -373,6 +346,6 @@ export class AdminController {
@Param('key') key: string, @Param('key') key: string,
@Body() data: PropertyDto @Body() data: PropertyDto
) { ) {
return await this.adminService.putSetting(key, data.value); return this.adminService.putSetting(key, data.value);
} }
} }

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

@ -26,6 +26,7 @@ import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { import {
AssetClass,
AssetSubClass, AssetSubClass,
Prisma, Prisma,
Property, Property,
@ -75,7 +76,7 @@ export class AdminService {
); );
} }
return await this.symbolProfileService.add( return this.symbolProfileService.add(
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
); );
} catch (error) { } catch (error) {
@ -216,6 +217,7 @@ export class AdminService {
countries: true, countries: true,
currency: true, currency: true,
dataSource: true, dataSource: true,
id: true,
name: true, name: true,
Order: { Order: {
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
@ -240,6 +242,7 @@ export class AdminService {
countries, countries,
currency, currency,
dataSource, dataSource,
id,
name, name,
Order, Order,
sectors, sectors,
@ -263,6 +266,7 @@ export class AdminService {
currency, currency,
countriesCount, countriesCount,
dataSource, dataSource,
id,
name, name,
symbol, symbol,
marketDataItemCount, marketDataItemCount,
@ -337,7 +341,8 @@ export class AdminService {
scraperConfiguration, scraperConfiguration,
sectors, sectors,
symbol, symbol,
symbolMapping symbolMapping,
url
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) { }: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
if (dataSource === 'MANUAL') { if (dataSource === 'MANUAL') {
await this.symbolProfileService.updateSymbolProfile({ await this.symbolProfileService.updateSymbolProfile({
@ -453,9 +458,10 @@ export class AdminService {
dataSource, dataSource,
marketDataItemCount, marketDataItemCount,
symbol, symbol,
assetClass: 'CASH', assetClass: AssetClass.LIQUIDITY,
countriesCount: 0, countriesCount: 0,
currency: symbol.replace(DEFAULT_CURRENCY, ''), currency: symbol.replace(DEFAULT_CURRENCY, ''),
id: undefined,
name: symbol, name: symbol,
sectorsCount: 0, sectorsCount: 0,
tags: [] tags: []
@ -500,13 +506,14 @@ export class AdminService {
}, },
createdAt: true, createdAt: true,
id: true, id: true,
role: true,
Subscription: true Subscription: true
}, },
take: 30 take: 30
}); });
return usersWithAnalytics.map( return usersWithAnalytics.map(
({ _count, Analytics, createdAt, id, Subscription }) => { ({ _count, Analytics, createdAt, id, role, Subscription }) => {
const daysSinceRegistration = const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1; differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics const engagement = Analytics
@ -526,6 +533,7 @@ export class AdminService {
createdAt, createdAt,
engagement, engagement,
id, id,
role,
subscription, subscription,
accountCount: _count.Account || 0, accountCount: _count.Account || 0,
country: Analytics?.country, country: Analytics?.country,

7
apps/api/src/app/admin/queue/queue.controller.ts

@ -46,4 +46,11 @@ export class QueueController {
public async deleteJob(@Param('id') id: string): Promise<void> { public async deleteJob(@Param('id') id: string): Promise<void> {
return this.queueService.deleteJob(id); return this.queueService.deleteJob(id);
} }
@Get('job/:id/execute')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async executeJob(@Param('id') id: string): Promise<void> {
return this.queueService.executeJob(id);
}
} }

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

@ -32,6 +32,10 @@ export class QueueService {
} }
} }
public async executeJob(aId: string) {
return (await this.dataGatheringQueue.getJob(aId))?.promote();
}
public async getJobs({ public async getJobs({
limit = 1000, limit = 1000,
status = QUEUE_JOB_STATUS_LIST status = QUEUE_JOB_STATUS_LIST
@ -54,6 +58,7 @@ export class QueueService {
finishedOn: job.finishedOn, finishedOn: job.finishedOn,
id: job.id, id: job.id,
name: job.name, name: job.name,
opts: job.opts,
stacktrace: job.stacktrace, stacktrace: job.stacktrace,
state: await job.getState(), state: await job.getState(),
timestamp: job.timestamp timestamp: job.timestamp

10
apps/api/src/app/admin/update-asset-profile.dto.ts

@ -5,7 +5,8 @@ import {
IsISO4217CurrencyCode, IsISO4217CurrencyCode,
IsObject, IsObject,
IsOptional, IsOptional,
IsString IsString,
IsUrl
} from 'class-validator'; } from 'class-validator';
export class UpdateAssetProfileDto { export class UpdateAssetProfileDto {
@ -50,4 +51,11 @@ export class UpdateAssetProfileDto {
symbolMapping?: { symbolMapping?: {
[dataProvider: string]: string; [dataProvider: string]: string;
}; };
@IsOptional()
@IsUrl({
protocols: ['https'],
require_protocol: true
})
url?: string;
} }

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

@ -1,3 +1,4 @@
import { EventsModule } from '@ghostfolio/api/events/events.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CronService } from '@ghostfolio/api/services/cron.service'; import { CronService } from '@ghostfolio/api/services/cron.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -14,6 +15,7 @@ import {
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
@ -44,6 +46,7 @@ import { TagModule } from './tag/tag.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@Module({ @Module({
controllers: [AppController],
imports: [ imports: [
AdminModule, AdminModule,
AccessModule, AccessModule,
@ -64,6 +67,8 @@ import { UserModule } from './user/user.module';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
EventEmitterModule.forRoot(),
EventsModule,
ExchangeRateModule, ExchangeRateModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ExportModule, ExportModule,
@ -109,7 +114,6 @@ import { UserModule } from './user/user.module';
TwitterBotModule, TwitterBotModule,
UserModule UserModule
], ],
controllers: [AppController],
providers: [CronService] providers: [CronService]
}) })
export class AppModule {} export class AppModule {}

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

@ -2,10 +2,12 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config'; import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
import { hasRole } from '@ghostfolio/common/permissions';
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import * as countriesAndTimezones from 'countries-and-timezones'; import * as countriesAndTimezones from 'countries-and-timezones';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable() @Injectable()
@ -29,6 +31,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
if (user) { if (user) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (hasRole(user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
const country = const country =
countriesAndTimezones.getCountryForTimezone(timezone)?.id; countriesAndTimezones.getCountryForTimezone(timezone)?.id;
@ -45,10 +54,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
return user; return user;
} else { } else {
throw ''; throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
} catch (error) {
if (error?.getStatus() === StatusCodes.TOO_MANY_REQUESTS) {
throw error;
} else {
throw new HttpException(
getReasonPhrase(StatusCodes.UNAUTHORIZED),
StatusCodes.UNAUTHORIZED
);
} }
} catch (err) {
throw new UnauthorizedException('unauthorized', err.message);
} }
} }
} }

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

@ -41,7 +41,7 @@ export class WebAuthService {
) {} ) {}
get rpID() { get rpID() {
return this.configurationService.get('WEB_AUTH_RP_ID'); return new URL(this.configurationService.get('ROOT_URL')).hostname;
} }
get expectedOrigin() { get expectedOrigin() {

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

@ -1,5 +1,6 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import type { import type {
@ -8,7 +9,7 @@ import type {
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -19,6 +20,7 @@ import {
Inject, Inject,
Param, Param,
Post, Post,
Query,
UseGuards, UseGuards,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
@ -106,13 +108,18 @@ export class BenchmarkController {
public async getBenchmarkMarketDataBySymbol( public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string, @Param('startDateString') startDateString: string,
@Param('symbol') symbol: string @Param('symbol') symbol: string,
@Query('range') dateRange: DateRange = 'max'
): Promise<BenchmarkMarketDataDetails> { ): Promise<BenchmarkMarketDataDetails> {
const startDate = new Date(startDateString); const { endDate, startDate } = getInterval(
dateRange,
new Date(startDateString)
);
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.Settings.settings.baseCurrency;
return this.benchmarkService.getMarketDataBySymbol({ return this.benchmarkService.getMarketDataBySymbol({
dataSource, dataSource,
endDate,
startDate, startDate,
symbol, symbol,
userCurrency userCurrency

54
apps/api/src/app/benchmark/benchmark.service.ts

@ -13,7 +13,8 @@ import {
import { import {
DATE_FORMAT, DATE_FORMAT,
calculateBenchmarkTrend, calculateBenchmarkTrend,
parseDate parseDate,
resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
Benchmark, Benchmark,
@ -26,8 +27,14 @@ import { BenchmarkTrend } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import Big from 'big.js'; import { Big } from 'big.js';
import { format, isSameDay, subDays } from 'date-fns'; import {
differenceInDays,
eachDayOfInterval,
format,
isSameDay,
subDays
} from 'date-fns';
import { isNumber, last, uniqBy } from 'lodash'; import { isNumber, last, uniqBy } from 'lodash';
import ms from 'ms'; import ms from 'ms';
@ -110,7 +117,9 @@ export class BenchmarkService {
const quotes = await this.dataProviderService.getQuotes({ const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => { items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
}) }),
requestTimeout: ms('30 seconds'),
useCache: false
}); });
for (const { dataSource, symbol } of benchmarkAssetProfiles) { for (const { dataSource, symbol } of benchmarkAssetProfiles) {
@ -163,7 +172,7 @@ export class BenchmarkService {
await this.redisCacheService.set( await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS, this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks), JSON.stringify(benchmarks),
ms('4 hours') / 1000 ms('2 hours') / 1000
); );
} }
@ -206,15 +215,28 @@ export class BenchmarkService {
public async getMarketDataBySymbol({ public async getMarketDataBySymbol({
dataSource, dataSource,
endDate = new Date(),
startDate, startDate,
symbol, symbol,
userCurrency userCurrency
}: { }: {
endDate?: Date;
startDate: Date; startDate: Date;
userCurrency: string; userCurrency: string;
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> { } & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
const marketData: { date: string; value: number }[] = []; const marketData: { date: string; value: number }[] = [];
const days = differenceInDays(endDate, startDate) + 1;
const dates = eachDayOfInterval(
{
start: startDate,
end: endDate
},
{ step: Math.round(days / Math.min(days, MAX_CHART_ITEMS)) }
).map((date) => {
return resetHours(date);
});
const [currentSymbolItem, marketDataItems] = await Promise.all([ const [currentSymbolItem, marketDataItems] = await Promise.all([
this.symbolService.get({ this.symbolService.get({
dataGatheringItem: { dataGatheringItem: {
@ -230,7 +252,7 @@ export class BenchmarkService {
dataSource, dataSource,
symbol, symbol,
date: { date: {
gte: startDate in: dates
} }
} }
}) })
@ -264,17 +286,7 @@ export class BenchmarkService {
return { marketData }; return { marketData };
} }
const step = Math.round(
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
);
let i = 0;
for (let marketDataItem of marketDataItems) { for (let marketDataItem of marketDataItems) {
if (i % step !== 0) {
continue;
}
const exchangeRate = const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(marketDataItem.date, DATE_FORMAT) format(marketDataItem.date, DATE_FORMAT)
@ -297,15 +309,15 @@ export class BenchmarkService {
}); });
} }
const includesToday = isSameDay( const includesEndDate = isSameDay(
parseDate(last(marketData).date), parseDate(last(marketData).date),
new Date() endDate
); );
if (currentSymbolItem?.marketPrice && !includesToday) { if (currentSymbolItem?.marketPrice && !includesEndDate) {
const exchangeRate = const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(new Date(), DATE_FORMAT) format(endDate, DATE_FORMAT)
]; ];
const exchangeRateFactor = const exchangeRateFactor =
@ -314,7 +326,7 @@ export class BenchmarkService {
: 1; : 1;
marketData.push({ marketData.push({
date: format(new Date(), DATE_FORMAT), date: format(endDate, DATE_FORMAT),
value: value:
this.calculateChangeInPercentage( this.calculateChangeInPercentage(
marketPriceAtStartDate, marketPriceAtStartDate,

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

@ -95,7 +95,10 @@ export class ExportService {
: SymbolProfile.symbol : SymbolProfile.symbol
}; };
} }
) ),
user: {
settings: { currency: userCurrency }
}
}; };
} }
} }

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

@ -43,8 +43,10 @@ export class ImportController {
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async import( public async import(
@Body() importData: ImportDataDto, @Body() importData: ImportDataDto,
@Query('dryRun') isDryRun = false @Query('dryRun') isDryRunParam = 'false'
): Promise<ImportResponse> { ): Promise<ImportResponse> {
const isDryRun = isDryRunParam === 'true';
if ( if (
!hasPermission(this.request.user.permissions, permissions.createAccount) !hasPermission(this.request.user.permissions, permissions.createAccount)
) { ) {

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

@ -13,6 +13,10 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/da
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
} from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
getAssetProfileIdentifier, getAssetProfileIdentifier,
@ -27,7 +31,7 @@ import {
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import Big from 'big.js'; import { Big } from 'big.js';
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns'; import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
import { uniqBy } from 'lodash'; import { uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -112,12 +116,13 @@ export class ImportService {
accountId: Account?.id, accountId: Account?.id,
accountUserId: undefined, accountUserId: undefined,
comment: undefined, comment: undefined,
currency: undefined,
createdAt: undefined, createdAt: undefined,
fee: 0, fee: 0,
feeInBaseCurrency: 0, feeInBaseCurrency: 0,
id: assetProfile.id, id: assetProfile.id,
isDraft: false, isDraft: false,
SymbolProfile: <SymbolProfile>(<unknown>assetProfile), SymbolProfile: assetProfile,
symbolProfileId: assetProfile.id, symbolProfileId: assetProfile.id,
type: 'DIVIDEND', type: 'DIVIDEND',
unitPrice: marketPrice, unitPrice: marketPrice,
@ -261,6 +266,7 @@ export class ImportService {
{ {
accountId, accountId,
comment, comment,
currency,
date, date,
error, error,
fee, fee,
@ -285,7 +291,6 @@ export class ImportService {
assetSubClass, assetSubClass,
countries, countries,
createdAt, createdAt,
currency,
dataSource, dataSource,
figi, figi,
figiComposite, figiComposite,
@ -342,6 +347,7 @@ export class ImportService {
if (isDryRun) { if (isDryRun) {
order = { order = {
comment, comment,
currency,
date, date,
fee, fee,
quantity, quantity,
@ -357,7 +363,6 @@ export class ImportService {
assetSubClass, assetSubClass,
countries, countries,
createdAt, createdAt,
currency,
dataSource, dataSource,
figi, figi,
figiComposite, figiComposite,
@ -371,6 +376,7 @@ export class ImportService {
symbolMapping, symbolMapping,
updatedAt, updatedAt,
url, url,
currency: assetProfile.currency,
comment: assetProfile.comment comment: assetProfile.comment
}, },
Account: validatedAccount, Account: validatedAccount,
@ -394,9 +400,9 @@ export class ImportService {
SymbolProfile: { SymbolProfile: {
connectOrCreate: { connectOrCreate: {
create: { create: {
currency,
dataSource, dataSource,
symbol symbol,
currency: assetProfile.currency
}, },
where: { where: {
dataSource_symbol: { dataSource_symbol: {
@ -410,6 +416,11 @@ export class ImportService {
User: { connect: { id: user.id } }, User: { connect: { id: user.id } },
userId: user.id userId: user.id
}); });
if (order.SymbolProfile?.symbol) {
// Update symbol that may have been assigned in createOrder()
assetProfile.symbol = order.SymbolProfile.symbol;
}
} }
const value = new Big(quantity).mul(unitPrice).toNumber(); const value = new Big(quantity).mul(unitPrice).toNumber();
@ -420,14 +431,14 @@ export class ImportService {
value, value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency( feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee, fee,
currency, assetProfile.currency,
userCurrency userCurrency
), ),
// @ts-ignore // @ts-ignore
SymbolProfile: assetProfile, SymbolProfile: assetProfile,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value, value,
currency, assetProfile.currency,
userCurrency userCurrency
) )
}); });
@ -446,15 +457,16 @@ export class ImportService {
}); });
}); });
this.dataGatheringService.gatherSymbols( this.dataGatheringService.gatherSymbols({
uniqueActivities.map(({ date, SymbolProfile }) => { dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => {
return { return {
date, date,
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol symbol: SymbolProfile.symbol
}; };
}) }),
); priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
} }
return activities; return activities;
@ -521,22 +533,14 @@ export class ImportService {
currency, currency,
dataSource, dataSource,
symbol, symbol,
assetClass: null, activitiesCount: undefined,
assetSubClass: null, assetClass: undefined,
comment: null, assetSubClass: undefined,
countries: null, countries: undefined,
createdAt: undefined, createdAt: undefined,
figi: null,
figiComposite: null,
figiShareClass: null,
id: undefined, id: undefined,
isin: null, sectors: undefined,
name: null, updatedAt: undefined
scraperConfiguration: null,
sectors: null,
symbolMapping: null,
updatedAt: undefined,
url: null
} }
}; };
} }
@ -605,7 +609,12 @@ export class ImportService {
)?.[symbol] )?.[symbol]
}; };
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') { if (
type === 'BUY' ||
type === 'DIVIDEND' ||
type === 'SELL' ||
type === 'STAKE'
) {
if (!assetProfile?.name) { if (!assetProfile?.name) {
throw new Error( throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`

4
apps/api/src/app/order/create-order.dto.ts

@ -42,6 +42,10 @@ export class CreateOrderDto {
@IsISO4217CurrencyCode() @IsISO4217CurrencyCode()
currency: string; currency: string;
@IsISO4217CurrencyCode()
@IsOptional()
customCurrency?: string;
@IsOptional() @IsOptional()
@IsEnum(DataSource, { each: true }) @IsEnum(DataSource, { each: true })
dataSource?: DataSource; dataSource?: DataSource;

10
apps/api/src/app/order/interfaces/activities.interface.ts

@ -1,13 +1,19 @@
import { OrderWithAccount } from '@ghostfolio/common/types'; import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces';
import { AccountWithPlatform } from '@ghostfolio/common/types';
import { Order, Tag } from '@prisma/client';
export interface Activities { export interface Activities {
activities: Activity[]; activities: Activity[];
count: number; count: number;
} }
export interface Activity extends OrderWithAccount { export interface Activity extends Order {
Account?: AccountWithPlatform;
error?: ActivityError; error?: ActivityError;
feeInBaseCurrency: number; feeInBaseCurrency: number;
SymbolProfile?: EnhancedSymbolProfile;
tags?: Tag[];
updateAccountBalance?: boolean; updateAccountBalance?: boolean;
value: number; value: number;
valueInBaseCurrency: number; valueInBaseCurrency: number;

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

@ -1,14 +1,18 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -56,15 +60,15 @@ export class OrderController {
} }
@Delete(':id') @Delete(':id')
@HasPermission(permissions.deleteOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> { public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
const order = await this.orderService.order({ id }); const order = await this.orderService.order({
id,
userId: this.request.user.id
});
if ( if (!order) {
!hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
!order ||
order.userId !== this.request.user.id
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -84,12 +88,20 @@ export class OrderController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange?: DateRange,
@Query('skip') skip?: number, @Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string, @Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder, @Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('take') take?: number @Query('take') take?: number
): Promise<Activities> { ): Promise<Activities> {
let endDate: Date;
let startDate: Date;
if (dateRange) {
({ endDate, startDate } = getInterval(dateRange));
}
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
@ -101,9 +113,11 @@ export class OrderController {
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.Settings.settings.baseCurrency;
const { activities, count } = await this.orderService.getOrders({ const { activities, count } = await this.orderService.getOrders({
endDate,
filters, filters,
sortColumn, sortColumn,
sortDirection, sortDirection,
startDate,
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
skip: isNaN(skip) ? undefined : skip, skip: isNaN(skip) ? undefined : skip,
@ -120,13 +134,22 @@ export class OrderController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> { public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
const currency = data.currency;
const customCurrency = data.customCurrency;
if (customCurrency) {
data.currency = customCurrency;
delete data.customCurrency;
}
const order = await this.orderService.createOrder({ const order = await this.orderService.createOrder({
...data, ...data,
date: parseISO(data.date), date: parseISO(data.date),
SymbolProfile: { SymbolProfile: {
connectOrCreate: { connectOrCreate: {
create: { create: {
currency: data.currency, currency,
dataSource: data.dataSource, dataSource: data.dataSource,
symbol: data.symbol symbol: data.symbol
}, },
@ -145,13 +168,16 @@ export class OrderController {
if (data.dataSource && !order.isDraft) { if (data.dataSource && !order.isDraft) {
// Gather symbol data in the background, if data source is set // Gather symbol data in the background, if data source is set
// (not MANUAL) and not draft // (not MANUAL) and not draft
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols({
{ dataGatheringItems: [
dataSource: data.dataSource, {
date: order.date, dataSource: data.dataSource,
symbol: data.symbol date: order.date,
} symbol: data.symbol
]); }
],
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
} }
return order; return order;
@ -176,8 +202,16 @@ export class OrderController {
const date = parseISO(data.date); const date = parseISO(data.date);
const accountId = data.accountId; const accountId = data.accountId;
const customCurrency = data.customCurrency;
delete data.accountId; delete data.accountId;
if (customCurrency) {
data.currency = customCurrency;
delete data.customCurrency;
}
return this.orderService.updateOrder({ return this.orderService.updateOrder({
data: { data: {
...data, ...data,

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

@ -1,9 +1,11 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -12,6 +14,7 @@ import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -19,11 +22,11 @@ import {
Order, Order,
Prisma, Prisma,
Tag, Tag,
Type as TypeOfOrder Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import Big from 'big.js'; import { Big } from 'big.js';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Activities } from './interfaces/activities.interface'; import { Activities } from './interfaces/activities.interface';
@ -33,6 +36,7 @@ export class OrderService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
@ -65,20 +69,13 @@ export class OrderService {
} }
const accountId = data.accountId; const accountId = data.accountId;
let currency = data.currency;
const tags = data.tags ?? []; const tags = data.tags ?? [];
const updateAccountBalance = data.updateAccountBalance ?? false; const updateAccountBalance = data.updateAccountBalance ?? false;
const userId = data.userId; const userId = data.userId;
if ( if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
data.type === 'FEE' ||
data.type === 'INTEREST' ||
data.type === 'ITEM' ||
data.type === 'LIABILITY'
) {
const assetClass = data.assetClass; const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass; const assetSubClass = data.assetSubClass;
currency = data.SymbolProfile.connectOrCreate.create.currency;
const dataSource: DataSource = 'MANUAL'; const dataSource: DataSource = 'MANUAL';
const id = uuidv4(); const id = uuidv4();
const name = data.SymbolProfile.connectOrCreate.create.symbol; const name = data.SymbolProfile.connectOrCreate.create.symbol;
@ -86,7 +83,6 @@ export class OrderService {
data.id = id; data.id = id;
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass; data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass; data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
data.SymbolProfile.connectOrCreate.create.currency = currency;
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource; data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
data.SymbolProfile.connectOrCreate.create.name = name; data.SymbolProfile.connectOrCreate.create.name = name;
data.SymbolProfile.connectOrCreate.create.symbol = id; data.SymbolProfile.connectOrCreate.create.symbol = id;
@ -108,7 +104,8 @@ export class OrderService {
jobId: getAssetProfileIdentifier({ jobId: getAssetProfileIdentifier({
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol symbol: data.SymbolProfile.connectOrCreate.create.symbol
}) }),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
} }
}); });
} }
@ -121,7 +118,6 @@ export class OrderService {
delete data.comment; delete data.comment;
} }
delete data.currency;
delete data.dataSource; delete data.dataSource;
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;
@ -130,13 +126,9 @@ export class OrderService {
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
const isDraft = const isDraft = ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)
data.type === 'FEE' || ? false
data.type === 'INTEREST' || : isAfter(data.date as Date, endOfToday());
data.type === 'ITEM' ||
data.type === 'LIABILITY'
? false
: isAfter(data.date as Date, endOfToday());
const order = await this.prismaService.order.create({ const order = await this.prismaService.order.create({
data: { data: {
@ -148,7 +140,8 @@ export class OrderService {
return { id }; return { id };
}) })
} }
} },
include: { SymbolProfile: true }
}); });
if (updateAccountBalance === true) { if (updateAccountBalance === true) {
@ -157,19 +150,26 @@ export class OrderService {
.plus(data.fee) .plus(data.fee)
.toNumber(); .toNumber();
if (data.type === 'BUY') { if (['BUY', 'FEE'].includes(data.type)) {
amount = new Big(amount).mul(-1).toNumber(); amount = new Big(amount).mul(-1).toNumber();
} }
await this.accountService.updateAccountBalance({ await this.accountService.updateAccountBalance({
accountId, accountId,
amount, amount,
currency,
userId, userId,
currency: data.SymbolProfile.connectOrCreate.create.currency,
date: data.date as Date date: data.date as Date
}); });
} }
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
})
);
return order; return order;
} }
@ -180,15 +180,17 @@ export class OrderService {
where where
}); });
if ( if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type)) {
order.type === 'FEE' ||
order.type === 'INTEREST' ||
order.type === 'ITEM' ||
order.type === 'LIABILITY'
) {
await this.symbolProfileService.deleteById(order.symbolProfileId); await this.symbolProfileService.deleteById(order.symbolProfileId);
} }
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
})
);
return order; return order;
} }
@ -197,6 +199,13 @@ export class OrderService {
where where
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: <string>where.userId
})
);
return count; return count;
} }
@ -212,24 +221,28 @@ export class OrderService {
} }
public async getOrders({ public async getOrders({
endDate,
filters, filters,
includeDrafts = false, includeDrafts = false,
skip, skip,
sortColumn, sortColumn,
sortDirection, sortDirection,
startDate,
take = Number.MAX_SAFE_INTEGER, take = Number.MAX_SAFE_INTEGER,
types, types,
userCurrency, userCurrency,
userId, userId,
withExcludedAccounts = false withExcludedAccounts = false
}: { }: {
endDate?: Date;
filters?: Filter[]; filters?: Filter[];
includeDrafts?: boolean; includeDrafts?: boolean;
skip?: number; skip?: number;
sortColumn?: string; sortColumn?: string;
sortDirection?: Prisma.SortOrder; sortDirection?: Prisma.SortOrder;
startDate?: Date;
take?: number; take?: number;
types?: TypeOfOrder[]; types?: ActivityType[];
userCurrency: string; userCurrency: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
@ -239,6 +252,18 @@ export class OrderService {
]; ];
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
if (endDate || startDate) {
where.AND = [];
if (endDate) {
where.AND.push({ date: { lte: endDate } });
}
if (startDate) {
where.AND.push({ date: { gt: startDate } });
}
}
const { const {
ACCOUNT: filtersByAccount, ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass, ASSET_CLASS: filtersByAssetClass,
@ -359,17 +384,45 @@ export class OrderService {
this.prismaService.order.count({ where }) this.prismaService.order.count({ where })
]); ]);
const uniqueAssets = uniqBy(
orders.map(({ SymbolProfile }) => {
return {
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
}),
({ dataSource, symbol }) => {
return getAssetProfileIdentifier({
dataSource,
symbol
});
}
);
const assetProfiles =
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
const activities = orders.map((order) => { const activities = orders.map((order) => {
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => {
return (
dataSource === order.SymbolProfile.dataSource &&
symbol === order.SymbolProfile.symbol
);
});
const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
return { return {
...order, ...order,
value, value,
// TODO: Use exchange rate of date
feeInBaseCurrency: this.exchangeRateDataService.toCurrency( feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
order.fee, order.fee,
order.SymbolProfile.currency, order.SymbolProfile.currency,
userCurrency userCurrency
), ),
SymbolProfile: assetProfile,
// TODO: Use exchange rate of date
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value, value,
order.SymbolProfile.currency, order.SymbolProfile.currency,
@ -400,13 +453,10 @@ export class OrderService {
dataSource?: DataSource; dataSource?: DataSource;
symbol?: string; symbol?: string;
tags?: Tag[]; tags?: Tag[];
type?: ActivityType;
}; };
where: Prisma.OrderWhereUniqueInput; where: Prisma.OrderWhereUniqueInput;
}): Promise<Order> { }): Promise<Order> {
if (data.Account.connect.id_userId.id === null) {
delete data.Account;
}
if (!data.comment) { if (!data.comment) {
data.comment = null; data.comment = null;
} }
@ -415,13 +465,12 @@ export class OrderService {
let isDraft = false; let isDraft = false;
if ( if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
data.type === 'FEE' ||
data.type === 'INTEREST' ||
data.type === 'ITEM' ||
data.type === 'LIABILITY'
) {
delete data.SymbolProfile.connect; delete data.SymbolProfile.connect;
if (data.Account?.connect?.id_userId?.id === null) {
data.Account = { disconnect: true };
}
} else { } else {
delete data.SymbolProfile.update; delete data.SymbolProfile.update;
@ -429,19 +478,22 @@ export class OrderService {
if (!isDraft) { if (!isDraft) {
// Gather symbol data of order in the background, if not draft // Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols({
{ dataGatheringItems: [
dataSource: data.SymbolProfile.connect.dataSource_symbol.dataSource, {
date: <Date>data.date, dataSource:
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol data.SymbolProfile.connect.dataSource_symbol.dataSource,
} date: <Date>data.date,
]); symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
}
],
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
} }
} }
delete data.assetClass; delete data.assetClass;
delete data.assetSubClass; delete data.assetSubClass;
delete data.currency;
delete data.dataSource; delete data.dataSource;
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;
@ -452,7 +504,7 @@ export class OrderService {
where where
}); });
return this.prismaService.order.update({ const order = await this.prismaService.order.update({
data: { data: {
...data, ...data,
isDraft, isDraft,
@ -464,6 +516,15 @@ export class OrderService {
}, },
where where
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
})
);
return order;
} }
private async orders(params: { private async orders(params: {

4
apps/api/src/app/order/update-order.dto.ts

@ -41,6 +41,10 @@ export class UpdateOrderDto {
@IsISO4217CurrencyCode() @IsISO4217CurrencyCode()
currency: string; currency: string;
@IsISO4217CurrencyCode()
@IsOptional()
customCurrency?: string;
@IsString() @IsString()
dataSource: DataSource; dataSource: DataSource;

7
apps/api/src/app/platform/create-platform.dto.ts

@ -1,9 +1,12 @@
import { IsString } from 'class-validator'; import { IsString, IsUrl } from 'class-validator';
export class CreatePlatformDto { export class CreatePlatformDto {
@IsString() @IsString()
name: string; name: string;
@IsString() @IsUrl({
protocols: ['https'],
require_protocol: true
})
url: string; url: string;
} }

7
apps/api/src/app/platform/update-platform.dto.ts

@ -1,4 +1,4 @@
import { IsString } from 'class-validator'; import { IsString, IsUrl } from 'class-validator';
export class UpdatePlatformDto { export class UpdatePlatformDto {
@IsString() @IsString()
@ -7,6 +7,9 @@ export class UpdatePlatformDto {
@IsString() @IsString()
name: string; name: string;
@IsString() @IsUrl({
protocols: ['https'],
require_protocol: true
})
url: string; url: string;
} }

302
apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts

@ -0,0 +1,302 @@
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import {
HistoricalDataItem,
SymbolMetrics,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { Big } from 'big.js';
import { addDays, eachDayOfInterval, format } from 'date-fns';
import { PortfolioOrder } from '../../interfaces/portfolio-order.interface';
import { TWRPortfolioCalculator } from '../twr/portfolio-calculator';
export class CPRPortfolioCalculator extends TWRPortfolioCalculator {
private holdings: { [date: string]: { [symbol: string]: Big } } = {};
private holdingCurrencies: { [symbol: string]: string } = {};
protected calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot {
return super.calculateOverallPerformance(positions);
}
protected getSymbolMetrics({
dataSource,
end,
exchangeRates,
isChartMode = false,
marketSymbolMap,
start,
step = 1,
symbol
}: {
end: Date;
exchangeRates: { [dateString: string]: number };
isChartMode?: boolean;
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
step?: number;
} & UniqueAsset): SymbolMetrics {
return super.getSymbolMetrics({
dataSource,
end,
exchangeRates,
isChartMode,
marketSymbolMap,
start,
step,
symbol
});
}
public override async getChartData({
end = new Date(Date.now()),
start,
step = 1
}: {
end?: Date;
start: Date;
step?: number;
}): Promise<HistoricalDataItem[]> {
const timelineHoldings = this.getHoldings(start, end);
const calculationDates = Object.keys(timelineHoldings)
.filter((date) => {
let parsed = parseDate(date);
parsed >= start && parsed <= end;
})
.sort();
let data: HistoricalDataItem[] = [];
const startString = format(start, DATE_FORMAT);
data.push({
date: startString,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
let totalInvestment = Object.keys(timelineHoldings[startString]).reduce(
(sum, holding) => {
return sum.plus(
timelineHoldings[startString][holding].mul(
this.marketMap[startString][holding]
)
);
},
new Big(0)
);
let previousNetPerformanceInPercentage = new Big(0);
let previousNetPerformanceInPercentageWithCurrencyEffect = new Big(0);
for (let i = 1; i < calculationDates.length; i++) {
const date = calculationDates[i];
const previousDate = calculationDates[i - 1];
const holdings = timelineHoldings[previousDate];
let newTotalInvestment = new Big(0);
let netPerformanceInPercentage = new Big(0);
let netPerformanceInPercentageWithCurrencyEffect = new Big(0);
for (const holding of Object.keys(holdings)) {
({
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment
} = await this.handleSingleHolding(
previousDate,
holding,
date,
totalInvestment,
timelineHoldings,
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment
));
totalInvestment = newTotalInvestment;
}
previousNetPerformanceInPercentage =
previousNetPerformanceInPercentage.mul(
netPerformanceInPercentage.plus(1)
);
previousNetPerformanceInPercentageWithCurrencyEffect =
previousNetPerformanceInPercentageWithCurrencyEffect.mul(
netPerformanceInPercentageWithCurrencyEffect.plus(1)
);
data.push({
date,
netPerformanceInPercentage:
previousNetPerformanceInPercentage.toNumber(),
netPerformanceInPercentageWithCurrencyEffect:
previousNetPerformanceInPercentageWithCurrencyEffect.toNumber()
});
}
return data;
}
private async handleSingleHolding(
previousDate: string,
holding: string,
date: string,
totalInvestment,
timelineHoldings: { [date: string]: { [symbol: string]: Big } },
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment
) {
const previousPrice = this.marketMap[previousDate][holding];
const currentPrice = this.marketMap[date][holding];
const previousPriceInBaseCurrency =
await this.exchangeRateDataService.toCurrencyAtDate(
previousPrice.toNumber(),
this.getCurrency(holding),
this.currency,
parseDate(previousDate)
);
const portfolioWeight = totalInvestment
? timelineHoldings[previousDate][holding]
.mul(previousPriceInBaseCurrency)
.div(totalInvestment)
: 0;
netPerformanceInPercentage = netPerformanceInPercentage.plus(
currentPrice.div(previousPrice).minus(1).mul(portfolioWeight)
);
const priceInBaseCurrency =
await this.exchangeRateDataService.toCurrencyAtDate(
currentPrice.toNumber(),
this.getCurrency(holding),
this.currency,
parseDate(date)
);
netPerformanceInPercentageWithCurrencyEffect =
netPerformanceInPercentageWithCurrencyEffect.plus(
new Big(priceInBaseCurrency)
.div(new Big(previousPriceInBaseCurrency))
.minus(1)
.mul(portfolioWeight)
);
newTotalInvestment = newTotalInvestment.plus(
timelineHoldings[date][holding].mul(priceInBaseCurrency)
);
return {
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment
};
}
private getCurrency(symbol: string) {
if (!this.holdingCurrencies[symbol]) {
this.holdingCurrencies[symbol] = this.activities.find(
(a) => a.SymbolProfile.symbol === symbol
).SymbolProfile.currency;
}
return this.holdingCurrencies[symbol];
}
private getHoldings(start: Date, end: Date) {
if (
this.holdings &&
Object.keys(this.holdings).some((h) => parseDate(h) >= end) &&
Object.keys(this.holdings).some((h) => parseDate(h) <= start)
) {
return this.holdings;
}
this.computeHoldings(start, end);
return this.holdings;
}
private computeHoldings(start: Date, end: Date) {
const investmentByDate = this.getInvestmentByDate();
const transactionDates = Object.keys(investmentByDate).sort();
let dates = eachDayOfInterval({ start, end }, { step: 1 })
.map((date) => {
return resetHours(date);
})
.sort((a, b) => a.getTime() - b.getTime());
let currentHoldings: { [date: string]: { [symbol: string]: Big } } = {};
this.calculateInitialHoldings(investmentByDate, start, currentHoldings);
for (let i = 1; i < dates.length; i++) {
const dateString = format(dates[i], DATE_FORMAT);
const previousDateString = format(dates[i - 1], DATE_FORMAT);
if (transactionDates.some((d) => d === dateString)) {
let holdings = { ...currentHoldings[previousDateString] };
investmentByDate[dateString].forEach((trade) => {
holdings[trade.SymbolProfile.symbol] ??= new Big(0);
holdings[trade.SymbolProfile.symbol] = holdings[
trade.SymbolProfile.symbol
].plus(trade.quantity.mul(getFactor(trade.type)));
});
currentHoldings[dateString] = holdings;
} else {
currentHoldings[dateString] = currentHoldings[previousDateString];
}
}
this.holdings = currentHoldings;
}
private calculateInitialHoldings(
investmentByDate: { [date: string]: PortfolioOrder[] },
start: Date,
currentHoldings: { [date: string]: { [symbol: string]: Big } }
) {
const preRangeTrades = Object.keys(investmentByDate)
.filter((date) => resetHours(new Date(date)) <= start)
.map((date) => investmentByDate[date])
.reduce((a, b) => a.concat(b), [])
.reduce((groupBySymbol, trade) => {
if (!groupBySymbol[trade.SymbolProfile.symbol]) {
groupBySymbol[trade.SymbolProfile.symbol] = [];
}
groupBySymbol[trade.SymbolProfile.symbol].push(trade);
return groupBySymbol;
}, {});
currentHoldings[format(start, DATE_FORMAT)] = {};
for (const symbol of Object.keys(preRangeTrades)) {
const trades: PortfolioOrder[] = preRangeTrades[symbol];
let startQuantity = trades.reduce((sum, trade) => {
return sum.plus(trade.quantity.mul(getFactor(trade.type)));
}, new Big(0));
currentHoldings[format(start, DATE_FORMAT)][symbol] = startQuantity;
}
}
private getInvestmentByDate(): { [date: string]: PortfolioOrder[] } {
return this.activities.reduce((groupedByDate, order) => {
if (!groupedByDate[order.date]) {
groupedByDate[order.date] = [];
}
groupedByDate[order.date].push(order);
return groupedByDate;
}, {});
}
}

33
apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts

@ -0,0 +1,33 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
export class MWRPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot {
throw new Error('Method not implemented.');
}
protected getSymbolMetrics({
dataSource,
end,
exchangeRates,
isChartMode = false,
marketSymbolMap,
start,
step = 1,
symbol
}: {
end: Date;
exchangeRates: { [dateString: string]: number };
isChartMode?: boolean;
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
step?: number;
} & UniqueAsset): SymbolMetrics {
throw new Error('Method not implemented.');
}
}

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

@ -0,0 +1,30 @@
export const activityDummyData = {
accountId: undefined,
accountUserId: undefined,
comment: undefined,
createdAt: new Date(),
currency: undefined,
feeInBaseCurrency: undefined,
id: undefined,
isDraft: false,
symbolProfileId: undefined,
updatedAt: new Date(),
userId: undefined,
value: undefined,
valueInBaseCurrency: undefined
};
export const symbolProfileDummyData = {
activitiesCount: undefined,
assetClass: undefined,
assetSubClass: undefined,
countries: [],
createdAt: undefined,
id: undefined,
sectors: [],
updatedAt: undefined
};
export const userDummyData = {
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
};

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

@ -0,0 +1,96 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { CPRPortfolioCalculator } from './constantPortfolioReturn/portfolio-calculator';
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator';
import { PortfolioCalculator } from './portfolio-calculator';
import { TWRPortfolioCalculator } from './twr/portfolio-calculator';
export enum PerformanceCalculationType {
MWR = 'MWR', // Money-Weighted Rate of Return
TWR = 'TWR', // Time-Weighted Rate of Return
CPR = 'CPR' // Constant Portfolio Rate of Return
}
@Injectable()
export class PortfolioCalculatorFactory {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly currentRateService: CurrentRateService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly redisCacheService: RedisCacheService
) {}
public createCalculator({
accountBalanceItems = [],
activities,
calculationType,
currency,
dateRange = 'max',
hasFilters,
isExperimentalFeatures = false,
userId
}: {
accountBalanceItems?: HistoricalDataItem[];
activities: Activity[];
calculationType: PerformanceCalculationType;
currency: string;
dateRange?: DateRange;
hasFilters: boolean;
isExperimentalFeatures?: boolean;
userId: string;
}): PortfolioCalculator {
const useCache = !hasFilters && isExperimentalFeatures;
switch (calculationType) {
case PerformanceCalculationType.MWR:
return new MWRPortfolioCalculator({
accountBalanceItems,
activities,
currency,
dateRange,
useCache,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.TWR:
return new TWRPortfolioCalculator({
accountBalanceItems,
activities,
currency,
currentRateService: this.currentRateService,
dateRange,
useCache,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.CPR:
return new CPRPortfolioCalculator({
accountBalanceItems,
activities,
currency,
currentRateService: this.currentRateService,
dateRange,
useCache,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService
});
default:
throw new Error('Invalid calculation type');
}
}
}

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

File diff suppressed because it is too large

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

@ -0,0 +1,218 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} 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 { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and sell in two activities', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-22'),
fee: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.65,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: 136.6
},
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: 136.6
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-22')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-22')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.04408677396780965649'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
hasErrors: false,
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.05528341497550734703'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.05528341497550734703'
),
netPerformanceWithCurrencyEffect: new Big('-15.8'),
positions: [
{
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
firstBuyDate: '2021-11-22',
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'),
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.05528341497550734703'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.05528341497550734703'
),
netPerformanceWithCurrencyEffect: 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'
),
transactionCount: 3,
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'),
totalValuablesWithCurrencyEffect: new Big('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 }
]);
});
});
});

124
apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts → apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts

@ -1,11 +1,22 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js'; import { Big } from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -29,51 +54,69 @@ describe('PortfolioCalculator', () => {
null, null,
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
}); });
describe('get current positions', () => { describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => { it.only('with BALN.SW buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator({ const spy = jest
currentRateService, .spyOn(Date, 'now')
exchangeRateDataService, .mockImplementation(() => parseDate('2021-12-18').getTime());
currency: 'CHF',
orders: [ const activities: Activity[] = [
{ {
...activityDummyData,
date: new Date('2021-11-22'),
fee: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
date: '2021-11-22',
dataSource: 'YAHOO', dataSource: 'YAHOO',
fee: new Big(1.55),
name: 'Bâloise Holding AG', name: 'Bâloise Holding AG',
quantity: new Big(2), symbol: 'BALN.SW'
symbol: 'BALN.SW',
type: 'BUY',
unitPrice: new Big(142.9)
}, },
{ type: 'BUY',
unitPrice: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
date: '2021-11-30',
dataSource: 'YAHOO', dataSource: 'YAHOO',
fee: new Big(1.65),
name: 'Bâloise Holding AG', name: 'Bâloise Holding AG',
quantity: new Big(2), symbol: 'BALN.SW'
symbol: 'BALN.SW', },
type: 'SELL', type: 'SELL',
unitPrice: new Big(136.6) unitPrice: 136.6
} }
] ];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
}); });
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-22') start: parseDate('2021-11-22')
}); });
const currentPositions = await portfolioCalculator.getCurrentPositions( const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-22') parseDate('2021-11-22')
); );
@ -86,8 +129,8 @@ describe('PortfolioCalculator', () => {
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(portfolioSnapshot).toEqual({
currentValue: new Big('0'), currentValueInBaseCurrency: new Big('0'),
errors: [], errors: [],
grossPerformance: new Big('-12.6'), grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'), grossPerformancePercentage: new Big('-0.0440867739678096571'),
@ -107,6 +150,8 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('0'), averagePrice: new Big('0'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'), fee: new Big('3.2'),
firstBuyDate: '2021-11-22', firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'), grossPerformance: new Big('-12.6'),
@ -127,14 +172,19 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 148.9, marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'), quantity: new Big('0'),
symbol: 'BALN.SW', symbol: 'BALN.SW',
tags: [],
timeWeightedInvestment: new Big('285.8'), timeWeightedInvestment: new Big('285.8'),
tags: undefined,
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'), timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'),
transactionCount: 2 transactionCount: 2,
valueInBaseCurrency: new Big('0')
} }
], ],
totalFeesWithCurrencyEffect: new Big('3.2'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'), totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0') totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(investments).toEqual([ expect(investments).toEqual([

105
apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts → apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts

@ -1,11 +1,22 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js'; import { Big } from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -29,40 +54,54 @@ describe('PortfolioCalculator', () => {
null, null,
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
}); });
describe('get current positions', () => { describe('get current positions', () => {
it.only('with BALN.SW buy', async () => { it.only('with BALN.SW buy', async () => {
const portfolioCalculator = new PortfolioCalculator({ const spy = jest
currentRateService, .spyOn(Date, 'now')
exchangeRateDataService, .mockImplementation(() => parseDate('2021-12-18').getTime());
currency: 'CHF',
orders: [ const activities: Activity[] = [
{ {
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
date: '2021-11-30',
dataSource: 'YAHOO', dataSource: 'YAHOO',
fee: new Big(1.55),
name: 'Bâloise Holding AG', name: 'Bâloise Holding AG',
quantity: new Big(2), symbol: 'BALN.SW'
symbol: 'BALN.SW', },
type: 'BUY', type: 'BUY',
unitPrice: new Big(136.6) unitPrice: 136.6
} }
] ];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
}); });
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-30') start: parseDate('2021-11-30')
}); });
const currentPositions = await portfolioCalculator.getCurrentPositions( const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-30') parseDate('2021-11-30')
); );
@ -75,8 +114,8 @@ describe('PortfolioCalculator', () => {
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(portfolioSnapshot).toEqual({
currentValue: new Big('297.8'), currentValueInBaseCurrency: new Big('297.8'),
errors: [], errors: [],
grossPerformance: new Big('24.6'), grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'), grossPerformancePercentage: new Big('0.09004392386530014641'),
@ -96,6 +135,8 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('136.6'), averagePrice: new Big('136.6'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('1.55'), fee: new Big('1.55'),
firstBuyDate: '2021-11-30', firstBuyDate: '2021-11-30',
grossPerformance: new Big('24.6'), grossPerformance: new Big('24.6'),
@ -116,13 +157,19 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 148.9, marketPriceInBaseCurrency: 148.9,
quantity: new Big('2'), quantity: new Big('2'),
symbol: 'BALN.SW', symbol: 'BALN.SW',
tags: [],
timeWeightedInvestment: new Big('273.2'), timeWeightedInvestment: new Big('273.2'),
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'), timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'),
transactionCount: 1 transactionCount: 1,
valueInBaseCurrency: new Big('297.8')
} }
], ],
totalFeesWithCurrencyEffect: new Big('1.55'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('273.2'), totalInvestment: new Big('273.2'),
totalInvestmentWithCurrencyEffect: new Big('273.2') totalInvestmentWithCurrencyEffect: new Big('273.2'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(investments).toEqual([ expect(investments).toEqual([

124
apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts → apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts

@ -1,12 +1,23 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js'; import { Big } from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -17,6 +28,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
jest.mock( jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => { () => {
@ -30,10 +50,15 @@ jest.mock(
); );
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -42,51 +67,69 @@ describe('PortfolioCalculator', () => {
null, null,
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
}); });
describe('get current positions', () => { describe('get current positions', () => {
it.only('with BTCUSD buy and sell partially', async () => { it.only('with BTCUSD buy and sell partially', async () => {
const portfolioCalculator = new PortfolioCalculator({ const spy = jest
currentRateService, .spyOn(Date, 'now')
exchangeRateDataService, .mockImplementation(() => parseDate('2018-01-01').getTime());
currency: 'CHF',
orders: [ const activities: Activity[] = [
{ {
...activityDummyData,
date: new Date('2015-01-01'),
fee: 0,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
date: '2015-01-01',
dataSource: 'YAHOO', dataSource: 'YAHOO',
fee: new Big(0),
name: 'Bitcoin USD', name: 'Bitcoin USD',
quantity: new Big(2), symbol: 'BTCUSD'
symbol: 'BTCUSD',
type: 'BUY',
unitPrice: new Big(320.43)
}, },
{ type: 'BUY',
unitPrice: 320.43
},
{
...activityDummyData,
date: new Date('2017-12-31'),
fee: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
date: '2017-12-31',
dataSource: 'YAHOO', dataSource: 'YAHOO',
fee: new Big(0),
name: 'Bitcoin USD', name: 'Bitcoin USD',
quantity: new Big(1), symbol: 'BTCUSD'
symbol: 'BTCUSD', },
type: 'SELL', type: 'SELL',
unitPrice: new Big(14156.4) unitPrice: 14156.4
} }
] ];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
}); });
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2018-01-01').getTime());
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({
start: parseDate('2015-01-01') start: parseDate('2015-01-01')
}); });
const currentPositions = await portfolioCalculator.getCurrentPositions( const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2015-01-01') parseDate('2015-01-01')
); );
@ -99,8 +142,8 @@ describe('PortfolioCalculator', () => {
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(portfolioSnapshot).toEqual({
currentValue: new Big('13298.425356'), currentValueInBaseCurrency: new Big('13298.425356'),
errors: [], errors: [],
grossPerformance: new Big('27172.74'), grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.41978276196153750666'), grossPerformancePercentage: new Big('42.41978276196153750666'),
@ -120,6 +163,8 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('320.43'), averagePrice: new Big('320.43'),
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'), fee: new Big('0'),
firstBuyDate: '2015-01-01', firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'), grossPerformance: new Big('27172.74'),
@ -144,16 +189,21 @@ describe('PortfolioCalculator', () => {
), ),
quantity: new Big('1'), quantity: new Big('1'),
symbol: 'BTCUSD', symbol: 'BTCUSD',
tags: undefined, tags: [],
timeWeightedInvestment: new Big('640.56763686131386861314'), timeWeightedInvestment: new Big('640.56763686131386861314'),
timeWeightedInvestmentWithCurrencyEffect: new Big( timeWeightedInvestmentWithCurrencyEffect: new Big(
'636.79469348020066587024' '636.79469348020066587024'
), ),
transactionCount: 2 transactionCount: 2,
valueInBaseCurrency: new Big('13298.425356')
} }
], ],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('320.43'), totalInvestment: new Big('320.43'),
totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957') totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(investments).toEqual([ expect(investments).toEqual([

157
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts

@ -0,0 +1,157 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} 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 { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('compute portfolio snapshot', () => {
it.only('with fee activity', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-09-01'),
fee: 49,
quantity: 0,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'MANUAL',
name: 'Account Opening Fee',
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141'
},
type: 'FEE',
unitPrice: 0
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-30')
);
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('0'),
grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('0'),
hasErrors: true,
netPerformance: new Big('0'),
netPerformancePercentage: new Big('0'),
netPerformancePercentageWithCurrencyEffect: new Big('0'),
netPerformanceWithCurrencyEffect: new Big('0'),
positions: [
{
averagePrice: new Big('0'),
currency: 'USD',
dataSource: 'MANUAL',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('49'),
firstBuyDate: '2021-09-01',
grossPerformance: null,
grossPerformancePercentage: null,
grossPerformancePercentageWithCurrencyEffect: null,
grossPerformanceWithCurrencyEffect: null,
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
marketPrice: null,
marketPriceInBaseCurrency: 0,
netPerformance: null,
netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffect: null,
netPerformanceWithCurrencyEffect: null,
quantity: new Big('0'),
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141',
tags: [],
timeWeightedInvestment: new Big('0'),
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
transactionCount: 1,
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('49'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
});
});
});

106
apps/api/src/app/portfolio/portfolio-calculator-googl-buy.spec.ts → apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts

@ -1,12 +1,23 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js'; import { Big } from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -17,6 +28,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
jest.mock( jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => { () => {
@ -30,10 +50,15 @@ jest.mock(
); );
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -42,40 +67,54 @@ describe('PortfolioCalculator', () => {
null, null,
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
}); });
describe('get current positions', () => { describe('get current positions', () => {
it.only('with GOOGL buy', async () => { it.only('with GOOGL buy', async () => {
const portfolioCalculator = new PortfolioCalculator({ const spy = jest
currentRateService, .spyOn(Date, 'now')
exchangeRateDataService, .mockImplementation(() => parseDate('2023-07-10').getTime());
currency: 'CHF',
orders: [ const activities: Activity[] = [
{ {
...activityDummyData,
date: new Date('2023-01-03'),
fee: 1,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
date: '2023-01-03',
dataSource: 'YAHOO', dataSource: 'YAHOO',
fee: new Big(1),
name: 'Alphabet Inc.', name: 'Alphabet Inc.',
quantity: new Big(1), symbol: 'GOOGL'
symbol: 'GOOGL', },
type: 'BUY', type: 'BUY',
unitPrice: new Big(89.12) unitPrice: 89.12
} }
] ];
});
portfolioCalculator.computeTransactionPoints();
const spy = jest const portfolioCalculator = factory.createCalculator({
.spyOn(Date, 'now') activities,
.mockImplementation(() => parseDate('2023-07-10').getTime()); calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({
start: parseDate('2023-01-03') start: parseDate('2023-01-03')
}); });
const currentPositions = await portfolioCalculator.getCurrentPositions( const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2023-01-03') parseDate('2023-01-03')
); );
@ -88,8 +127,8 @@ describe('PortfolioCalculator', () => {
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(portfolioSnapshot).toEqual({
currentValue: new Big('103.10483'), currentValueInBaseCurrency: new Big('103.10483'),
errors: [], errors: [],
grossPerformance: new Big('27.33'), grossPerformance: new Big('27.33'),
grossPerformancePercentage: new Big('0.3066651705565529623'), grossPerformancePercentage: new Big('0.3066651705565529623'),
@ -109,6 +148,8 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('89.12'), averagePrice: new Big('89.12'),
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('1'), fee: new Big('1'),
firstBuyDate: '2023-01-03', firstBuyDate: '2023-01-03',
grossPerformance: new Big('27.33'), grossPerformance: new Big('27.33'),
@ -129,14 +170,19 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 103.10483, marketPriceInBaseCurrency: 103.10483,
quantity: new Big('1'), quantity: new Big('1'),
symbol: 'GOOGL', symbol: 'GOOGL',
tags: undefined, tags: [],
timeWeightedInvestment: new Big('89.12'), timeWeightedInvestment: new Big('89.12'),
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'), timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
transactionCount: 1 transactionCount: 1,
valueInBaseCurrency: new Big('103.10483')
} }
], ],
totalFeesWithCurrencyEffect: new Big('1'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('89.12'), totalInvestment: new Big('89.12'),
totalInvestmentWithCurrencyEffect: new Big('82.329056') totalInvestmentWithCurrencyEffect: new Big('82.329056'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(investments).toEqual([ expect(investments).toEqual([

157
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts

@ -0,0 +1,157 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} 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 { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('compute portfolio snapshot', () => {
it.only('with item activity', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-01-31').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2022-01-01'),
fee: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'MANUAL',
name: 'Penthouse Apartment',
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
},
type: 'ITEM',
unitPrice: 500000
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-01-01')
);
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('0'),
grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('0'),
hasErrors: true,
netPerformance: new Big('0'),
netPerformancePercentage: new Big('0'),
netPerformancePercentageWithCurrencyEffect: new Big('0'),
netPerformanceWithCurrencyEffect: new Big('0'),
positions: [
{
averagePrice: new Big('500000'),
currency: 'USD',
dataSource: 'MANUAL',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
firstBuyDate: '2022-01-01',
grossPerformance: null,
grossPerformancePercentage: null,
grossPerformancePercentageWithCurrencyEffect: null,
grossPerformanceWithCurrencyEffect: null,
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
marketPrice: null,
marketPriceInBaseCurrency: 500000,
netPerformance: null,
netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffect: null,
netPerformanceWithCurrencyEffect: null,
quantity: new Big('0'),
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde',
tags: [],
timeWeightedInvestment: new Big('0'),
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
transactionCount: 1,
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
});
});
});

108
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts

@ -0,0 +1,108 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} 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 { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('compute portfolio snapshot', () => {
it.only('with liability activity', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-01-31').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2023-01-01'), // Date in future
fee: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'MANUAL',
name: 'Loan',
symbol: '55196015-1365-4560-aa60-8751ae6d18f8'
},
type: 'LIABILITY',
unitPrice: 3000
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
spy.mockRestore();
const liabilitiesInBaseCurrency =
await portfolioCalculator.getLiabilitiesInBaseCurrency();
expect(liabilitiesInBaseCurrency).toEqual(new Big(3000));
});
});
});

165
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -0,0 +1,165 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with MSFT buy', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2023-07-10').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-09-16'),
fee: 19,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'BUY',
unitPrice: 298.58
},
{
...activityDummyData,
date: new Date('2021-11-16'),
fee: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'DIVIDEND',
unitPrice: 0.62
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2023-07-10')
);
spy.mockRestore();
expect(portfolioSnapshot).toMatchObject({
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('298.58'),
currency: 'USD',
dataSource: 'YAHOO',
dividend: new Big('0.62'),
dividendInBaseCurrency: new Big('0.62'),
fee: new Big('19'),
firstBuyDate: '2021-09-16',
investment: new Big('298.58'),
investmentWithCurrencyEffect: new Big('298.58'),
marketPrice: 331.83,
marketPriceInBaseCurrency: 331.83,
quantity: new Big('1'),
symbol: 'MSFT',
tags: [],
transactionCount: 2
}
],
totalFeesWithCurrencyEffect: new Big('19'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('298.58'),
totalInvestmentWithCurrencyEffect: new Big('298.58'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
});
});
});

124
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts

@ -0,0 +1,124 @@
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
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 { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
import { subDays } from 'date-fns';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('get current positions', () => {
it('with no orders', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const portfolioCalculator = factory.createCalculator({
activities: [],
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const start = subDays(new Date(Date.now()), 10);
const chartData = await portfolioCalculator.getChartData({ start });
const portfolioSnapshot =
await portfolioCalculator.computeSnapshot(start);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffect: new Big(0),
netPerformanceWithCurrencyEffect: new Big(0),
positions: [],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([]);
expect(investmentsByMonth).toEqual([
{
date: '2021-12-01',
investment: 0
}
]);
});
});
});

124
apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts → apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -1,11 +1,22 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js'; import { Big } from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -29,51 +54,69 @@ describe('PortfolioCalculator', () => {
null, null,
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
}); });
describe('get current positions', () => { describe('get current positions', () => {
it.only('with NOVN.SW buy and sell partially', async () => { it.only('with NOVN.SW buy and sell partially', async () => {
const portfolioCalculator = new PortfolioCalculator({ const spy = jest
currentRateService, .spyOn(Date, 'now')
exchangeRateDataService, .mockImplementation(() => parseDate('2022-04-11').getTime());
currency: 'CHF',
orders: [ const activities: Activity[] = [
{ {
...activityDummyData,
date: new Date('2022-03-07'),
fee: 1.3,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
date: '2022-03-07',
dataSource: 'YAHOO', dataSource: 'YAHOO',
fee: new Big(1.3),
name: 'Novartis AG', name: 'Novartis AG',
quantity: new Big(2), symbol: 'NOVN.SW'
symbol: 'NOVN.SW',
type: 'BUY',
unitPrice: new Big(75.8)
}, },
{ type: 'BUY',
unitPrice: 75.8
},
{
...activityDummyData,
date: new Date('2022-04-08'),
fee: 2.95,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
date: '2022-04-08',
dataSource: 'YAHOO', dataSource: 'YAHOO',
fee: new Big(2.95),
name: 'Novartis AG', name: 'Novartis AG',
quantity: new Big(1), symbol: 'NOVN.SW'
symbol: 'NOVN.SW', },
type: 'SELL', type: 'SELL',
unitPrice: new Big(85.73) unitPrice: 85.73
} }
] ];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
}); });
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({
start: parseDate('2022-03-07') start: parseDate('2022-03-07')
}); });
const currentPositions = await portfolioCalculator.getCurrentPositions( const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-03-07') parseDate('2022-03-07')
); );
@ -86,8 +129,8 @@ describe('PortfolioCalculator', () => {
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(portfolioSnapshot).toEqual({
currentValue: new Big('87.8'), currentValueInBaseCurrency: new Big('87.8'),
errors: [], errors: [],
grossPerformance: new Big('21.93'), grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.15113417083448194384'), grossPerformancePercentage: new Big('0.15113417083448194384'),
@ -107,6 +150,8 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('75.80'), averagePrice: new Big('75.80'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4.25'), fee: new Big('4.25'),
firstBuyDate: '2022-03-07', firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'), grossPerformance: new Big('21.93'),
@ -127,16 +172,21 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 87.8, marketPriceInBaseCurrency: 87.8,
quantity: new Big('1'), quantity: new Big('1'),
symbol: 'NOVN.SW', symbol: 'NOVN.SW',
tags: undefined, tags: [],
timeWeightedInvestment: new Big('145.10285714285714285714'), timeWeightedInvestment: new Big('145.10285714285714285714'),
timeWeightedInvestmentWithCurrencyEffect: new Big( timeWeightedInvestmentWithCurrencyEffect: new Big(
'145.10285714285714285714' '145.10285714285714285714'
), ),
transactionCount: 2 transactionCount: 2,
valueInBaseCurrency: new Big('87.8')
} }
], ],
totalFeesWithCurrencyEffect: new Big('4.25'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('75.80'), totalInvestment: new Big('75.80'),
totalInvestmentWithCurrencyEffect: new Big('75.80') totalInvestmentWithCurrencyEffect: new Big('75.80'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(investments).toEqual([ expect(investments).toEqual([

129
apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts → apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -1,11 +1,22 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js'; import { Big } from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -29,51 +54,69 @@ describe('PortfolioCalculator', () => {
null, null,
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
}); });
describe('get current positions', () => { describe('get current positions', () => {
it.only('with NOVN.SW buy and sell', async () => { it.only('with NOVN.SW buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator({ const spy = jest
currentRateService, .spyOn(Date, 'now')
exchangeRateDataService, .mockImplementation(() => parseDate('2022-04-11').getTime());
currency: 'CHF',
orders: [ const activities: Activity[] = [
{ {
...activityDummyData,
date: new Date('2022-03-07'),
fee: 0,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
date: '2022-03-07',
dataSource: 'YAHOO', dataSource: 'YAHOO',
fee: new Big(0),
name: 'Novartis AG', name: 'Novartis AG',
quantity: new Big(2), symbol: 'NOVN.SW'
symbol: 'NOVN.SW',
type: 'BUY',
unitPrice: new Big(75.8)
}, },
{ type: 'BUY',
unitPrice: 75.8
},
{
...activityDummyData,
date: new Date('2022-04-08'),
fee: 0,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
date: '2022-04-08',
dataSource: 'YAHOO', dataSource: 'YAHOO',
fee: new Big(0),
name: 'Novartis AG', name: 'Novartis AG',
quantity: new Big(2), symbol: 'NOVN.SW'
symbol: 'NOVN.SW', },
type: 'SELL', type: 'SELL',
unitPrice: new Big(85.73) unitPrice: 85.73
} }
] ];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
}); });
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({
start: parseDate('2022-03-07') start: parseDate('2022-03-07')
}); });
const currentPositions = await portfolioCalculator.getCurrentPositions( const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-03-07') parseDate('2022-03-07')
); );
@ -93,7 +136,8 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0, netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0,
timeWeightedPerformance: 0, netWorth: 151.6,
totalAccountBalance: 0,
totalInvestment: 151.6, totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6, totalInvestmentValueWithCurrencyEffect: 151.6,
value: 151.6, value: 151.6,
@ -107,15 +151,16 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 13.100263852242744, netPerformanceInPercentage: 13.100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744, netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744,
netPerformanceWithCurrencyEffect: 19.86, netPerformanceWithCurrencyEffect: 19.86,
timeWeightedPerformance: 0, netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0, totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0, totalInvestmentValueWithCurrencyEffect: 0,
value: 0, value: 0,
valueWithCurrencyEffect: 0 valueWithCurrencyEffect: 0
}); });
expect(currentPositions).toEqual({ expect(portfolioSnapshot).toEqual({
currentValue: new Big('0'), currentValueInBaseCurrency: new Big('0'),
errors: [], errors: [],
grossPerformance: new Big('19.86'), grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'), grossPerformancePercentage: new Big('0.13100263852242744063'),
@ -135,6 +180,8 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('0'), averagePrice: new Big('0'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'), fee: new Big('0'),
firstBuyDate: '2022-03-07', firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'), grossPerformance: new Big('19.86'),
@ -155,13 +202,19 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 87.8, marketPriceInBaseCurrency: 87.8,
quantity: new Big('0'), quantity: new Big('0'),
symbol: 'NOVN.SW', symbol: 'NOVN.SW',
tags: [],
timeWeightedInvestment: new Big('151.6'), timeWeightedInvestment: new Big('151.6'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
transactionCount: 2 transactionCount: 2,
valueInBaseCurrency: new Big('0')
} }
], ],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'), totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0') totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(investments).toEqual([ expect(investments).toEqual([

37
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts

@ -0,0 +1,37 @@
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
test.skip('Skip empty test', () => 1);
});

922
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts

@ -0,0 +1,922 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
import {
addDays,
addMilliseconds,
differenceInDays,
format,
isBefore
} from 'date-fns';
import { cloneDeep, first, last, sortBy } from 'lodash';
export class TWRPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot {
let currentValueInBaseCurrency = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0);
let hasErrors = false;
let netPerformance = new Big(0);
let netPerformanceWithCurrencyEffect = new Big(0);
let totalFeesWithCurrencyEffect = new Big(0);
let totalInterestWithCurrencyEffect = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalTimeWeightedInvestment = new Big(0);
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.fee) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.fee
);
}
if (currentPosition.valueInBaseCurrency) {
currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
currentPosition.valueInBaseCurrency
);
} else {
hasErrors = true;
}
if (currentPosition.investment) {
totalInvestment = totalInvestment.plus(currentPosition.investment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
currentPosition.investmentWithCurrencyEffect
);
} else {
hasErrors = true;
}
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
grossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.plus(
currentPosition.grossPerformanceWithCurrencyEffect
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
netPerformanceWithCurrencyEffect =
netPerformanceWithCurrencyEffect.plus(
currentPosition.netPerformanceWithCurrencyEffect
);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
if (currentPosition.timeWeightedInvestment) {
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
currentPosition.timeWeightedInvestment
);
totalTimeWeightedInvestmentWithCurrencyEffect =
totalTimeWeightedInvestmentWithCurrencyEffect.plus(
currentPosition.timeWeightedInvestmentWithCurrencyEffect
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
'PortfolioCalculator'
);
hasErrors = true;
}
}
return {
currentValueInBaseCurrency,
grossPerformance,
grossPerformanceWithCurrencyEffect,
hasErrors,
netPerformance,
netPerformanceWithCurrencyEffect,
positions,
totalFeesWithCurrencyEffect,
totalInterestWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
netPerformancePercentage: totalTimeWeightedInvestment.eq(0)
? new Big(0)
: netPerformance.div(totalTimeWeightedInvestment),
netPerformancePercentageWithCurrencyEffect:
totalTimeWeightedInvestmentWithCurrencyEffect.eq(0)
? new Big(0)
: netPerformanceWithCurrencyEffect.div(
totalTimeWeightedInvestmentWithCurrencyEffect
),
grossPerformancePercentage: totalTimeWeightedInvestment.eq(0)
? new Big(0)
: grossPerformance.div(totalTimeWeightedInvestment),
grossPerformancePercentageWithCurrencyEffect:
totalTimeWeightedInvestmentWithCurrencyEffect.eq(0)
? new Big(0)
: grossPerformanceWithCurrencyEffect.div(
totalTimeWeightedInvestmentWithCurrencyEffect
),
totalLiabilitiesWithCurrencyEffect: new Big(0),
totalValuablesWithCurrencyEffect: new Big(0)
};
}
protected getSymbolMetrics({
dataSource,
end,
exchangeRates,
isChartMode = false,
marketSymbolMap,
start,
step = 1,
symbol
}: {
end: Date;
exchangeRates: { [dateString: string]: number };
isChartMode?: boolean;
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
step?: number;
} & UniqueAsset): SymbolMetrics {
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
const currentValues: { [date: string]: Big } = {};
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};
let fees = new Big(0);
let feesAtStartDate = new Big(0);
let feesAtStartDateWithCurrencyEffect = new Big(0);
let feesWithCurrencyEffect = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0);
let grossPerformanceAtStartDate = new Big(0);
let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0);
let grossPerformanceFromSells = new Big(0);
let grossPerformanceFromSellsWithCurrencyEffect = new Big(0);
let initialValue: Big;
let initialValueWithCurrencyEffect: Big;
let investmentAtStartDate: Big;
let investmentAtStartDateWithCurrencyEffect: Big;
const investmentValuesAccumulated: { [date: string]: Big } = {};
const investmentValuesAccumulatedWithCurrencyEffect: {
[date: string]: Big;
} = {};
const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0);
let lastAveragePriceWithCurrencyEffect = new Big(0);
const netPerformanceValues: { [date: string]: Big } = {};
const netPerformanceValuesWithCurrencyEffect: { [date: string]: Big } = {};
const timeWeightedInvestmentValues: { [date: string]: Big } = {};
const timeWeightedInvestmentValuesWithCurrencyEffect: {
[date: string]: Big;
} = {};
let totalAccountBalanceInBaseCurrency = new Big(0);
let totalDividend = new Big(0);
let totalStakeRewards = new Big(0);
let totalDividendInBaseCurrency = new Big(0);
let totalInterest = new Big(0);
let totalInterestInBaseCurrency = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentFromBuyTransactions = new Big(0);
let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalLiabilities = new Big(0);
let totalLiabilitiesInBaseCurrency = new Big(0);
let totalQuantityFromBuyTransactions = new Big(0);
let totalUnits = new Big(0);
let totalValuables = new Big(0);
let totalValuablesInBaseCurrency = new Big(0);
let valueAtStartDate: Big;
let valueAtStartDateWithCurrencyEffect: Big;
// Clone orders to keep the original values in this.orders
let orders: PortfolioOrderItem[] = cloneDeep(this.activities).filter(
({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol;
}
);
if (orders.length <= 0) {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
unitPrices: {},
feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffect: new Big(0),
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
netPerformanceValuesPercentage: {},
netPerformanceWithCurrencyEffect: new Big(0),
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
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),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
};
}
const dateOfFirstTransaction = new Date(first(orders).date);
const unitPriceAtStartDate =
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
const unitPriceAtEndDate =
marketSymbolMap[format(end, DATE_FORMAT)]?.[symbol];
if (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
) {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
unitPrices: {},
feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: true,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffect: new Big(0),
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
netPerformanceValuesPercentage: {},
netPerformanceWithCurrencyEffect: new Big(0),
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
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),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
};
}
// Add a synthetic order at the start and the end date
orders.push({
date: format(start, DATE_FORMAT),
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'start',
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: unitPriceAtStartDate
});
orders.push({
date: format(end, DATE_FORMAT),
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'end',
SymbolProfile: {
dataSource,
symbol
},
quantity: new Big(0),
type: 'BUY',
unitPrice: unitPriceAtEndDate
});
let day = start;
let lastUnitPrice: Big;
if (isChartMode) {
const datesWithOrders = {};
for (const { date, type } of orders) {
if (['BUY', 'SELL', 'STAKE'].includes(type)) {
datesWithOrders[date] = true;
}
}
while (isBefore(day, end)) {
const hasDate = datesWithOrders[format(day, DATE_FORMAT)];
if (!hasDate) {
orders.push({
date: format(day, DATE_FORMAT),
fee: new Big(0),
feeInBaseCurrency: new Big(0),
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice:
marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ??
lastUnitPrice
});
}
lastUnitPrice = last(orders).unitPrice;
day = addDays(day, step);
}
}
// Sort orders so that the start and end placeholder order are at the correct
// position
orders = sortBy(orders, ({ date, itemType }) => {
let sortIndex = new Date(date);
if (itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
} else if (itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
return sortIndex.getTime();
});
const indexOfStartOrder = orders.findIndex(({ itemType }) => {
return itemType === 'start';
});
const indexOfEndOrder = orders.findIndex(({ itemType }) => {
return itemType === 'end';
});
let totalInvestmentDays = 0;
let sumOfTimeWeightedInvestments = new Big(0);
let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0);
for (let i = 0; i < orders.length; i += 1) {
const order = orders[i];
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log();
console.log();
console.log(
i + 1,
order.date,
order.type,
order.itemType ? `(${order.itemType})` : ''
);
}
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 === 'ITEM') {
const valuables = order.quantity.mul(order.unitPrice);
totalValuables = totalValuables.plus(valuables);
totalValuablesInBaseCurrency = totalValuablesInBaseCurrency.plus(
valuables.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
order.unitPrice =
indexOfStartOrder === 0
? orders[i + 1]?.unitPrice
: unitPriceAtStartDate;
}
if (order.fee) {
order.feeInBaseCurrency = order.fee.mul(currentExchangeRate ?? 1);
order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul(
exchangeRateAtOrderDate ?? 1
);
}
if (order.type === 'STAKE') {
order.unitPrice = marketSymbolMap[order.date]?.[symbol] ?? new Big(0);
}
if (order.unitPrice) {
order.unitPriceInBaseCurrency = order.unitPrice.mul(
currentExchangeRate ?? 1
);
order.unitPriceInBaseCurrencyWithCurrencyEffect = order.unitPrice.mul(
exchangeRateAtOrderDate ?? 1
);
}
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
order.unitPriceInBaseCurrency
);
const valueOfInvestmentBeforeTransactionWithCurrencyEffect =
totalUnits.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect);
if (!investmentAtStartDate && i >= indexOfStartOrder) {
investmentAtStartDate = totalInvestment ?? new Big(0);
investmentAtStartDateWithCurrencyEffect =
totalInvestmentWithCurrencyEffect ?? new Big(0);
valueAtStartDate = valueOfInvestmentBeforeTransaction;
valueAtStartDateWithCurrencyEffect =
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
}
let transactionInvestment = new Big(0);
let transactionInvestmentWithCurrencyEffect = new Big(0);
if (order.type === 'BUY') {
transactionInvestment = order.quantity
.mul(order.unitPriceInBaseCurrency)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect = order.quantity
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect)
.mul(getFactor(order.type));
totalQuantityFromBuyTransactions =
totalQuantityFromBuyTransactions.plus(order.quantity);
totalInvestmentFromBuyTransactions =
totalInvestmentFromBuyTransactions.plus(transactionInvestment);
totalInvestmentFromBuyTransactionsWithCurrencyEffect =
totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
} else if (order.type === 'SELL') {
if (totalUnits.gt(0)) {
transactionInvestment = totalInvestment
.div(totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect
.div(totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
}
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('order.quantity', order.quantity.toNumber());
console.log('transactionInvestment', transactionInvestment.toNumber());
console.log(
'transactionInvestmentWithCurrencyEffect',
transactionInvestmentWithCurrencyEffect.toNumber()
);
}
const totalInvestmentBeforeTransaction = totalInvestment;
const totalInvestmentBeforeTransactionWithCurrencyEffect =
totalInvestmentWithCurrencyEffect;
totalInvestment = totalInvestment.plus(transactionInvestment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
if (i >= indexOfStartOrder && !initialValue) {
if (
i === indexOfStartOrder &&
!valueOfInvestmentBeforeTransaction.eq(0)
) {
initialValue = valueOfInvestmentBeforeTransaction;
initialValueWithCurrencyEffect =
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
} else if (transactionInvestment.gt(0)) {
initialValue = transactionInvestment;
initialValueWithCurrencyEffect =
transactionInvestmentWithCurrencyEffect;
}
}
fees = fees.plus(order.feeInBaseCurrency ?? 0);
feesWithCurrencyEffect = feesWithCurrencyEffect.plus(
order.feeInBaseCurrencyWithCurrencyEffect ?? 0
);
totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type)));
const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency);
const valueOfInvestmentWithCurrencyEffect = totalUnits.mul(
order.unitPriceInBaseCurrencyWithCurrencyEffect
);
const grossPerformanceFromSell =
order.type === 'SELL'
? order.unitPriceInBaseCurrency
.minus(lastAveragePrice)
.mul(order.quantity)
: new Big(0);
const grossPerformanceFromSellWithCurrencyEffect =
order.type === 'SELL'
? order.unitPriceInBaseCurrencyWithCurrencyEffect
.minus(lastAveragePriceWithCurrencyEffect)
.mul(order.quantity)
: new Big(0);
grossPerformanceFromSells = grossPerformanceFromSells.plus(
grossPerformanceFromSell
);
grossPerformanceFromSellsWithCurrencyEffect =
grossPerformanceFromSellsWithCurrencyEffect.plus(
grossPerformanceFromSellWithCurrencyEffect
);
lastAveragePrice = totalQuantityFromBuyTransactions.eq(0)
? new Big(0)
: totalInvestmentFromBuyTransactions.div(
totalQuantityFromBuyTransactions
);
lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq(
0
)
? new Big(0)
: totalInvestmentFromBuyTransactionsWithCurrencyEffect.div(
totalQuantityFromBuyTransactions
);
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
'grossPerformanceFromSells',
grossPerformanceFromSells.toNumber()
);
console.log(
'grossPerformanceFromSellWithCurrencyEffect',
grossPerformanceFromSellWithCurrencyEffect.toNumber()
);
}
const newGrossPerformance = valueOfInvestment
.minus(totalInvestment)
.plus(grossPerformanceFromSells);
const newGrossPerformanceWithCurrencyEffect =
valueOfInvestmentWithCurrencyEffect
.minus(totalInvestmentWithCurrencyEffect)
.plus(grossPerformanceFromSellsWithCurrencyEffect);
grossPerformance = newGrossPerformance;
grossPerformanceWithCurrencyEffect =
newGrossPerformanceWithCurrencyEffect;
if (order.itemType === 'start') {
feesAtStartDate = fees;
feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect;
grossPerformanceAtStartDate = grossPerformance;
grossPerformanceAtStartDateWithCurrencyEffect =
grossPerformanceWithCurrencyEffect;
}
if (
i > indexOfStartOrder &&
['BUY', 'SELL', 'STAKE'].includes(order.type)
) {
// Only consider periods with an investment for the calculation of
// the time weighted investment
if (valueOfInvestmentBeforeTransaction.gt(0)) {
// Calculate the number of days since the previous order
const orderDate = new Date(order.date);
const previousOrderDate = new Date(orders[i - 1].date);
let daysSinceLastOrder = differenceInDays(
orderDate,
previousOrderDate
);
if (daysSinceLastOrder <= 0) {
// The time between two activities on the same day is unknown
// -> Set it to the smallest floating point number greater than 0
daysSinceLastOrder = Number.EPSILON;
}
// Sum up the total investment days since the start date to calculate
// the time weighted investment
totalInvestmentDays += daysSinceLastOrder;
sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add(
valueAtStartDate
.minus(investmentAtStartDate)
.plus(totalInvestmentBeforeTransaction)
.mul(daysSinceLastOrder)
);
sumOfTimeWeightedInvestmentsWithCurrencyEffect =
sumOfTimeWeightedInvestmentsWithCurrencyEffect.add(
valueAtStartDateWithCurrencyEffect
.minus(investmentAtStartDateWithCurrencyEffect)
.plus(totalInvestmentBeforeTransactionWithCurrencyEffect)
.mul(daysSinceLastOrder)
);
}
if (isChartMode) {
currentValues[order.date] = valueOfInvestment;
currentValuesWithCurrencyEffect[order.date] =
valueOfInvestmentWithCurrencyEffect;
netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
netPerformanceValuesWithCurrencyEffect[order.date] =
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.minus(
feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect)
);
investmentValuesAccumulated[order.date] = totalInvestment;
investmentValuesAccumulatedWithCurrencyEffect[order.date] =
totalInvestmentWithCurrencyEffect;
investmentValuesWithCurrencyEffect[order.date] = (
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0)
).add(transactionInvestmentWithCurrencyEffect);
timeWeightedInvestmentValues[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
timeWeightedInvestmentValuesWithCurrencyEffect[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays
)
: new Big(0);
}
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('totalInvestment', totalInvestment.toNumber());
console.log(
'totalInvestmentWithCurrencyEffect',
totalInvestmentWithCurrencyEffect.toNumber()
);
console.log(
'totalGrossPerformance',
grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
);
console.log(
'totalGrossPerformanceWithCurrencyEffect',
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.toNumber()
);
}
if (i === indexOfEndOrder) {
break;
}
}
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalGrossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.minus(
grossPerformanceAtStartDateWithCurrencyEffect
);
const totalNetPerformance = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
const totalNetPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.minus(feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect));
const timeWeightedAverageInvestmentBetweenStartAndEndDate =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
const timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays
)
: new Big(0);
const grossPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalGrossPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
: new Big(0);
const grossPerformancePercentageWithCurrencyEffect =
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt(
0
)
? totalGrossPerformanceWithCurrencyEffect.div(
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
)
: new Big(0);
const feesPerUnit = totalUnits.gt(0)
? fees.minus(feesAtStartDate).div(totalUnits)
: new Big(0);
const feesPerUnitWithCurrencyEffect = totalUnits.gt(0)
? feesWithCurrencyEffect
.minus(feesAtStartDateWithCurrencyEffect)
.div(totalUnits)
: new Big(0);
const netPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalNetPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
: new Big(0);
const netPerformancePercentageWithCurrencyEffect =
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt(
0
)
? totalNetPerformanceWithCurrencyEffect.div(
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
)
: new Big(0);
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
`
${symbol}
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
2
)} -> ${unitPriceAtEndDate.toFixed(2)}
Total investment: ${totalInvestment.toFixed(2)}
Total investment with currency effect: ${totalInvestmentWithCurrencyEffect.toFixed(
2
)}
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed(
2
)}
Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed(
2
)}
Total dividend: ${totalDividend.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed(
2
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed(
2
)} / ${grossPerformancePercentageWithCurrencyEffect
.mul(100)
.toFixed(2)}%
Fees per unit: ${feesPerUnit.toFixed(2)}
Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed(
2
)}
Net performance: ${totalNetPerformance.toFixed(
2
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%
Net performance with currency effect: ${totalNetPerformanceWithCurrencyEffect.toFixed(
2
)} / ${netPerformancePercentageWithCurrencyEffect.mul(100).toFixed(2)}%`
);
}
let unitPrices = Object.keys(marketSymbolMap)
.map((date) => {
return { [date]: marketSymbolMap[date][symbol] };
})
.reduce((map, u) => {
return { ...u, ...map };
}, {});
return {
currentValues,
currentValuesWithCurrencyEffect,
unitPrices,
feesWithCurrencyEffect,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
initialValue,
initialValueWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
netPerformanceValuesPercentage: {},
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect,
totalAccountBalanceInBaseCurrency,
totalDividend,
totalDividendInBaseCurrency,
totalInterest,
totalInterestInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect,
totalLiabilities,
totalLiabilitiesInBaseCurrency,
totalValuables,
totalValuablesInBaseCurrency,
grossPerformance: totalGrossPerformance,
grossPerformanceWithCurrencyEffect:
totalGrossPerformanceWithCurrencyEffect,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance,
netPerformanceWithCurrencyEffect: totalNetPerformanceWithCurrencyEffect,
timeWeightedInvestment:
timeWeightedAverageInvestmentBetweenStartAndEndDate,
timeWeightedInvestmentWithCurrencyEffect:
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
};
}
}

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

@ -8,6 +8,13 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
function mockGetValue(symbol: string, date: Date) { function mockGetValue(symbol: string, date: Date) {
switch (symbol) { switch (symbol) {
case '55196015-1365-4560-aa60-8751ae6d18f8':
if (isSameDay(parseDate('2022-01-31'), date)) {
return { marketPrice: 3000 };
}
return { marketPrice: 0 };
case 'BALN.SW': case 'BALN.SW':
if (isSameDay(parseDate('2021-11-12'), date)) { if (isSameDay(parseDate('2021-11-12'), date)) {
return { marketPrice: 146 }; return { marketPrice: 146 };
@ -43,6 +50,17 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 }; return { marketPrice: 0 };
case 'MSFT':
if (isSameDay(parseDate('2021-09-16'), date)) {
return { marketPrice: 89.12 };
} else if (isSameDay(parseDate('2021-11-16'), date)) {
return { marketPrice: 339.51 };
} else if (isSameDay(parseDate('2023-07-10'), date)) {
return { marketPrice: 331.83 };
}
return { marketPrice: 0 };
case 'NOVN.SW': case 'NOVN.SW':
if (isSameDay(parseDate('2022-04-11'), date)) { if (isSameDay(parseDate('2022-04-11'), date)) {
return { marketPrice: 87.8 }; return { marketPrice: 87.8 };

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

@ -37,7 +37,7 @@ export class CurrentRateService {
}: GetValuesParams): Promise<GetValuesObject> { }: GetValuesParams): Promise<GetValuesObject> {
const dataProviderInfos: DataProviderInfo[] = []; const dataProviderInfos: DataProviderInfo[] = [];
const includeToday = const includesToday =
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) && (!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) && (!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
(!dateQuery.in || this.containsToday(dateQuery.in)); (!dateQuery.in || this.containsToday(dateQuery.in));
@ -46,7 +46,7 @@ export class CurrentRateService {
const quoteErrors: ResponseError['errors'] = []; const quoteErrors: ResponseError['errors'] = [];
const today = resetHours(new Date()); const today = resetHours(new Date());
if (includeToday) { if (includesToday) {
promises.push( promises.push(
this.dataProviderService this.dataProviderService
.getQuotes({ items: dataGatheringItems, user: this.request?.user }) .getQuotes({ items: dataGatheringItems, user: this.request?.user })

19
apps/api/src/app/portfolio/interfaces/current-positions.interface.ts

@ -1,19 +0,0 @@
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
import Big from 'big.js';
export interface CurrentPositions extends ResponseError {
positions: TimelinePosition[];
grossPerformance: Big;
grossPerformanceWithCurrencyEffect: Big;
grossPerformancePercentage: Big;
grossPerformancePercentageWithCurrencyEffect: Big;
netAnnualizedPerformance?: Big;
netAnnualizedPerformanceWithCurrencyEffect?: Big;
netPerformance: Big;
netPerformanceWithCurrencyEffect: Big;
netPerformancePercentage: Big;
netPerformancePercentageWithCurrencyEffect: Big;
currentValue: Big;
totalInvestment: Big;
}

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

@ -1,11 +1,11 @@
import Big from 'big.js'; import { Big } from 'big.js';
import { PortfolioOrder } from './portfolio-order.interface'; import { PortfolioOrder } from './portfolio-order.interface';
export interface PortfolioOrderItem extends PortfolioOrder { export interface PortfolioOrderItem extends PortfolioOrder {
feeInBaseCurrency?: Big; feeInBaseCurrency?: Big;
feeInBaseCurrencyWithCurrencyEffect?: Big; feeInBaseCurrencyWithCurrencyEffect?: Big;
itemType?: '' | 'start' | 'end'; itemType?: 'end' | 'start';
unitPriceInBaseCurrency?: Big; unitPriceInBaseCurrency?: Big;
unitPriceInBaseCurrencyWithCurrencyEffect?: Big; unitPriceInBaseCurrencyWithCurrencyEffect?: Big;
} }

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

@ -1,15 +1,12 @@
import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import Big from 'big.js';
export interface PortfolioOrder { export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> {
currency: string;
date: string; date: string;
dataSource: DataSource;
fee: Big; fee: Big;
name: string;
quantity: Big; quantity: Big;
symbol: string; SymbolProfile: Pick<
tags?: Tag[]; Activity['SymbolProfile'],
type: TypeOfOrder; 'currency' | 'dataSource' | 'name' | 'symbol'
>;
unitPrice: Big; unitPrice: Big;
} }

12
apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts

@ -1,9 +1,9 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
DataProviderInfo, DataProviderInfo,
EnhancedSymbolProfile, EnhancedSymbolProfile,
HistoricalDataItem HistoricalDataItem
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Account, Tag } from '@prisma/client'; import { Account, Tag } from '@prisma/client';
@ -13,6 +13,8 @@ export interface PortfolioPositionDetail {
dataProviderInfo: DataProviderInfo; dataProviderInfo: DataProviderInfo;
dividendInBaseCurrency: number; dividendInBaseCurrency: number;
stakeRewards: number; stakeRewards: number;
dividendYieldPercent: number;
dividendYieldPercentWithCurrencyEffect: number;
feeInBaseCurrency: number; feeInBaseCurrency: number;
firstBuyDate: string; firstBuyDate: string;
grossPerformance: number; grossPerformance: number;
@ -28,16 +30,10 @@ export interface PortfolioPositionDetail {
netPerformancePercent: number; netPerformancePercent: number;
netPerformancePercentWithCurrencyEffect: number; netPerformancePercentWithCurrencyEffect: number;
netPerformanceWithCurrencyEffect: number; netPerformanceWithCurrencyEffect: number;
orders: OrderWithAccount[]; orders: Activity[];
quantity: number; quantity: number;
SymbolProfile: EnhancedSymbolProfile; SymbolProfile: EnhancedSymbolProfile;
tags: Tag[]; tags: Tag[];
transactionCount: number; transactionCount: number;
value: number; value: number;
} }
export interface HistoricalDataContainer {
isAllTimeHigh: boolean;
isAllTimeLow: boolean;
items: HistoricalDataItem[];
}

5
apps/api/src/app/portfolio/interfaces/portfolio-positions.interface.ts

@ -1,5 +0,0 @@
import { Position } from '@ghostfolio/common/interfaces';
export interface PortfolioPositions {
positions: Position[];
}

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

@ -1,9 +1,11 @@
import { DataSource, Tag } from '@prisma/client'; import { DataSource, Tag } from '@prisma/client';
import Big from 'big.js'; import { Big } from 'big.js';
export interface TransactionPointSymbol { export interface TransactionPointSymbol {
averagePrice: Big;
currency: string; currency: string;
dataSource: DataSource; dataSource: DataSource;
dividend: Big;
fee: Big; fee: Big;
firstBuyDate: string; firstBuyDate: string;
investment: Big; investment: Big;

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

@ -1,6 +1,12 @@
import { Big } from 'big.js';
import { TransactionPointSymbol } from './transaction-point-symbol.interface'; import { TransactionPointSymbol } from './transaction-point-symbol.interface';
export interface TransactionPoint { export interface TransactionPoint {
date: string; date: string;
fees: Big;
interest: Big;
items: TransactionPointSymbol[]; items: TransactionPointSymbol[];
liabilities: Big;
valuables: Big;
} }

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

@ -1,86 +0,0 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
});
describe('get current positions', () => {
it('with no orders', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: []
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const chartData = await portfolioCalculator.getChartData({
start: new Date()
});
const currentPositions = await portfolioCalculator.getCurrentPositions(
new Date()
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffect: new Big(0),
netPerformanceWithCurrencyEffect: new Big(0),
positions: [],
totalInvestment: new Big(0)
});
expect(investments).toEqual([]);
expect(investmentsByMonth).toEqual([]);
});
});
});

152
apps/api/src/app/portfolio/portfolio-calculator-novn-baln-buy-and-sell.spec.ts

@ -1,152 +0,0 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
});
describe('get current positions', () => {
it.only('with NOVN.SW and BALN.SW buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: [
{
currency: 'CHF',
date: '2022-03-07',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Novartis AG',
quantity: new Big(2),
symbol: 'NOVN.SW',
type: 'BUY',
unitPrice: new Big(75.8)
},
{
currency: 'CHF',
date: '2022-04-01',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Novartis AG',
quantity: new Big(0),
symbol: 'NOVN.SW',
type: 'BUY',
unitPrice: new Big(80.0)
},
{
currency: 'CHF',
date: '2022-04-08',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Novartis AG',
quantity: new Big(2),
symbol: 'NOVN.SW',
type: 'SELL',
unitPrice: new Big(85.73)
},
{
currency: 'CHF',
date: '2022-03-22',
dataSource: 'YAHOO',
fee: new Big(1.55),
name: 'Bâloise Holding AG',
quantity: new Big(2),
symbol: 'BALN.SW',
type: 'BUY',
unitPrice: new Big(142.9)
},
{
currency: 'CHF',
date: '2022-04-01',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Bâloise Holding AG',
quantity: new Big(0),
symbol: 'BALN.SW',
type: 'BUY',
unitPrice: new Big(138)
},
{
currency: 'CHF',
date: '2022-04-10',
dataSource: 'YAHOO',
fee: new Big(1.65),
name: 'Bâloise Holding AG',
quantity: new Big(2),
symbol: 'BALN.SW',
type: 'SELL',
unitPrice: new Big(136.6)
}
]
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2022-03-07'),
calculateTimeWeightedPerformance: true
});
spy.mockRestore();
expect(chartData[0]).toEqual({
date: '2022-03-07',
investmentValueWithCurrencyEffect: 151.6,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
timeWeightedPerformance: 0,
totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6,
value: 151.6,
valueWithCurrencyEffect: 151.6
});
expect(chartData[chartData.length - 1]).toEqual({
date: '2022-04-11',
investmentValueWithCurrencyEffect: 0,
netPerformance: 19.86,
netPerformanceInPercentage: 13.100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744,
netPerformanceWithCurrencyEffect: 19.86,
timeWeightedPerformance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
});
});
});

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

File diff suppressed because it is too large

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

@ -1,17 +1,20 @@
import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor'; import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
import { AccessService } from '@ghostfolio/api/app/access/access.service'; import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
nullifyValuesInObject nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper'; } from '@ghostfolio/api/helper/object.helper';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
HEADER_KEY_IMPERSONATION HEADER_KEY_IMPERSONATION
@ -19,6 +22,7 @@ import {
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividends,
PortfolioHoldingsResponse,
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPublicDetails, PortfolioPublicDetails,
@ -44,11 +48,11 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import Big from 'big.js'; import { AssetClass, AssetSubClass } from '@prisma/client';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
@Controller('portfolio') @Controller('portfolio')
@ -58,6 +62,8 @@ export class PortfolioController {
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
@ -73,8 +79,11 @@ export class PortfolioController {
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('isAllocation') isAllocation: boolean = false @Query('isAllocation') isAllocation: boolean = false,
@Query('withMarkets') withMarketsParam = 'false'
): Promise<PortfolioDetails & { hasError: boolean }> { ): Promise<PortfolioDetails & { hasError: boolean }> {
const withMarkets = withMarketsParam === 'true';
let hasDetails = true; let hasDetails = true;
let hasError = false; let hasError = false;
const hasReadRestrictedAccessPermission = const hasReadRestrictedAccessPermission =
@ -93,22 +102,15 @@ export class PortfolioController {
filterByTags filterByTags
}); });
const { const { accounts, hasErrors, holdings, platforms, summary } =
accounts, await this.portfolioService.getDetails({
filteredValueInBaseCurrency, dateRange,
filteredValueInPercentage, filters,
hasErrors, impersonationId,
holdings, withMarkets,
platforms, userId: this.request.user.id,
summary, withSummary: true
totalValueInBaseCurrency });
} = await this.portfolioService.getDetails({
dateRange,
filters,
impersonationId,
userId: this.request.user.id,
isAllocation
});
if (hasErrors || hasNotDefinedValuesInObject(holdings)) { if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
hasError = true; hasError = true;
@ -120,40 +122,27 @@ export class PortfolioController {
hasReadRestrictedAccessPermission || hasReadRestrictedAccessPermission ||
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
) { ) {
let investmentTuple: [number, number] = [0, 0]; const totalInvestment = Object.values(holdings)
for (let holding of Object.entries(holdings)) { .map(({ investment }) => {
var portfolioPosition = holding[1]; return investment;
investmentTuple[0] += portfolioPosition.investment; })
investmentTuple[1] += this.exchangeRateDataService.toCurrency( .reduce((a, b) => a + b, 0);
portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency, const totalValue = Object.values(holdings)
this.request.user.Settings.settings.baseCurrency .filter(({ assetClass, assetSubClass }) => {
); return (
} assetClass !== AssetClass.LIQUIDITY &&
const totalInvestment = investmentTuple[0]; assetSubClass !== AssetSubClass.CASH
);
const totalValue = investmentTuple[1]; })
.map(({ valueInBaseCurrency }) => {
if (hasDetails === false) { return valueInBaseCurrency;
portfolioSummary = nullifyValuesInObject(summary, [ })
'cash', .reduce((a, b) => {
'committedFunds', return a + b;
'currentGrossPerformance', }, 0);
'currentNetPerformance',
'currentValue', for (const [, portfolioPosition] of Object.entries(holdings)) {
'dividend',
'emergencyFund',
'excludedAccountsAndActivities',
'fees',
'items',
'liabilities',
'netWorth',
'totalBuy',
'totalSell'
]);
}
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
portfolioPosition.investment = portfolioPosition.investment =
portfolioPosition.investment / totalInvestment; portfolioPosition.investment / totalInvestment;
portfolioPosition.valueInPercentage = portfolioPosition.valueInPercentage =
@ -195,23 +184,25 @@ export class PortfolioController {
portfolioSummary = nullifyValuesInObject(summary, [ portfolioSummary = nullifyValuesInObject(summary, [
'cash', 'cash',
'committedFunds', 'committedFunds',
'currentGrossPerformance', 'currentNetWorth',
'currentGrossPerformanceWithCurrencyEffect', 'currentValueInBaseCurrency',
'currentNetPerformance', 'dividendInBaseCurrency',
'currentNetPerformanceWithCurrencyEffect',
'currentValue',
'dividend',
'emergencyFund', 'emergencyFund',
'excludedAccountsAndActivities', 'excludedAccountsAndActivities',
'fees', 'fees',
'filteredValueInBaseCurrency',
'fireWealth', 'fireWealth',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'interest', 'interest',
'items', 'items',
'liabilities', 'liabilities',
'netWorth', 'netPerformance',
'netPerformanceWithCurrencyEffect',
'totalBuy', 'totalBuy',
'totalInvestment', 'totalInvestment',
'totalSell' 'totalSell',
'totalValueInBaseCurrency'
]); ]);
} }
@ -219,11 +210,11 @@ export class PortfolioController {
holdings[symbol] = { holdings[symbol] = {
...portfolioPosition, ...portfolioPosition,
assetClass: assetClass:
hasDetails || portfolioPosition.assetClass === 'CASH' hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetClass ? portfolioPosition.assetClass
: undefined, : undefined,
assetSubClass: assetSubClass:
hasDetails || portfolioPosition.assetSubClass === 'CASH' hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetSubClass ? portfolioPosition.assetSubClass
: undefined, : undefined,
countries: hasDetails ? portfolioPosition.countries : [], countries: hasDetails ? portfolioPosition.countries : [],
@ -238,12 +229,9 @@ export class PortfolioController {
return { return {
accounts, accounts,
filteredValueInBaseCurrency,
filteredValueInPercentage,
hasError, hasError,
holdings, holdings,
platforms, platforms,
totalValueInBaseCurrency,
summary: portfolioSummary summary: portfolioSummary
}; };
} }
@ -270,11 +258,24 @@ export class PortfolioController {
filterByTags filterByTags
}); });
let dividends = await this.portfolioService.getDividends({ const impersonationUserId =
dateRange, await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const { endDate, startDate } = getInterval(dateRange);
const { activities } = await this.orderService.getOrders({
endDate,
filters, filters,
groupBy, startDate,
impersonationId userCurrency,
userId: impersonationUserId || this.request.user.id,
types: ['DIVIDEND']
});
let dividends = await this.portfolioService.getDividends({
activities,
groupBy
}); });
if ( if (
@ -304,6 +305,37 @@ export class PortfolioController {
return { dividends }; return { dividends };
} }
@Get('holdings')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getHoldings(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('holdingType') filterByHoldingType?: string,
@Query('query') filterBySearchQuery?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioHoldingsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByHoldingType,
filterBySearchQuery,
filterByTags
});
const { holdings } = await this.portfolioService.getDetails({
dateRange,
filters,
impersonationId,
userId: this.request.user.id
});
return { holdings: Object.values(holdings) };
}
@Get('investments') @Get('investments')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getInvestments( public async getInvestments(
@ -403,7 +435,6 @@ export class PortfolioController {
filters, filters,
impersonationId, impersonationId,
withExcludedAccounts, withExcludedAccounts,
withItems,
userId: this.request.user.id, userId: this.request.user.id,
calculateTimeWeightedPerformance calculateTimeWeightedPerformance
}); });
@ -417,6 +448,7 @@ export class PortfolioController {
({ ({
date, date,
netPerformanceInPercentage, netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
netWorth, netWorth,
totalInvestment, totalInvestment,
value value
@ -424,6 +456,7 @@ export class PortfolioController {
return { return {
date, date,
netPerformanceInPercentage, netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
netWorthInPercentage: netWorthInPercentage:
performanceInformation.performance.currentNetWorth === 0 performanceInformation.performance.currentNetWorth === 0
? 0 ? 0
@ -437,10 +470,14 @@ export class PortfolioController {
.div(performanceInformation.performance.totalInvestment) .div(performanceInformation.performance.totalInvestment)
.toNumber(), .toNumber(),
valueInPercentage: valueInPercentage:
performanceInformation.performance.currentValue === 0 performanceInformation.performance.currentValueInBaseCurrency ===
0
? 0 ? 0
: new Big(value) : new Big(value)
.div(performanceInformation.performance.currentValue) .div(
performanceInformation.performance
.currentValueInBaseCurrency
)
.toNumber() .toNumber()
}; };
} }
@ -449,12 +486,12 @@ export class PortfolioController {
performanceInformation.performance = nullifyValuesInObject( performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance, performanceInformation.performance,
[ [
'currentGrossPerformance',
'currentGrossPerformanceWithCurrencyEffect',
'currentNetPerformance',
'currentNetPerformanceWithCurrencyEffect',
'currentNetWorth', 'currentNetWorth',
'currentValue', 'currentValueInBaseCurrency',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'totalInvestment' 'totalInvestment'
] ]
); );
@ -471,39 +508,13 @@ export class PortfolioController {
); );
performanceInformation.performance = nullifyValuesInObject( performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance, performanceInformation.performance,
['currentNetPerformance', 'currentNetPerformancePercent'] ['netPerformance']
); );
} }
return performanceInformation; return performanceInformation;
} }
@Get('positions')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('query') filterBySearchQuery?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioPositions> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterBySearchQuery,
filterByTags
});
return this.portfolioService.getPositions({
dateRange,
filters,
impersonationId
});
}
@Get('public/:accessId') @Get('public/:accessId')
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublic( public async getPublic(
@ -527,10 +538,10 @@ export class PortfolioController {
} }
const { holdings } = await this.portfolioService.getDetails({ const { holdings } = await this.portfolioService.getDetails({
dateRange: 'max',
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }], filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
impersonationId: access.userId, impersonationId: access.userId,
userId: user.id userId: user.id,
withMarkets: true
}); });
const portfolioPublicDetails: PortfolioPublicDetails = { const portfolioPublicDetails: PortfolioPublicDetails = {

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

@ -2,6 +2,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
@ -15,6 +16,7 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/sym
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { PortfolioController } from './portfolio.controller'; import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
@ -34,6 +36,7 @@ import { RulesService } from './rules.service';
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,
PrismaModule, PrismaModule,
RedisCacheModule,
SymbolProfileModule, SymbolProfileModule,
UserModule UserModule
], ],
@ -41,6 +44,7 @@ import { RulesService } from './rules.service';
AccountBalanceService, AccountBalanceService,
AccountService, AccountService,
CurrentRateService, CurrentRateService,
PortfolioCalculatorFactory,
PortfolioService, PortfolioService,
RulesService RulesService
] ]

52
apps/api/src/app/portfolio/portfolio-calculator.spec.ts → apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -1,18 +1,19 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { Big } from 'big.js';
import Big from 'big.js'; import { PortfolioService } from './portfolio.service';
import { CurrentRateService } from './current-rate.service'; describe('PortfolioService', () => {
import { PortfolioCalculator } from './portfolio-calculator'; let portfolioService: PortfolioService;
describe('PortfolioCalculator', () => { beforeAll(async () => {
let currentRateService: CurrentRateService; portfolioService = new PortfolioService(
let exchangeRateDataService: ExchangeRateDataService; null,
null,
beforeEach(() => { null,
currentRateService = new CurrentRateService(null, null, null, null); null,
null,
exchangeRateDataService = new ExchangeRateDataService( null,
null,
null, null,
null, null,
null, null,
@ -21,28 +22,21 @@ describe('PortfolioCalculator', () => {
}); });
describe('annualized performance percentage', () => { describe('annualized performance percentage', () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'USD',
orders: []
});
it('Get annualized performance', async () => { it('Get annualized performance', async () => {
expect( expect(
portfolioCalculator portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
netPerformancePercent: new Big(0) netPerformancePercentage: new Big(0)
}) })
.toNumber() .toNumber()
).toEqual(0); ).toEqual(0);
expect( expect(
portfolioCalculator portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: 0, daysInMarket: 0,
netPerformancePercent: new Big(0) netPerformancePercentage: new Big(0)
}) })
.toNumber() .toNumber()
).toEqual(0); ).toEqual(0);
@ -51,19 +45,19 @@ describe('PortfolioCalculator', () => {
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html * Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
*/ */
expect( expect(
portfolioCalculator portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: 65, // < 1 year daysInMarket: 65, // < 1 year
netPerformancePercent: new Big(0.1025) netPerformancePercentage: new Big(0.1025)
}) })
.toNumber() .toNumber()
).toBeCloseTo(0.729705); ).toBeCloseTo(0.729705);
expect( expect(
portfolioCalculator portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: 365, // 1 year daysInMarket: 365, // 1 year
netPerformancePercent: new Big(0.05) netPerformancePercentage: new Big(0.05)
}) })
.toNumber() .toNumber()
).toBeCloseTo(0.05); ).toBeCloseTo(0.05);
@ -72,10 +66,10 @@ describe('PortfolioCalculator', () => {
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation * Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
*/ */
expect( expect(
portfolioCalculator portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: 575, // > 1 year daysInMarket: 575, // > 1 year
netPerformancePercent: new Big(0.2374) netPerformancePercentage: new Big(0.2374)
}) })
.toNumber() .toNumber()
).toBeCloseTo(0.145); ).toBeCloseTo(0.145);

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

File diff suppressed because it is too large

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

@ -17,8 +17,16 @@ export class RulesService {
return rule.getSettings(aUserSettings)?.isActive; return rule.getSettings(aUserSettings)?.isActive;
}) })
.map((rule) => { .map((rule) => {
const evaluationResult = rule.evaluate(rule.getSettings(aUserSettings)); const { evaluation, value } = rule.evaluate(
return { ...evaluationResult, name: rule.getName() }; rule.getSettings(aUserSettings)
);
return {
evaluation,
value,
key: rule.getKey(),
name: rule.getName()
};
}); });
} }
} }

13
apps/api/src/app/redis-cache/redis-cache.service.mock.ts

@ -0,0 +1,13 @@
import { RedisCacheService } from './redis-cache.service';
export const RedisCacheServiceMock = {
get: (key: string): Promise<string> => {
return Promise.resolve(null);
},
getPortfolioSnapshotKey: (userId: string): string => {
return `portfolio-snapshot-${userId}`;
},
set: (key: string, value: string, ttlInSeconds?: number): Promise<string> => {
return Promise.resolve(value);
}
};

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

@ -21,7 +21,11 @@ export class RedisCacheService {
} }
public async get(key: string): Promise<string> { public async get(key: string): Promise<string> {
return await this.cache.get(key); return this.cache.get(key);
}
public getPortfolioSnapshotKey({ userId }: { userId: string }) {
return `portfolio-snapshot-${userId}`;
} }
public getQuoteKey({ dataSource, symbol }: UniqueAsset) { public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
@ -29,15 +33,15 @@ export class RedisCacheService {
} }
public async remove(key: string) { public async remove(key: string) {
await this.cache.del(key); return this.cache.del(key);
} }
public async reset() { public async reset() {
await this.cache.reset(); return this.cache.reset();
} }
public async set(key: string, value: string, ttlInSeconds?: number) { public async set(key: string, value: string, ttlInSeconds?: number) {
await this.cache.set( return this.cache.set(
key, key,
value, value,
ttlInSeconds ?? this.configurationService.get('CACHE_TTL') ttlInSeconds ?? this.configurationService.get('CACHE_TTL')

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

@ -116,7 +116,7 @@ export class SubscriptionController {
@Body() { couponId, priceId }: { couponId: string; priceId: string } @Body() { couponId, priceId }: { couponId: string; priceId: string }
) { ) {
try { try {
return await this.subscriptionService.createCheckoutSession({ return this.subscriptionService.createCheckoutSession({
couponId, couponId,
priceId, priceId,
user: this.request.user user: this.request.user

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

@ -39,9 +39,11 @@ export class SymbolController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async lookupSymbol( public async lookupSymbol(
@Query('includeIndices') includeIndices = false, @Query('includeIndices') includeIndicesParam = 'false',
@Query('query') query = '' @Query('query') query = ''
): Promise<{ items: LookupItem[] }> { ): Promise<{ items: LookupItem[] }> {
const includeIndices = includeIndicesParam === 'true';
try { try {
return this.symbolService.lookup({ return this.symbolService.lookup({
includeIndices, includeIndices,

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

@ -74,11 +74,21 @@ export class SymbolService {
date = new Date(), date = new Date(),
symbol symbol
}: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> { }: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> {
const historicalData = await this.dataProviderService.getHistoricalRaw( let historicalData: {
[{ dataSource, symbol }], [symbol: string]: {
date, [date: string]: IDataProviderHistoricalResponse;
date };
); } = {
[symbol]: {}
};
try {
historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [{ dataSource, symbol }],
from: date,
to: date
});
} catch {}
return { return {
marketPrice: marketPrice:

8
apps/api/src/app/user/update-user-setting.dto.ts

@ -14,6 +14,7 @@ import {
IsOptional, IsOptional,
IsString IsString
} from 'class-validator'; } from 'class-validator';
import { eachYearOfInterval, format } from 'date-fns';
export class UpdateUserSettingDto { export class UpdateUserSettingDto {
@IsNumber() @IsNumber()
@ -42,7 +43,12 @@ export class UpdateUserSettingDto {
'max', 'max',
'mtd', 'mtd',
'wtd', 'wtd',
'ytd' 'ytd',
...eachYearOfInterval({ end: new Date(), start: new Date(0) }).map(
(date) => {
return format(date, 'yyyy');
}
)
]) ])
@IsOptional() @IsOptional()
dateRange?: DateRange; dateRange?: DateRange;

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

@ -2,11 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { User, UserSettings } from '@ghostfolio/common/interfaces'; import { User, UserSettings } from '@ghostfolio/common/interfaces';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
hasPermission,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -63,13 +59,6 @@ export class UserController {
public async getUser( public async getUser(
@Headers('accept-language') acceptLanguage: string @Headers('accept-language') acceptLanguage: string
): Promise<User> { ): Promise<User> {
if (hasRole(this.request.user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
return this.userService.getUser( return this.userService.getUser(
this.request.user, this.request.user,
acceptLanguage?.split(',')?.[0] acceptLanguage?.split(',')?.[0]
@ -135,7 +124,7 @@ export class UserController {
} }
} }
return await this.userService.updateUserSetting({ return this.userService.updateUserSetting({
userSettings, userSettings,
userId: this.request.user.id userId: this.request.user.id
}); });

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

@ -1,5 +1,6 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -25,6 +26,7 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client'; import { Prisma, Role, User } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { sortBy, without } from 'lodash'; import { sortBy, without } from 'lodash';
@ -37,6 +39,7 @@ export class UserService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly eventEmitter: EventEmitter2,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
@ -51,13 +54,22 @@ export class UserService {
{ Account, id, permissions, Settings, subscription }: UserWithSettings, { Account, id, permissions, Settings, subscription }: UserWithSettings,
aLocale = locale aLocale = locale
): Promise<IUser> { ): Promise<IUser> {
const access = await this.prismaService.access.findMany({ let [access, firstActivity, tags] = await Promise.all([
include: { this.prismaService.access.findMany({
User: true include: {
}, User: true
orderBy: { alias: 'asc' }, },
where: { GranteeUser: { id } } orderBy: { alias: 'asc' },
}); where: { GranteeUser: { id } }
}),
this.prismaService.order.findFirst({
orderBy: {
date: 'asc'
},
where: { userId: id }
}),
this.tagService.getByUser(id)
]);
let systemMessage: SystemMessage; let systemMessage: SystemMessage;
@ -69,8 +81,6 @@ export class UserService {
systemMessage = systemMessageProperty; systemMessage = systemMessageProperty;
} }
let tags = await this.tagService.getByUser(id);
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === 'Basic' subscription.type === 'Basic'
@ -91,6 +101,7 @@ export class UserService {
}; };
}), }),
accounts: Account, accounts: Account,
dateOfFirstActivity: firstActivity?.date ?? new Date(),
settings: { settings: {
...(<UserSettings>Settings.settings), ...(<UserSettings>Settings.settings),
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale locale: (<UserSettings>Settings.settings)?.locale ?? aLocale
@ -429,11 +440,9 @@ export class UserService {
userId: string; userId: string;
userSettings: UserSettings; userSettings: UserSettings;
}) { }) {
const settings = userSettings as unknown as Prisma.JsonObject; const { settings } = await this.prismaService.settings.upsert({
await this.prismaService.settings.upsert({
create: { create: {
settings, settings: userSettings as unknown as Prisma.JsonObject,
User: { User: {
connect: { connect: {
id: userId id: userId
@ -441,25 +450,33 @@ export class UserService {
} }
}, },
update: { update: {
settings settings: userSettings as unknown as Prisma.JsonObject
}, },
where: { where: {
userId userId
} }
}); });
return; this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId
})
);
return settings;
} }
private getRandomString(length: number) { private getRandomString(length: number) {
const bytes = crypto.randomBytes(length);
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const result = []; const result = [];
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
result.push( const randomByte = bytes[i];
characters.charAt(Math.floor(Math.random() * characters.length)) result.push(characters[randomByte % characters.length]);
);
} }
return result.join(''); return result.join('');
} }
} }

2
apps/api/src/assets/cryptocurrencies/custom.json

@ -4,6 +4,8 @@
"LUNA1": "Terra", "LUNA1": "Terra",
"LUNA2": "Terra", "LUNA2": "Terra",
"SGB1": "Songbird", "SGB1": "Songbird",
"SMURFCAT": "Real Smurf Cat",
"TON11419": "Toncoin",
"UNI1": "Uniswap", "UNI1": "Uniswap",
"UNI7083": "Uniswap", "UNI7083": "Uniswap",
"UST": "TerraUSD" "UST": "TerraUSD"

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

@ -1232,10 +1232,12 @@
<loc>https://ghostfol.io/nl/veelgestelde-vragen</loc> <loc>https://ghostfol.io/nl/veelgestelde-vragen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<!--
<url> <url>
<loc>https://ghostfol.io/pl</loc> <loc>https://ghostfol.io/pl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
-->
<url> <url>
<loc>https://ghostfol.io/pt</loc> <loc>https://ghostfol.io/pt</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -1296,4 +1298,10 @@
<loc>https://ghostfol.io/tr</loc> <loc>https://ghostfol.io/tr</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<!--
<url>
<loc>https://ghostfol.io/zh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
</urlset> </urlset>

2
apps/api/src/environments/environment.prod.ts

@ -1,4 +1,4 @@
export const environment = { export const environment = {
production: true, production: true,
version: `v${require('../../../../package.json').version}` version: `${require('../../../../package.json').version}`
}; };

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

@ -0,0 +1,11 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { Module } from '@nestjs/common';
import { PortfolioChangedListener } from './portfolio-changed.listener';
@Module({
imports: [RedisCacheModule],
providers: [PortfolioChangedListener]
})
export class EventsModule {}

15
apps/api/src/events/portfolio-changed.event.ts

@ -0,0 +1,15 @@
export class PortfolioChangedEvent {
private userId: string;
public constructor({ userId }: { userId: string }) {
this.userId = userId;
}
public static getName() {
return 'portfolio.changed';
}
public getUserId() {
return this.userId;
}
}

25
apps/api/src/events/portfolio-changed.listener.ts

@ -0,0 +1,25 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PortfolioChangedEvent } from './portfolio-changed.event';
@Injectable()
export class PortfolioChangedListener {
public constructor(private readonly redisCacheService: RedisCacheService) {}
@OnEvent(PortfolioChangedEvent.getName())
handlePortfolioChangedEvent(event: PortfolioChangedEvent) {
Logger.log(
`Portfolio of user '${event.getUserId()}' has changed`,
'PortfolioChangedListener'
);
this.redisCacheService.remove(
this.redisCacheService.getPortfolioSnapshotKey({
userId: event.getUserId()
})
);
}
}

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

@ -1,4 +1,4 @@
import Big from 'big.js'; import { Big } from 'big.js';
import { cloneDeep, isArray, isObject } from 'lodash'; import { cloneDeep, isArray, isObject } from 'lodash';
export function hasNotDefinedValuesInObject(aObject: Object): boolean { export function hasNotDefinedValuesInObject(aObject: Object): boolean {

91
apps/api/src/helper/portfolio.helper.ts

@ -0,0 +1,91 @@
import { resetHours } from '@ghostfolio/common/helper';
import { DateRange } from '@ghostfolio/common/types';
import { Type as ActivityType } from '@prisma/client';
import {
endOfDay,
max,
subDays,
startOfMonth,
startOfWeek,
startOfYear,
subYears,
endOfYear
} from 'date-fns';
export function getFactor(activityType: ActivityType) {
let factor: number;
switch (activityType) {
case 'BUY':
case 'STAKE':
factor = 1;
break;
case 'SELL':
factor = -1;
break;
default:
factor = 0;
break;
}
return factor;
}
export function getInterval(
aDateRange: DateRange,
portfolioStart = new Date(0)
) {
let endDate = endOfDay(new Date(Date.now()));
let startDate = portfolioStart;
switch (aDateRange) {
case '1d':
startDate = max([
startDate,
subDays(resetHours(new Date(Date.now())), 1)
]);
break;
case 'mtd':
startDate = max([
startDate,
subDays(startOfMonth(resetHours(new Date(Date.now()))), 1)
]);
break;
case 'wtd':
startDate = max([
startDate,
subDays(
startOfWeek(resetHours(new Date(Date.now())), { weekStartsOn: 1 }),
1
)
]);
break;
case 'ytd':
startDate = max([
startDate,
subDays(startOfYear(resetHours(new Date(Date.now()))), 1)
]);
break;
case '1y':
startDate = max([
startDate,
subYears(resetHours(new Date(Date.now())), 1)
]);
break;
case '5y':
startDate = max([
startDate,
subYears(resetHours(new Date(Date.now())), 5)
]);
break;
case 'max':
break;
default:
// '2024', '2023', '2022', etc.
endDate = endOfYear(new Date(aDateRange));
startDate = max([startDate, new Date(aDateRange)]);
}
return { endDate, startDate };
}

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

@ -49,7 +49,6 @@ export class RedactValuesInResponseInterceptor<T>
'dividendInBaseCurrency', 'dividendInBaseCurrency',
'fee', 'fee',
'feeInBaseCurrency', 'feeInBaseCurrency',
'filteredValueInBaseCurrency',
'grossPerformance', 'grossPerformance',
'grossPerformanceWithCurrencyEffect', 'grossPerformanceWithCurrencyEffect',
'investment', 'investment',

4
apps/api/src/models/order.ts

@ -1,6 +1,6 @@
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces'; import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client'; import { Account, SymbolProfile, Type as ActivityType } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
export class Order { export class Order {
@ -14,7 +14,7 @@ export class Order {
private symbol: string; private symbol: string;
private symbolProfile: SymbolProfile; private symbolProfile: SymbolProfile;
private total: number; private total: number;
private type: TypeOfOrder; private type: ActivityType;
private unitPrice: number; private unitPrice: number;
public constructor(data: IOrder) { public constructor(data: IOrder) {

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

@ -1,25 +1,34 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { groupBy } from '@ghostfolio/common/helper'; import { groupBy } from '@ghostfolio/common/helper';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; import { UserSettings } from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models';
import { EvaluationResult } from './interfaces/evaluation-result.interface'; import { EvaluationResult } from './interfaces/evaluation-result.interface';
import { RuleInterface } from './interfaces/rule.interface'; import { RuleInterface } from './interfaces/rule.interface';
export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> { export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
private key: string;
private name: string; private name: string;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
{ {
key,
name name
}: { }: {
key: string;
name: string; name: string;
} }
) { ) {
this.key = key;
this.name = name; this.name = name;
} }
public getKey() {
return this.key;
}
public getName() { public getName() {
return this.name; return this.name;
} }

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

@ -15,6 +15,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
accounts: PortfolioDetails['accounts'] accounts: PortfolioDetails['accounts']
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: AccountClusterRiskCurrentInvestment.name,
name: 'Investment' name: 'Investment'
}); });
@ -35,7 +36,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} }
let maxItem; let maxItem: (typeof accounts)[0];
let totalInvestment = 0; let totalInvestment = 0;
for (const account of Object.values(accounts)) { for (const account of Object.values(accounts)) {
@ -52,7 +53,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
} }
} }
const maxInvestmentRatio = maxItem.investment / totalInvestment; const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0;
if (maxInvestmentRatio > ruleSettings.threshold) { if (maxInvestmentRatio > ruleSettings.threshold) {
return { return {

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

@ -11,6 +11,7 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
accounts: PortfolioDetails['accounts'] accounts: PortfolioDetails['accounts']
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: AccountClusterRiskSingleAccount.name,
name: 'Single Account' name: 'Single Account'
}); });

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

@ -1,7 +1,8 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; import { UserSettings } from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models';
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> { export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
private positions: TimelinePosition[]; private positions: TimelinePosition[];
@ -11,6 +12,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
positions: TimelinePosition[] positions: TimelinePosition[]
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: CurrencyClusterRiskBaseCurrencyCurrentInvestment.name,
name: 'Investment: Base Currency' name: 'Investment: Base Currency'
}); });
@ -43,7 +45,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
const baseCurrencyValueRatio = baseCurrencyItem?.value / totalValue || 0; const baseCurrencyValueRatio = baseCurrencyItem?.value / totalValue || 0;
if (maxItem.groupKey !== ruleSettings.baseCurrency) { if (maxItem?.groupKey !== ruleSettings.baseCurrency) {
return { return {
evaluation: `The major part of your current investment is not in your base currency (${( evaluation: `The major part of your current investment is not in your base currency (${(
baseCurrencyValueRatio * 100 baseCurrencyValueRatio * 100

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

@ -1,7 +1,8 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; import { UserSettings } from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models';
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> { export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
private positions: TimelinePosition[]; private positions: TimelinePosition[];
@ -11,6 +12,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
positions: TimelinePosition[] positions: TimelinePosition[]
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: CurrencyClusterRiskCurrentInvestment.name,
name: 'Investment' name: 'Investment'
}); });
@ -37,7 +39,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
} }
}); });
const maxValueRatio = maxItem.value / totalValue; const maxValueRatio = maxItem?.value / totalValue || 0;
if (maxValueRatio > ruleSettings.threshold) { if (maxValueRatio > ruleSettings.threshold) {
return { return {
@ -52,7 +54,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
return { return {
evaluation: `The major part of your current investment is in ${ evaluation: `The major part of your current investment is in ${
maxItem.groupKey maxItem?.groupKey ?? ruleSettings.baseCurrency
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${ } (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${
ruleSettings.threshold * 100 ruleSettings.threshold * 100
}%`, }%`,

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

@ -11,6 +11,7 @@ export class EmergencyFundSetup extends Rule<Settings> {
emergencyFund: number emergencyFund: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: EmergencyFundSetup.name,
name: 'Emergency Fund: Set up' name: 'Emergency Fund: Set up'
}); });

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

@ -13,6 +13,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
fees: number fees: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: FeeRatioInitialInvestment.name,
name: 'Fee Ratio' name: 'Fee Ratio'
}); });

10
apps/api/src/services/api/api.service.ts

@ -10,18 +10,21 @@ export class ApiService {
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByAssetSubClasses, filterByAssetSubClasses,
filterByHoldingType,
filterBySearchQuery, filterBySearchQuery,
filterByTags filterByTags
}: { }: {
filterByAccounts?: string; filterByAccounts?: string;
filterByAssetClasses?: string; filterByAssetClasses?: string;
filterByAssetSubClasses?: string; filterByAssetSubClasses?: string;
filterByHoldingType?: string;
filterBySearchQuery?: string; filterBySearchQuery?: string;
filterByTags?: string; filterByTags?: string;
}): Filter[] { }): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? []; const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? []; const assetClasses = filterByAssetClasses?.split(',') ?? [];
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const holdingType = filterByHoldingType;
const searchQuery = filterBySearchQuery?.toLowerCase(); const searchQuery = filterBySearchQuery?.toLowerCase();
const tagIds = filterByTags?.split(',') ?? []; const tagIds = filterByTags?.split(',') ?? [];
@ -52,6 +55,13 @@ export class ApiService {
}) })
]; ];
if (holdingType) {
filters.push({
id: holdingType,
type: 'HOLDING_TYPE'
});
}
if (searchQuery) { if (searchQuery) {
filters.push({ filters.push({
id: searchQuery, id: searchQuery,

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

@ -3,7 +3,7 @@ import { DEFAULT_ROOT_URL } from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str } from 'envalid'; import { bool, cleanEnv, host, json, num, port, str, url } from 'envalid';
@Injectable() @Injectable()
export class ConfigurationService { export class ConfigurationService {
@ -49,14 +49,13 @@ export class ConfigurationService {
REDIS_PASSWORD: str({ default: '' }), REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }), REDIS_PORT: port({ default: 6379 }),
REQUEST_TIMEOUT: num({ default: 2000 }), REQUEST_TIMEOUT: num({ default: 2000 }),
ROOT_URL: str({ default: DEFAULT_ROOT_URL }), ROOT_URL: url({ default: DEFAULT_ROOT_URL }),
STRIPE_PUBLIC_KEY: str({ default: '' }), STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }),
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),
TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }), TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }),
TWITTER_API_KEY: str({ default: 'dummyApiKey' }), TWITTER_API_KEY: str({ default: 'dummyApiKey' }),
TWITTER_API_SECRET: str({ default: 'dummyApiSecret' }), TWITTER_API_SECRET: str({ default: 'dummyApiSecret' })
WEB_AUTH_RP_ID: host({ default: 'localhost' })
}); });
} }

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

@ -1,4 +1,5 @@
import { import {
DATA_GATHERING_QUEUE_PRIORITY_LOW,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS, GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
PROPERTY_IS_DATA_GATHERING_ENABLED PROPERTY_IS_DATA_GATHERING_ENABLED
@ -56,7 +57,8 @@ export class CronService {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }) jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
} }
}; };
}) })

20
apps/api/src/services/data-gathering/data-gathering.processor.ts

@ -37,7 +37,17 @@ export class DataGatheringProcessor {
@Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS }) @Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS })
public async gatherAssetProfile(job: Job<UniqueAsset>) { public async gatherAssetProfile(job: Job<UniqueAsset>) {
try { try {
Logger.log(
`Asset profile data gathering has been started for ${job.data.symbol} (${job.data.dataSource})`,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS})`
);
await this.dataGatheringService.gatherAssetProfiles([job.data]); await this.dataGatheringService.gatherAssetProfiles([job.data]);
Logger.log(
`Asset profile data gathering has been completed for ${job.data.symbol} (${job.data.dataSource})`,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS})`
);
} catch (error) { } catch (error) {
Logger.error( Logger.error(
error, error,
@ -62,11 +72,11 @@ export class DataGatheringProcessor {
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})` `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
); );
const historicalData = await this.dataProviderService.getHistoricalRaw( const historicalData = await this.dataProviderService.getHistoricalRaw({
[{ dataSource, symbol }], dataGatheringItems: [{ dataSource, symbol }],
currentDate, from: currentDate,
new Date() to: new Date()
); });
const data: Prisma.MarketDataUpdateInput[] = []; const data: Prisma.MarketDataUpdateInput[] = [];
let lastMarketPrice: number; let lastMarketPrice: number;

63
apps/api/src/services/data-gathering/data-gathering.service.ts

@ -8,6 +8,8 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DATA_GATHERING_QUEUE, DATA_GATHERING_QUEUE,
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW,
GATHER_HISTORICAL_MARKET_DATA_PROCESS, GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS
@ -64,24 +66,35 @@ export class DataGatheringService {
public async gather7Days() { public async gather7Days() {
const dataGatheringItems = await this.getSymbols7D(); const dataGatheringItems = await this.getSymbols7D();
await this.gatherSymbols(dataGatheringItems); await this.gatherSymbols({
dataGatheringItems,
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
});
} }
public async gatherMax() { public async gatherMax() {
const dataGatheringItems = await this.getSymbolsMax(); const dataGatheringItems = await this.getSymbolsMax();
await this.gatherSymbols(dataGatheringItems); await this.gatherSymbols({
dataGatheringItems,
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
});
} }
public async gatherSymbol({ dataSource, symbol }: UniqueAsset) { public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
const symbols = (await this.getSymbolsMax()).filter((dataGatheringItem) => { const dataGatheringItems = (await this.getSymbolsMax()).filter(
return ( (dataGatheringItem) => {
dataGatheringItem.dataSource === dataSource && return (
dataGatheringItem.symbol === symbol dataGatheringItem.dataSource === dataSource &&
); dataGatheringItem.symbol === symbol
);
}
);
await this.gatherSymbols({
dataGatheringItems,
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
}); });
await this.gatherSymbols(symbols);
} }
public async gatherSymbolForDate({ public async gatherSymbolForDate({
@ -94,11 +107,11 @@ export class DataGatheringService {
symbol: string; symbol: string;
}) { }) {
try { try {
const historicalData = await this.dataProviderService.getHistoricalRaw( const historicalData = await this.dataProviderService.getHistoricalRaw({
[{ dataSource, symbol }], dataGatheringItems: [{ dataSource, symbol }],
date, from: date,
date to: date
); });
const marketPrice = const marketPrice =
historicalData[symbol][format(date, DATE_FORMAT)].marketPrice; historicalData[symbol][format(date, DATE_FORMAT)].marketPrice;
@ -225,22 +238,23 @@ export class DataGatheringService {
error, error,
'DataGatheringService' 'DataGatheringService'
); );
if (uniqueAssets.length === 1) {
throw error;
}
} }
} }
Logger.log(
`Asset profile data gathering has been completed for ${uniqueAssets
.map(({ dataSource, symbol }) => {
return `${symbol} (${dataSource})`;
})
.join(',')}.`,
'DataGatheringService'
);
} }
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) { public async gatherSymbols({
dataGatheringItems,
priority
}: {
dataGatheringItems: IDataGatheringItem[];
priority: number;
}) {
await this.addJobsToQueue( await this.addJobsToQueue(
aSymbolsWithStartDate.map(({ dataSource, date, symbol }) => { dataGatheringItems.map(({ dataSource, date, symbol }) => {
return { return {
data: { data: {
dataSource, dataSource,
@ -250,6 +264,7 @@ export class DataGatheringService {
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS, name: GATHER_HISTORICAL_MARKET_DATA_PROCESS,
opts: { opts: {
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, ...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
priority,
jobId: `${getAssetProfileIdentifier({ jobId: `${getAssetProfileIdentifier({
dataSource, dataSource,
symbol symbol

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

@ -59,7 +59,7 @@ export class CoinGeckoService implements DataProviderInterface {
}): Promise<Partial<SymbolProfile>> { }): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = { const response: Partial<SymbolProfile> = {
symbol, symbol,
assetClass: AssetClass.CASH, assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CRYPTOCURRENCY, assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: DEFAULT_CURRENCY, currency: DEFAULT_CURRENCY,
dataSource: this.getName() dataSource: this.getName()
@ -243,7 +243,7 @@ export class CoinGeckoService implements DataProviderInterface {
return { return {
name, name,
symbol, symbol,
assetClass: AssetClass.CASH, assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CRYPTOCURRENCY, assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: DEFAULT_CURRENCY, currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),

9
apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts

@ -5,12 +5,12 @@ import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import { countries } from 'countries-list';
import got from 'got'; import got from 'got';
@Injectable() @Injectable()
export class TrackinsightDataEnhancerService implements DataEnhancerInterface { export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://www.trackinsight.com/data-api'; private static baseUrl = 'https://www.trackinsight.com/data-api';
private static countries = require('countries-list/dist/countries.json');
private static countriesMapping = { private static countriesMapping = {
'Russian Federation': 'Russia' 'Russian Federation': 'Russia'
}; };
@ -127,20 +127,19 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
(response.countries as unknown as Country[]).length === 0 (response.countries as unknown as Country[]).length === 0
) { ) {
response.countries = []; response.countries = [];
for (const [name, value] of Object.entries<any>( for (const [name, value] of Object.entries<any>(
holdings?.countries ?? {} holdings?.countries ?? {}
)) { )) {
let countryCode: string; let countryCode: string;
for (const [key, country] of Object.entries<any>( for (const [code, country] of Object.entries(countries)) {
TrackinsightDataEnhancerService.countries
)) {
if ( if (
country.name === name || country.name === name ||
country.name === country.name ===
TrackinsightDataEnhancerService.countriesMapping[name] TrackinsightDataEnhancerService.countriesMapping[name]
) { ) {
countryCode = key; countryCode = code;
break; break;
} }
} }

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

@ -266,7 +266,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
switch (quoteType?.toLowerCase()) { switch (quoteType?.toLowerCase()) {
case 'cryptocurrency': case 'cryptocurrency':
assetClass = AssetClass.CASH; assetClass = AssetClass.LIQUIDITY;
assetSubClass = AssetSubClass.CRYPTOCURRENCY; assetSubClass = AssetSubClass.CRYPTOCURRENCY;
break; break;
case 'equity': case 'equity':

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

Loading…
Cancel
Save