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. 78
      apps/api/src/app/account/account.service.ts
  12. 47
      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. 56
      apps/api/src/app/order/order.controller.ts
  28. 149
      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. 114
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts
  39. 99
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts
  40. 114
      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. 100
      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. 114
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  48. 119
      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. 211
      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. 1472
      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. 43
      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. 55
      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/),
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
- Optimized the calculation of the portfolio summary
### Fixed
- Fixed the activities import (query parameter handling)
## 2.60.0 - 2024-03-02
### Added

3
Dockerfile

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

44
README.md

@ -7,14 +7,12 @@
**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) |
[**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: 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)
New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
</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.
@ -87,23 +85,23 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables
| Name | Default Value | Description |
| ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` |   | The _CoinGecko_ Pro API |
| `DATABASE_URL` | | 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 |
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `0` | The database index of _Redis_ |
| `REDIS_HOST` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_PORT` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds |
| Name | Type | Default Value | Description |
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | string | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | string (`optional`) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API |
| `DATABASE_URL` | string | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | string (`optional`) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | string | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | number (`optional`) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | string | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | string | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | string | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | number (`optional`) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | string | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | string | | The password of _Redis_ |
| `REDIS_PORT` | number | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | number (`optional`) | `2000` | The timeout of network requests to data providers in milliseconds |
### 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)
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
@ -154,7 +152,7 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt
- [Node.js](https://nodejs.org/en/download) (version 18+)
- [Yarn](https://yarnpkg.com/en/docs/install)
- 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
@ -206,7 +204,7 @@ Set the header for each request as follows:
"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>`.

1
apps/api/jest.config.ts

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

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

@ -83,7 +83,7 @@ export class AccessController {
}
try {
return await this.accessService.createAccess({
return this.accessService.createAccess({
alias: data.alias || undefined,
GranteeUser: 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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions';
@ -5,6 +6,8 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Body,
Post,
Delete,
HttpException,
Inject,
@ -17,14 +20,44 @@ import { AccountBalance } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountBalanceService } from './account-balance.service';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Controller('account-balance')
export class AccountBalanceController {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
@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)
@Delete(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -32,10 +65,11 @@ export class AccountBalanceController {
@Param('id') id: string
): Promise<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(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -43,7 +77,8 @@ export class AccountBalanceController {
}
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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -10,6 +11,6 @@ import { AccountBalanceService } from './account-balance.service';
controllers: [AccountBalanceController],
exports: [AccountBalanceService],
imports: [ExchangeRateDataModule, PrismaModule],
providers: [AccountBalanceService]
providers: [AccountBalanceService, AccountService]
})
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 { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { resetHours } from '@ghostfolio/common/helper';
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AccountBalance, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Injectable()
export class AccountBalanceService {
public constructor(
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService
) {}
@ -25,20 +32,63 @@ export class AccountBalanceService {
});
}
public async createAccountBalance(
data: Prisma.AccountBalanceCreateInput
): Promise<AccountBalance> {
return this.prismaService.accountBalance.create({
data
public async createOrUpdateAccountBalance({
accountId,
balance,
date,
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(
where: Prisma.AccountBalanceWhereUniqueInput
): Promise<AccountBalance> {
return this.prismaService.accountBalance.delete({
const accountBalance = await this.prismaService.accountBalance.delete({
where
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: <string>where.userId
})
);
return accountBalance;
}
@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;
}

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

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

47
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 { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
import {
getAssetProfileIdentifier,
resetHours
} from '@ghostfolio/common/helper';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
AdminData,
AdminMarketData,
@ -94,7 +93,8 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...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,
opts: {
...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,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol })
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
}
});
}
@ -325,34 +327,6 @@ export class AdminController {
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<EnhancedSymbolProfile> {
if (dataSource === 'MANUAL') {
await this.adminService.patchAssetProfileData({
dataSource,
symbol,
tags: {
set: []
}
});
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,
@ -364,7 +338,6 @@ export class AdminController {
}
});
}
}
@HasPermission(permissions.accessAdminControl)
@Put('settings/:key')
@ -373,6 +346,6 @@ export class AdminController {
@Param('key') key: string,
@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 {
AssetClass,
AssetSubClass,
Prisma,
Property,
@ -75,7 +76,7 @@ export class AdminService {
);
}
return await this.symbolProfileService.add(
return this.symbolProfileService.add(
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
);
} catch (error) {
@ -216,6 +217,7 @@ export class AdminService {
countries: true,
currency: true,
dataSource: true,
id: true,
name: true,
Order: {
orderBy: [{ date: 'asc' }],
@ -240,6 +242,7 @@ export class AdminService {
countries,
currency,
dataSource,
id,
name,
Order,
sectors,
@ -263,6 +266,7 @@ export class AdminService {
currency,
countriesCount,
dataSource,
id,
name,
symbol,
marketDataItemCount,
@ -337,7 +341,8 @@ export class AdminService {
scraperConfiguration,
sectors,
symbol,
symbolMapping
symbolMapping,
url
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
if (dataSource === 'MANUAL') {
await this.symbolProfileService.updateSymbolProfile({
@ -453,9 +458,10 @@ export class AdminService {
dataSource,
marketDataItemCount,
symbol,
assetClass: 'CASH',
assetClass: AssetClass.LIQUIDITY,
countriesCount: 0,
currency: symbol.replace(DEFAULT_CURRENCY, ''),
id: undefined,
name: symbol,
sectorsCount: 0,
tags: []
@ -500,13 +506,14 @@ export class AdminService {
},
createdAt: true,
id: true,
role: true,
Subscription: true
},
take: 30
});
return usersWithAnalytics.map(
({ _count, Analytics, createdAt, id, Subscription }) => {
({ _count, Analytics, createdAt, id, role, Subscription }) => {
const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics
@ -526,6 +533,7 @@ export class AdminService {
createdAt,
engagement,
id,
role,
subscription,
accountCount: _count.Account || 0,
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> {
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({
limit = 1000,
status = QUEUE_JOB_STATUS_LIST
@ -54,6 +58,7 @@ export class QueueService {
finishedOn: job.finishedOn,
id: job.id,
name: job.name,
opts: job.opts,
stacktrace: job.stacktrace,
state: await job.getState(),
timestamp: job.timestamp

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

@ -5,7 +5,8 @@ import {
IsISO4217CurrencyCode,
IsObject,
IsOptional,
IsString
IsString,
IsUrl
} from 'class-validator';
export class UpdateAssetProfileDto {
@ -50,4 +51,11 @@ export class UpdateAssetProfileDto {
symbolMapping?: {
[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 { CronService } from '@ghostfolio/api/services/cron.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -14,6 +15,7 @@ import {
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static';
import { StatusCodes } from 'http-status-codes';
@ -44,6 +46,7 @@ import { TagModule } from './tag/tag.module';
import { UserModule } from './user/user.module';
@Module({
controllers: [AppController],
imports: [
AdminModule,
AccessModule,
@ -64,6 +67,8 @@ import { UserModule } from './user/user.module';
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
EventEmitterModule.forRoot(),
EventsModule,
ExchangeRateModule,
ExchangeRateDataModule,
ExportModule,
@ -109,7 +114,6 @@ import { UserModule } from './user/user.module';
TwitterBotModule,
UserModule
],
controllers: [AppController],
providers: [CronService]
})
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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
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 * as countriesAndTimezones from 'countries-and-timezones';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable()
@ -29,6 +31,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
if (user) {
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 =
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
@ -45,10 +54,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
return user;
} 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() {
return this.configurationService.get('WEB_AUTH_RP_ID');
return new URL(this.configurationService.get('ROOT_URL')).hostname;
}
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 { 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 { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import type {
@ -8,7 +9,7 @@ import type {
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@ -19,6 +20,7 @@ import {
Inject,
Param,
Post,
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
@ -106,13 +108,18 @@ export class BenchmarkController {
public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string,
@Param('symbol') symbol: string
@Param('symbol') symbol: string,
@Query('range') dateRange: DateRange = 'max'
): Promise<BenchmarkMarketDataDetails> {
const startDate = new Date(startDateString);
const { endDate, startDate } = getInterval(
dateRange,
new Date(startDateString)
);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
return this.benchmarkService.getMarketDataBySymbol({
dataSource,
endDate,
startDate,
symbol,
userCurrency

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

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

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

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

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

@ -43,8 +43,10 @@ export class ImportController {
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async import(
@Body() importData: ImportDataDto,
@Query('dryRun') isDryRun = false
@Query('dryRun') isDryRunParam = 'false'
): Promise<ImportResponse> {
const isDryRun = isDryRunParam === 'true';
if (
!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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 {
DATE_FORMAT,
getAssetProfileIdentifier,
@ -27,7 +31,7 @@ import {
import { Injectable } from '@nestjs/common';
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 { uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
@ -112,12 +116,13 @@ export class ImportService {
accountId: Account?.id,
accountUserId: undefined,
comment: undefined,
currency: undefined,
createdAt: undefined,
fee: 0,
feeInBaseCurrency: 0,
id: assetProfile.id,
isDraft: false,
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
SymbolProfile: assetProfile,
symbolProfileId: assetProfile.id,
type: 'DIVIDEND',
unitPrice: marketPrice,
@ -261,6 +266,7 @@ export class ImportService {
{
accountId,
comment,
currency,
date,
error,
fee,
@ -285,7 +291,6 @@ export class ImportService {
assetSubClass,
countries,
createdAt,
currency,
dataSource,
figi,
figiComposite,
@ -342,6 +347,7 @@ export class ImportService {
if (isDryRun) {
order = {
comment,
currency,
date,
fee,
quantity,
@ -357,7 +363,6 @@ export class ImportService {
assetSubClass,
countries,
createdAt,
currency,
dataSource,
figi,
figiComposite,
@ -371,6 +376,7 @@ export class ImportService {
symbolMapping,
updatedAt,
url,
currency: assetProfile.currency,
comment: assetProfile.comment
},
Account: validatedAccount,
@ -394,9 +400,9 @@ export class ImportService {
SymbolProfile: {
connectOrCreate: {
create: {
currency,
dataSource,
symbol
symbol,
currency: assetProfile.currency
},
where: {
dataSource_symbol: {
@ -410,6 +416,11 @@ export class ImportService {
User: { connect: { id: 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();
@ -420,14 +431,14 @@ export class ImportService {
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee,
currency,
assetProfile.currency,
userCurrency
),
// @ts-ignore
SymbolProfile: assetProfile,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
currency,
assetProfile.currency,
userCurrency
)
});
@ -446,15 +457,16 @@ export class ImportService {
});
});
this.dataGatheringService.gatherSymbols(
uniqueActivities.map(({ date, SymbolProfile }) => {
this.dataGatheringService.gatherSymbols({
dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => {
return {
date,
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
})
);
}),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
}
return activities;
@ -521,22 +533,14 @@ export class ImportService {
currency,
dataSource,
symbol,
assetClass: null,
assetSubClass: null,
comment: null,
countries: null,
activitiesCount: undefined,
assetClass: undefined,
assetSubClass: undefined,
countries: undefined,
createdAt: undefined,
figi: null,
figiComposite: null,
figiShareClass: null,
id: undefined,
isin: null,
name: null,
scraperConfiguration: null,
sectors: null,
symbolMapping: null,
updatedAt: undefined,
url: null
sectors: undefined,
updatedAt: undefined
}
};
}
@ -605,7 +609,12 @@ export class ImportService {
)?.[symbol]
};
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') {
if (
type === 'BUY' ||
type === 'DIVIDEND' ||
type === 'SELL' ||
type === 'STAKE'
) {
if (!assetProfile?.name) {
throw new Error(
`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()
currency: string;
@IsISO4217CurrencyCode()
@IsOptional()
customCurrency?: string;
@IsOptional()
@IsEnum(DataSource, { each: true })
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 {
activities: Activity[];
count: number;
}
export interface Activity extends OrderWithAccount {
export interface Activity extends Order {
Account?: AccountWithPlatform;
error?: ActivityError;
feeInBaseCurrency: number;
SymbolProfile?: EnhancedSymbolProfile;
tags?: Tag[];
updateAccountBalance?: boolean;
value: number;
valueInBaseCurrency: number;

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

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

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

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

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

@ -41,6 +41,10 @@ export class UpdateOrderDto {
@IsISO4217CurrencyCode()
currency: string;
@IsISO4217CurrencyCode()
@IsOptional()
customCurrency?: string;
@IsString()
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 {
@IsString()
name: string;
@IsString()
@IsUrl({
protocols: ['https'],
require_protocol: true
})
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 {
@IsString()
@ -7,6 +7,9 @@ export class UpdatePlatformDto {
@IsString()
name: string;
@IsString()
@IsUrl({
protocols: ['https'],
require_protocol: true
})
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 }
]);
});
});
});

114
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 { 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 { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
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', () => {
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(
@ -29,51 +54,69 @@ describe('PortfolioCalculator', () => {
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', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: [
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',
date: '2021-11-22',
dataSource: 'YAHOO',
fee: new Big(1.55),
name: 'Bâloise Holding AG',
quantity: new Big(2),
symbol: 'BALN.SW',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: new Big(142.9)
unitPrice: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
date: '2021-11-30',
dataSource: 'YAHOO',
fee: new Big(1.65),
name: 'Bâloise Holding AG',
quantity: new Big(2),
symbol: 'BALN.SW',
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: new Big(136.6)
unitPrice: 136.6
}
]
});
portfolioCalculator.computeTransactionPoints();
];
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 chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-22')
});
const currentPositions = await portfolioCalculator.getCurrentPositions(
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-22')
);
@ -86,8 +129,8 @@ describe('PortfolioCalculator', () => {
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big('0'),
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'),
@ -107,6 +150,8 @@ describe('PortfolioCalculator', () => {
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'),
@ -127,14 +172,19 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'),
symbol: 'BALN.SW',
tags: [],
timeWeightedInvestment: new Big('285.8'),
tags: undefined,
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'),
totalInvestmentWithCurrencyEffect: new Big('0')
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([

99
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 { 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 { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
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', () => {
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(
@ -29,40 +54,54 @@ describe('PortfolioCalculator', () => {
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with BALN.SW buy', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: [
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
date: '2021-11-30',
dataSource: 'YAHOO',
fee: new Big(1.55),
name: 'Bâloise Holding AG',
quantity: new Big(2),
symbol: 'BALN.SW',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: new Big(136.6)
unitPrice: 136.6
}
]
});
portfolioCalculator.computeTransactionPoints();
];
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 chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-30')
});
const currentPositions = await portfolioCalculator.getCurrentPositions(
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-30')
);
@ -75,8 +114,8 @@ describe('PortfolioCalculator', () => {
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big('297.8'),
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('297.8'),
errors: [],
grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'),
@ -96,6 +135,8 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('136.6'),
currency: 'CHF',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('1.55'),
firstBuyDate: '2021-11-30',
grossPerformance: new Big('24.6'),
@ -116,13 +157,19 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 148.9,
quantity: new Big('2'),
symbol: 'BALN.SW',
tags: [],
timeWeightedInvestment: 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'),
totalInvestmentWithCurrencyEffect: new Big('273.2')
totalInvestmentWithCurrencyEffect: new Big('273.2'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([

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

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')
});
});
});
});

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

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

119
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 { 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 { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
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', () => {
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(
@ -29,51 +54,69 @@ describe('PortfolioCalculator', () => {
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: [
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2022-03-07'),
fee: 0,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
date: '2022-03-07',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Novartis AG',
quantity: new Big(2),
symbol: 'NOVN.SW',
symbol: 'NOVN.SW'
},
type: 'BUY',
unitPrice: new Big(75.8)
unitPrice: 75.8
},
{
...activityDummyData,
date: new Date('2022-04-08'),
fee: 0,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
date: '2022-04-08',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Novartis AG',
quantity: new Big(2),
symbol: 'NOVN.SW',
symbol: 'NOVN.SW'
},
type: 'SELL',
unitPrice: new Big(85.73)
unitPrice: 85.73
}
]
});
];
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2022-03-07')
});
const currentPositions = await portfolioCalculator.getCurrentPositions(
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-03-07')
);
@ -93,7 +136,8 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
timeWeightedPerformance: 0,
netWorth: 151.6,
totalAccountBalance: 0,
totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6,
value: 151.6,
@ -107,15 +151,16 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 13.100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744,
netPerformanceWithCurrencyEffect: 19.86,
timeWeightedPerformance: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
expect(currentPositions).toEqual({
currentValue: new Big('0'),
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
@ -135,6 +180,8 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'),
@ -155,13 +202,19 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 87.8,
quantity: new Big('0'),
symbol: 'NOVN.SW',
tags: [],
timeWeightedInvestment: 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'),
totalInvestmentWithCurrencyEffect: new Big('0')
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
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) {
switch (symbol) {
case '55196015-1365-4560-aa60-8751ae6d18f8':
if (isSameDay(parseDate('2022-01-31'), date)) {
return { marketPrice: 3000 };
}
return { marketPrice: 0 };
case 'BALN.SW':
if (isSameDay(parseDate('2021-11-12'), date)) {
return { marketPrice: 146 };
@ -43,6 +50,17 @@ function mockGetValue(symbol: string, date: Date) {
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':
if (isSameDay(parseDate('2022-04-11'), date)) {
return { marketPrice: 87.8 };

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

@ -37,7 +37,7 @@ export class CurrentRateService {
}: GetValuesParams): Promise<GetValuesObject> {
const dataProviderInfos: DataProviderInfo[] = [];
const includeToday =
const includesToday =
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
(!dateQuery.in || this.containsToday(dateQuery.in));
@ -46,7 +46,7 @@ export class CurrentRateService {
const quoteErrors: ResponseError['errors'] = [];
const today = resetHours(new Date());
if (includeToday) {
if (includesToday) {
promises.push(
this.dataProviderService
.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';
export interface PortfolioOrderItem extends PortfolioOrder {
feeInBaseCurrency?: Big;
feeInBaseCurrencyWithCurrencyEffect?: Big;
itemType?: '' | 'start' | 'end';
itemType?: 'end' | 'start';
unitPriceInBaseCurrency?: 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 Big from 'big.js';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
export interface PortfolioOrder {
currency: string;
export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> {
date: string;
dataSource: DataSource;
fee: Big;
name: string;
quantity: Big;
symbol: string;
tags?: Tag[];
type: TypeOfOrder;
SymbolProfile: Pick<
Activity['SymbolProfile'],
'currency' | 'dataSource' | 'name' | 'symbol'
>;
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 {
DataProviderInfo,
EnhancedSymbolProfile,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Account, Tag } from '@prisma/client';
@ -13,6 +13,8 @@ export interface PortfolioPositionDetail {
dataProviderInfo: DataProviderInfo;
dividendInBaseCurrency: number;
stakeRewards: number;
dividendYieldPercent: number;
dividendYieldPercentWithCurrencyEffect: number;
feeInBaseCurrency: number;
firstBuyDate: string;
grossPerformance: number;
@ -28,16 +30,10 @@ export interface PortfolioPositionDetail {
netPerformancePercent: number;
netPerformancePercentWithCurrencyEffect: number;
netPerformanceWithCurrencyEffect: number;
orders: OrderWithAccount[];
orders: Activity[];
quantity: number;
SymbolProfile: EnhancedSymbolProfile;
tags: Tag[];
transactionCount: 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 Big from 'big.js';
import { Big } from 'big.js';
export interface TransactionPointSymbol {
averagePrice: Big;
currency: string;
dataSource: DataSource;
dividend: Big;
fee: Big;
firstBuyDate: string;
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';
export interface TransactionPoint {
date: string;
fees: Big;
interest: Big;
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

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

@ -1,17 +1,20 @@
import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import {
hasNotDefinedValuesInObject,
nullifyValuesInObject
} 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 { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import {
DEFAULT_CURRENCY,
HEADER_KEY_IMPERSONATION
@ -19,6 +22,7 @@ import {
import {
PortfolioDetails,
PortfolioDividends,
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioPerformanceResponse,
PortfolioPublicDetails,
@ -44,11 +48,11 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
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 { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioService } from './portfolio.service';
@Controller('portfolio')
@ -58,6 +62,8 @@ export class PortfolioController {
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
@ -73,8 +79,11 @@ export class PortfolioController {
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string,
@Query('isAllocation') isAllocation: boolean = false
@Query('isAllocation') isAllocation: boolean = false,
@Query('withMarkets') withMarketsParam = 'false'
): Promise<PortfolioDetails & { hasError: boolean }> {
const withMarkets = withMarketsParam === 'true';
let hasDetails = true;
let hasError = false;
const hasReadRestrictedAccessPermission =
@ -93,21 +102,14 @@ export class PortfolioController {
filterByTags
});
const {
accounts,
filteredValueInBaseCurrency,
filteredValueInPercentage,
hasErrors,
holdings,
platforms,
summary,
totalValueInBaseCurrency
} = await this.portfolioService.getDetails({
const { accounts, hasErrors, holdings, platforms, summary } =
await this.portfolioService.getDetails({
dateRange,
filters,
impersonationId,
withMarkets,
userId: this.request.user.id,
isAllocation
withSummary: true
});
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
@ -120,40 +122,27 @@ export class PortfolioController {
hasReadRestrictedAccessPermission ||
this.userService.isRestrictedView(this.request.user)
) {
let investmentTuple: [number, number] = [0, 0];
for (let holding of Object.entries(holdings)) {
var portfolioPosition = holding[1];
investmentTuple[0] += portfolioPosition.investment;
investmentTuple[1] += this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency,
this.request.user.Settings.settings.baseCurrency
);
}
const totalInvestment = investmentTuple[0];
const totalValue = investmentTuple[1];
const totalInvestment = Object.values(holdings)
.map(({ investment }) => {
return investment;
})
.reduce((a, b) => a + b, 0);
if (hasDetails === false) {
portfolioSummary = nullifyValuesInObject(summary, [
'cash',
'committedFunds',
'currentGrossPerformance',
'currentNetPerformance',
'currentValue',
'dividend',
'emergencyFund',
'excludedAccountsAndActivities',
'fees',
'items',
'liabilities',
'netWorth',
'totalBuy',
'totalSell'
]);
}
const totalValue = Object.values(holdings)
.filter(({ assetClass, assetSubClass }) => {
return (
assetClass !== AssetClass.LIQUIDITY &&
assetSubClass !== AssetSubClass.CASH
);
})
.map(({ valueInBaseCurrency }) => {
return valueInBaseCurrency;
})
.reduce((a, b) => {
return a + b;
}, 0);
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
for (const [, portfolioPosition] of Object.entries(holdings)) {
portfolioPosition.investment =
portfolioPosition.investment / totalInvestment;
portfolioPosition.valueInPercentage =
@ -195,23 +184,25 @@ export class PortfolioController {
portfolioSummary = nullifyValuesInObject(summary, [
'cash',
'committedFunds',
'currentGrossPerformance',
'currentGrossPerformanceWithCurrencyEffect',
'currentNetPerformance',
'currentNetPerformanceWithCurrencyEffect',
'currentValue',
'dividend',
'currentNetWorth',
'currentValueInBaseCurrency',
'dividendInBaseCurrency',
'emergencyFund',
'excludedAccountsAndActivities',
'fees',
'filteredValueInBaseCurrency',
'fireWealth',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'interest',
'items',
'liabilities',
'netWorth',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'totalBuy',
'totalInvestment',
'totalSell'
'totalSell',
'totalValueInBaseCurrency'
]);
}
@ -219,11 +210,11 @@ export class PortfolioController {
holdings[symbol] = {
...portfolioPosition,
assetClass:
hasDetails || portfolioPosition.assetClass === 'CASH'
hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetClass
: undefined,
assetSubClass:
hasDetails || portfolioPosition.assetSubClass === 'CASH'
hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetSubClass
: undefined,
countries: hasDetails ? portfolioPosition.countries : [],
@ -238,12 +229,9 @@ export class PortfolioController {
return {
accounts,
filteredValueInBaseCurrency,
filteredValueInPercentage,
hasError,
holdings,
platforms,
totalValueInBaseCurrency,
summary: portfolioSummary
};
}
@ -270,11 +258,24 @@ export class PortfolioController {
filterByTags
});
let dividends = await this.portfolioService.getDividends({
dateRange,
const impersonationUserId =
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,
groupBy,
impersonationId
startDate,
userCurrency,
userId: impersonationUserId || this.request.user.id,
types: ['DIVIDEND']
});
let dividends = await this.portfolioService.getDividends({
activities,
groupBy
});
if (
@ -304,6 +305,37 @@ export class PortfolioController {
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')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getInvestments(
@ -403,7 +435,6 @@ export class PortfolioController {
filters,
impersonationId,
withExcludedAccounts,
withItems,
userId: this.request.user.id,
calculateTimeWeightedPerformance
});
@ -417,6 +448,7 @@ export class PortfolioController {
({
date,
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
netWorth,
totalInvestment,
value
@ -424,6 +456,7 @@ export class PortfolioController {
return {
date,
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
netWorthInPercentage:
performanceInformation.performance.currentNetWorth === 0
? 0
@ -437,10 +470,14 @@ export class PortfolioController {
.div(performanceInformation.performance.totalInvestment)
.toNumber(),
valueInPercentage:
performanceInformation.performance.currentValue === 0
performanceInformation.performance.currentValueInBaseCurrency ===
0
? 0
: new Big(value)
.div(performanceInformation.performance.currentValue)
.div(
performanceInformation.performance
.currentValueInBaseCurrency
)
.toNumber()
};
}
@ -449,12 +486,12 @@ export class PortfolioController {
performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance,
[
'currentGrossPerformance',
'currentGrossPerformanceWithCurrencyEffect',
'currentNetPerformance',
'currentNetPerformanceWithCurrencyEffect',
'currentNetWorth',
'currentValue',
'currentValueInBaseCurrency',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'totalInvestment'
]
);
@ -471,39 +508,13 @@ export class PortfolioController {
);
performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance,
['currentNetPerformance', 'currentNetPerformancePercent']
['netPerformance']
);
}
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')
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublic(
@ -527,10 +538,10 @@ export class PortfolioController {
}
const { holdings } = await this.portfolioService.getDetails({
dateRange: 'max',
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
impersonationId: access.userId,
userId: user.id
userId: user.id,
withMarkets: true
});
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 { AccountService } from '@ghostfolio/api/app/account/account.service';
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 { ApiModule } from '@ghostfolio/api/services/api/api.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 { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
import { CurrentRateService } from './current-rate.service';
import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service';
@ -34,6 +36,7 @@ import { RulesService } from './rules.service';
MarketDataModule,
OrderModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule,
UserModule
],
@ -41,6 +44,7 @@ import { RulesService } from './rules.service';
AccountBalanceService,
AccountService,
CurrentRateService,
PortfolioCalculatorFactory,
PortfolioService,
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';
import { PortfolioCalculator } from './portfolio-calculator';
describe('PortfolioService', () => {
let portfolioService: PortfolioService;
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
beforeAll(async () => {
portfolioService = new PortfolioService(
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
@ -21,28 +22,21 @@ describe('PortfolioCalculator', () => {
});
describe('annualized performance percentage', () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'USD',
orders: []
});
it('Get annualized performance', async () => {
expect(
portfolioCalculator
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
netPerformancePercent: new Big(0)
netPerformancePercentage: new Big(0)
})
.toNumber()
).toEqual(0);
expect(
portfolioCalculator
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 0,
netPerformancePercent: new Big(0)
netPerformancePercentage: new Big(0)
})
.toNumber()
).toEqual(0);
@ -51,19 +45,19 @@ describe('PortfolioCalculator', () => {
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
*/
expect(
portfolioCalculator
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 65, // < 1 year
netPerformancePercent: new Big(0.1025)
netPerformancePercentage: new Big(0.1025)
})
.toNumber()
).toBeCloseTo(0.729705);
expect(
portfolioCalculator
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 365, // 1 year
netPerformancePercent: new Big(0.05)
netPerformancePercentage: new Big(0.05)
})
.toNumber()
).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
*/
expect(
portfolioCalculator
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 575, // > 1 year
netPerformancePercent: new Big(0.2374)
netPerformancePercentage: new Big(0.2374)
})
.toNumber()
).toBeCloseTo(0.145);

1472
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;
})
.map((rule) => {
const evaluationResult = rule.evaluate(rule.getSettings(aUserSettings));
return { ...evaluationResult, name: rule.getName() };
const { evaluation, value } = rule.evaluate(
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> {
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) {
@ -29,15 +33,15 @@ export class RedisCacheService {
}
public async remove(key: string) {
await this.cache.del(key);
return this.cache.del(key);
}
public async reset() {
await this.cache.reset();
return this.cache.reset();
}
public async set(key: string, value: string, ttlInSeconds?: number) {
await this.cache.set(
return this.cache.set(
key,
value,
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 }
) {
try {
return await this.subscriptionService.createCheckoutSession({
return this.subscriptionService.createCheckoutSession({
couponId,
priceId,
user: this.request.user

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

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

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

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

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

@ -14,6 +14,7 @@ import {
IsOptional,
IsString
} from 'class-validator';
import { eachYearOfInterval, format } from 'date-fns';
export class UpdateUserSettingDto {
@IsNumber()
@ -42,7 +43,12 @@ export class UpdateUserSettingDto {
'max',
'mtd',
'wtd',
'ytd'
'ytd',
...eachYearOfInterval({ end: new Date(), start: new Date(0) }).map(
(date) => {
return format(date, 'yyyy');
}
)
])
@IsOptional()
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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { User, UserSettings } from '@ghostfolio/common/interfaces';
import {
hasPermission,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -63,13 +59,6 @@ export class UserController {
public async getUser(
@Headers('accept-language') acceptLanguage: string
): Promise<User> {
if (hasRole(this.request.user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
return this.userService.getUser(
this.request.user,
acceptLanguage?.split(',')?.[0]
@ -135,7 +124,7 @@ export class UserController {
}
}
return await this.userService.updateUserSetting({
return this.userService.updateUserSetting({
userSettings,
userId: this.request.user.id
});

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

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

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

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

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

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

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

@ -1,4 +1,4 @@
export const environment = {
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';
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',
'fee',
'feeInBaseCurrency',
'filteredValueInBaseCurrency',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'investment',

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

@ -1,6 +1,6 @@
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';
export class Order {
@ -14,7 +14,7 @@ export class Order {
private symbol: string;
private symbolProfile: SymbolProfile;
private total: number;
private type: TypeOfOrder;
private type: ActivityType;
private unitPrice: number;
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
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 { RuleInterface } from './interfaces/rule.interface';
export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
private key: string;
private name: string;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
{
key,
name
}: {
key: string;
name: string;
}
) {
this.key = key;
this.name = name;
}
public getKey() {
return this.key;
}
public getName() {
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']
) {
super(exchangeRateDataService, {
key: AccountClusterRiskCurrentInvestment.name,
name: 'Investment'
});
@ -35,7 +36,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
};
}
let maxItem;
let maxItem: (typeof accounts)[0];
let totalInvestment = 0;
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) {
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']
) {
super(exchangeRateDataService, {
key: AccountClusterRiskSingleAccount.name,
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 { Rule } from '@ghostfolio/api/models/rule';
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> {
private positions: TimelinePosition[];
@ -11,6 +12,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
positions: TimelinePosition[]
) {
super(exchangeRateDataService, {
key: CurrencyClusterRiskBaseCurrencyCurrentInvestment.name,
name: 'Investment: Base Currency'
});
@ -43,7 +45,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
const baseCurrencyValueRatio = baseCurrencyItem?.value / totalValue || 0;
if (maxItem.groupKey !== ruleSettings.baseCurrency) {
if (maxItem?.groupKey !== ruleSettings.baseCurrency) {
return {
evaluation: `The major part of your current investment is not in your base currency (${(
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 { Rule } from '@ghostfolio/api/models/rule';
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> {
private positions: TimelinePosition[];
@ -11,6 +12,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
positions: TimelinePosition[]
) {
super(exchangeRateDataService, {
key: CurrencyClusterRiskCurrentInvestment.name,
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) {
return {
@ -52,7 +54,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
return {
evaluation: `The major part of your current investment is in ${
maxItem.groupKey
maxItem?.groupKey ?? ruleSettings.baseCurrency
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${
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
) {
super(exchangeRateDataService, {
key: EmergencyFundSetup.name,
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
) {
super(exchangeRateDataService, {
key: FeeRatioInitialInvestment.name,
name: 'Fee Ratio'
});

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

@ -10,18 +10,21 @@ export class ApiService {
filterByAccounts,
filterByAssetClasses,
filterByAssetSubClasses,
filterByHoldingType,
filterBySearchQuery,
filterByTags
}: {
filterByAccounts?: string;
filterByAssetClasses?: string;
filterByAssetSubClasses?: string;
filterByHoldingType?: string;
filterBySearchQuery?: string;
filterByTags?: string;
}): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const holdingType = filterByHoldingType;
const searchQuery = filterBySearchQuery?.toLowerCase();
const tagIds = filterByTags?.split(',') ?? [];
@ -52,6 +55,13 @@ export class ApiService {
})
];
if (holdingType) {
filters.push({
id: holdingType,
type: 'HOLDING_TYPE'
});
}
if (searchQuery) {
filters.push({
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 { 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()
export class ConfigurationService {
@ -49,14 +49,13 @@ export class ConfigurationService {
REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }),
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_SECRET_KEY: str({ default: '' }),
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),
TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }),
TWITTER_API_KEY: str({ default: 'dummyApiKey' }),
TWITTER_API_SECRET: str({ default: 'dummyApiSecret' }),
WEB_AUTH_RP_ID: host({ default: 'localhost' })
TWITTER_API_SECRET: str({ default: 'dummyApiSecret' })
});
}

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

@ -1,4 +1,5 @@
import {
DATA_GATHERING_QUEUE_PRIORITY_LOW,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
PROPERTY_IS_DATA_GATHERING_ENABLED
@ -56,7 +57,8 @@ export class CronService {
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...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 })
public async gatherAssetProfile(job: Job<UniqueAsset>) {
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]);
Logger.log(
`Asset profile data gathering has been completed for ${job.data.symbol} (${job.data.dataSource})`,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS})`
);
} catch (error) {
Logger.error(
error,
@ -62,11 +72,11 @@ export class DataGatheringProcessor {
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
);
const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }],
currentDate,
new Date()
);
const historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [{ dataSource, symbol }],
from: currentDate,
to: new Date()
});
const data: Prisma.MarketDataUpdateInput[] = [];
let lastMarketPrice: number;

55
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 {
DATA_GATHERING_QUEUE,
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW,
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
PROPERTY_BENCHMARKS
@ -64,24 +66,35 @@ export class DataGatheringService {
public async gather7Days() {
const dataGatheringItems = await this.getSymbols7D();
await this.gatherSymbols(dataGatheringItems);
await this.gatherSymbols({
dataGatheringItems,
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
});
}
public async gatherMax() {
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) {
await this.marketDataService.deleteMany({ dataSource, symbol });
const symbols = (await this.getSymbolsMax()).filter((dataGatheringItem) => {
const dataGatheringItems = (await this.getSymbolsMax()).filter(
(dataGatheringItem) => {
return (
dataGatheringItem.dataSource === dataSource &&
dataGatheringItem.symbol === symbol
);
}
);
await this.gatherSymbols({
dataGatheringItems,
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
await this.gatherSymbols(symbols);
}
public async gatherSymbolForDate({
@ -94,11 +107,11 @@ export class DataGatheringService {
symbol: string;
}) {
try {
const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }],
date,
date
);
const historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [{ dataSource, symbol }],
from: date,
to: date
});
const marketPrice =
historicalData[symbol][format(date, DATE_FORMAT)].marketPrice;
@ -225,22 +238,23 @@ export class DataGatheringService {
error,
'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(
aSymbolsWithStartDate.map(({ dataSource, date, symbol }) => {
dataGatheringItems.map(({ dataSource, date, symbol }) => {
return {
data: {
dataSource,
@ -250,6 +264,7 @@ export class DataGatheringService {
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS,
opts: {
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
priority,
jobId: `${getAssetProfileIdentifier({
dataSource,
symbol

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

@ -59,7 +59,7 @@ export class CoinGeckoService implements DataProviderInterface {
}): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = {
symbol,
assetClass: AssetClass.CASH,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: DEFAULT_CURRENCY,
dataSource: this.getName()
@ -243,7 +243,7 @@ export class CoinGeckoService implements DataProviderInterface {
return {
name,
symbol,
assetClass: AssetClass.CASH,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: DEFAULT_CURRENCY,
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 { SymbolProfile } from '@prisma/client';
import { countries } from 'countries-list';
import got from 'got';
@Injectable()
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://www.trackinsight.com/data-api';
private static countries = require('countries-list/dist/countries.json');
private static countriesMapping = {
'Russian Federation': 'Russia'
};
@ -127,20 +127,19 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
(response.countries as unknown as Country[]).length === 0
) {
response.countries = [];
for (const [name, value] of Object.entries<any>(
holdings?.countries ?? {}
)) {
let countryCode: string;
for (const [key, country] of Object.entries<any>(
TrackinsightDataEnhancerService.countries
)) {
for (const [code, country] of Object.entries(countries)) {
if (
country.name === name ||
country.name ===
TrackinsightDataEnhancerService.countriesMapping[name]
) {
countryCode = key;
countryCode = code;
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()) {
case 'cryptocurrency':
assetClass = AssetClass.CASH;
assetClass = AssetClass.LIQUIDITY;
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
break;
case 'equity':

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

Loading…
Cancel
Save