Browse Source

Merge pull request #100 from dandevaud/mr/upstream-24-08

Mr/upstream 24 08
pull/5027/head
dandevaud 10 months ago
committed by GitHub
parent
commit
d192faa54c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 10
      .eslintrc.json
  2. 12
      .github/workflows/build-code.yml
  3. 5
      .gitignore
  4. 2
      .nvmrc
  5. 1
      .prettierignore
  6. 1
      .yarnrc
  7. 307
      CHANGELOG.md
  8. 63
      DEVELOPMENT.md
  9. 44
      Dockerfile
  10. 108
      README.md
  11. 4
      apps/api/src/app/account/account.service.ts
  12. 5
      apps/api/src/app/account/create-account.dto.ts
  13. 5
      apps/api/src/app/account/update-account.dto.ts
  14. 10
      apps/api/src/app/admin/admin.controller.ts
  15. 4
      apps/api/src/app/admin/admin.module.ts
  16. 220
      apps/api/src/app/admin/admin.service.ts
  17. 5
      apps/api/src/app/admin/update-asset-profile.dto.ts
  18. 2
      apps/api/src/app/app.module.ts
  19. 29
      apps/api/src/app/asset/asset.controller.ts
  20. 17
      apps/api/src/app/asset/asset.module.ts
  21. 16
      apps/api/src/app/benchmark/benchmark.controller.ts
  22. 2
      apps/api/src/app/benchmark/benchmark.module.ts
  23. 1
      apps/api/src/app/benchmark/benchmark.service.spec.ts
  24. 216
      apps/api/src/app/benchmark/benchmark.service.ts
  25. 6
      apps/api/src/app/benchmark/interfaces/benchmark-value.interface.ts
  26. 2
      apps/api/src/app/cache/cache.controller.ts
  27. 43
      apps/api/src/app/import/import.service.ts
  28. 1
      apps/api/src/app/info/info.module.ts
  29. 4
      apps/api/src/app/logo/logo.service.ts
  30. 12
      apps/api/src/app/order/create-order.dto.ts
  31. 39
      apps/api/src/app/order/order.controller.ts
  32. 169
      apps/api/src/app/order/order.service.ts
  33. 12
      apps/api/src/app/order/update-order.dto.ts
  34. 51
      apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts
  35. 9
      apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts
  36. 1
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  37. 26
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  38. 723
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  39. 55
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  40. 53
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts
  41. 59
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts
  42. 75
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  43. 37
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts
  44. 65
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts
  45. 37
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts
  46. 7
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts
  47. 18
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts
  48. 35
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts
  49. 53
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  50. 78
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts
  51. 1436
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  52. 17
      apps/api/src/app/portfolio/current-rate.service.mock.ts
  53. 16
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  54. 119
      apps/api/src/app/portfolio/current-rate.service.ts
  55. 4
      apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts
  56. 1
      apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts
  57. 68
      apps/api/src/app/portfolio/portfolio.controller.ts
  58. 78
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  59. 343
      apps/api/src/app/portfolio/portfolio.service.ts
  60. 15
      apps/api/src/app/portfolio/rules.service.ts
  61. 7
      apps/api/src/app/portfolio/update-holding-tags.dto.ts
  62. 49
      apps/api/src/app/redis-cache/redis-cache.service.ts
  63. 50
      apps/api/src/app/sitemap/sitemap.controller.ts
  64. 5
      apps/api/src/app/sitemap/sitemap.module.ts
  65. 2
      apps/api/src/app/subscription/subscription.service.ts
  66. 7
      apps/api/src/app/symbol/interfaces/symbol-item.interface.ts
  67. 6
      apps/api/src/app/symbol/symbol.service.ts
  68. 15
      apps/api/src/app/user/update-user-setting.dto.ts
  69. 14
      apps/api/src/app/user/user.controller.ts
  70. 2
      apps/api/src/app/user/user.module.ts
  71. 28
      apps/api/src/app/user/user.service.ts
  72. 1112
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  73. 995
      apps/api/src/assets/sitemap.xml
  74. 6
      apps/api/src/events/portfolio-changed.listener.ts
  75. 71
      apps/api/src/helper/portfolio.helper.ts
  76. 12
      apps/api/src/models/rules/account-cluster-risk/current-investment.ts
  77. 2
      apps/api/src/models/rules/account-cluster-risk/single-account.ts
  78. 2
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  79. 12
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  80. 16
      apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts
  81. 12
      apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts
  82. 4
      apps/api/src/services/configuration/configuration.service.ts
  83. 5
      apps/api/src/services/cron.service.ts
  84. 4
      apps/api/src/services/data-gathering/data-gathering.processor.ts
  85. 156
      apps/api/src/services/data-gathering/data-gathering.service.ts
  86. 1
      apps/api/src/services/data-provider/data-enhancer/data-enhancer.service.ts
  87. 20
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  88. 33
      apps/api/src/services/data-provider/data-provider.service.ts
  89. 15
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  90. 7
      apps/api/src/services/data-provider/manual/manual.service.ts
  91. 24
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  92. 1
      apps/api/src/services/interfaces/environment.interface.ts
  93. 7
      apps/api/src/services/interfaces/interfaces.ts
  94. 16
      apps/api/src/services/market-data/market-data.service.ts
  95. 72
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  96. 2
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  97. 44
      apps/api/src/validators/is-currency-code.ts
  98. 18
      apps/client/localhost.cert
  99. 28
      apps/client/localhost.pem
  100. 14
      apps/client/project.json

10
.eslintrc.json

@ -24,12 +24,18 @@
{ {
"files": ["*.ts", "*.tsx"], "files": ["*.ts", "*.tsx"],
"extends": ["plugin:@nx/typescript"], "extends": ["plugin:@nx/typescript"],
"rules": {} "rules": {
"@typescript-eslint/no-extra-semi": "error",
"no-extra-semi": "off"
}
}, },
{ {
"files": ["*.js", "*.jsx"], "files": ["*.js", "*.jsx"],
"extends": ["plugin:@nx/javascript"], "extends": ["plugin:@nx/javascript"],
"rules": {} "rules": {
"@typescript-eslint/no-extra-semi": "error",
"no-extra-semi": "off"
}
}, },
{ {
"files": ["*.ts"], "files": ["*.ts"],

12
.github/workflows/build-code.yml

@ -13,7 +13,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node_version: node_version:
- 18 - 20
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -24,16 +24,16 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node_version }} node-version: ${{ matrix.node_version }}
cache: 'yarn' cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: yarn install --frozen-lockfile run: npm ci
- name: Check formatting - name: Check formatting
run: yarn format:check run: npm run format:check
- name: Execute tests - name: Execute tests
run: yarn test run: npm test
- name: Build application - name: Build application
run: yarn build:production run: npm run build:production

5
.gitignore

@ -5,8 +5,8 @@
/tmp /tmp
# dependencies # dependencies
/.yarn
/node_modules /node_modules
npm-debug.log
# IDEs and editors # IDEs and editors
/.idea /.idea
@ -28,15 +28,14 @@
.env .env
.env.prod .env.prod
.nx/cache .nx/cache
.nx/workspace-data
/.sass-cache /.sass-cache
/connect.lock /connect.lock
/coverage /coverage
/dist /dist
/libpeerconnection.log /libpeerconnection.log
npm-debug.log
testem.log testem.log
/typings /typings
yarn-error.log
# System Files # System Files
.DS_Store .DS_Store

2
.nvmrc

@ -1 +1 @@
v18 v20

1
.prettierignore

@ -1,4 +1,5 @@
/.nx/cache /.nx/cache
/.nx/workspace-data
/apps/client/src/polyfills.ts /apps/client/src/polyfills.ts
/dist /dist
/test/import /test/import

1
.yarnrc

@ -1 +0,0 @@
network-timeout 600000

307
CHANGELOG.md

@ -5,7 +5,306 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased ## 2.106.0-beta.2 - 2024-08-26
### Changed
- Reworked the portfolio calculator
- Exposed the maximum of chart data items as an environment variable (`MAX_CHART_ITEMS`)
### Fixed
- Fixed an issue in the view mode toggle of the holdings tab on the home page (experimental)
## 2.105.0 - 2024-08-21
### Added
- Added support to deactivate rules in the _X-ray_ section (experimental)
### Changed
- Improved the language localization for German (`de`)
### Fixed
- Fixed the currency conversion for fees and values in the dividend import by applying the correct rate based on the activity date
- Fixed the currency conversion for fees and values in the activities service by applying the correct rate based on the activity date
## 2.104.1 - 2024-08-17
### Fixed
- Fixed an issue with the clone functionality of an activity caused by a changed date format
## 2.104.0 - 2024-08-17
### Added
- Set up a notification service for alert and confirmation dialogs
### Changed
- Refactored the dark theme CSS selector
- Improved the language localization for German (`de`)
- Upgraded `date-fns` from version `2.29.3` to `3.6.0`
- Upgraded `zone.js` from version `0.14.7` to `0.14.10`
### Fixed
- Removed `read_only: true` from the `docker-compose.yml` file to allow `prisma` to run migrations
## 2.103.0 - 2024-08-10
### Changed
- Improved the color assignment in the chart of the holdings tab on the home page (experimental)
- Enabled Catalan (`ca`) as an option in the user settings (experimental)
- Enabled Polish (`pl`) as an option in the user settings (experimental)
- Improved the language localization for Portuguese (`pt`)
- Optimized the docker image layers to reduce the image size
- Updated the binary targets of `debian-openssl` for `prisma`
- Upgraded `prisma` from version `5.17.0` to `5.18.0`
## 2.102.0 - 2024-08-07
### Added
- Added support to clone an activity from the account detail dialog (experimental)
- Added support to edit an activity from the account detail dialog (experimental)
- Added support to clone an activity from the holding detail dialog (experimental)
- Added support to edit an activity from the holding detail dialog (experimental)
### Changed
- Improved the caching of the benchmarks in the markets overview by returning cached data and recalculating in the background when it expires
- Improved the language localization for German (`de`)
- Improved the language localization for Polish (`pl`)
- Upgraded `Nx` from version `19.5.1` to `19.5.6`
### Fixed
- Fixed the cache flush endpoint response
## 2.101.0 - 2024-08-03
### Changed
- Hardened container security by switching to a non-root user, setting the filesystem to read-only, and dropping unnecessary capabilities
## 2.100.0 - 2024-08-03
### Added
- Added support to manage tags of holdings in the holding detail dialog
### Changed
- Improved the color assignment in the chart of the holdings tab on the home page (experimental)
- Persisted the view mode of the holdings tab on the home page (experimental)
- Improved the language localization for Catalan (`ca`)
- Improved the language localization for Spanish (`es`)
## 2.99.0 - 2024-07-29
### Changed
- Migrated the usage of `yarn` to `npm`
- Upgraded `storybook` from version `7.0.9` to `8.2.5`
- Downgraded `marked` from version `13.0.0` to `12.0.2`
## 2.98.0 - 2024-07-27
### Added
- Set up the language localization for Catalan (`ca`)
### Changed
- Improved the account selector of the create or update activity dialog
- Improved the handling of the numerical precision in the value component
- Skipped derived currencies in the get quotes functionality of the data provider service
- Improved the language localization for Spanish (`es`)
- Upgraded `angular` from version `18.0.4` to `18.1.1`
- Upgraded `Nx` from version `19.4.3` to `19.5.1`
- Upgraded `prisma` from version `5.16.1` to `5.17.0`
### Fixed
- Fixed the dividend import from a data provider for holdings without an account
- Fixed an issue in the public page related to a non-existent access
## 2.97.0 - 2024-07-20
### Added
- Added _selfh.st_ to the _As seen in_ section on the landing page
### Changed
- Improved the numerical precision in the holding detail dialog
- Improved the handling of the numerical precision in the value component
- Optimized the 7d data gathering by prioritizing the currencies
- Improved the language localization for German (`de`)
- Upgraded `Node.js` from version `18` to `20` (`Dockerfile`)
- Upgraded `Nx` from version `19.4.0` to `19.4.3`
- Upgraded `prettier` from version `3.3.1` to `3.3.3`
### Fixed
- Fixed the table sorting of the holdings tab on the home page
## 2.96.0 - 2024-07-13
### Changed
- Improved the chart of the holdings tab on the home page (experimental)
- Separated the icon purposes in the `site.webmanifest`
### Fixed
- Fixed an issue in the portfolio summary with the currency conversion of fees
- Fixed an issue in the the search for a holding
- Removed the show condition of the experimental features setting in the user settings
## 2.95.0 - 2024-07-12
### Added
- Added a chart to the holdings tab of the home page (experimental)
## 2.94.0 - 2024-07-09
### Changed
- Improved the language localization for German (`de`)
### Fixed
- Fixed a pagination issue in the activities endpoint by adding `id` as a secondary sort criterion to `date` to ensure consistent ordering
## 2.93.0 - 2024-07-07
### Added
- Added the _Crypto Coins Heatmap_ to the resources section
- Added the _Stock Heatmap_ to the resources section
- Extended the content of the _Self-Hosting_ section by the platforms concept on the Frequently Asked Questions (FAQ) page
### Changed
- Improved the allocations by ETF holding on the allocations page for the impersonation mode (experimental)
- Improved the detection of REST APIs (`JSON`) used via the scraper configuration
- Improved the usability to delete an asset profile of type currency in the historical market data table and the asset profile details dialog of the admin control
- Refreshed the cryptocurrencies list
- Refactored the thresholds of the rules in the _X-ray_ section
- Removed the obsolete `version` from the `docker-compose` files
- Upgraded `Nx` from version `19.2.2` to `19.4.0`
## 2.92.0 - 2024-06-30
### Added
- Added support for bulk deletion of asset profiles from the market data table in the admin control panel
### Changed
- Added support for derived currencies in the currency validation
- Added support for automatic deletion of unused asset profiles when deleting activities
- Improved the caching of the benchmarks in the markets overview (only cache if needed)
- Upgraded `prisma` from version `5.15.0` to `5.16.1`
### Fixed
- Fixed an issue with the all time high in the benchmarks of the markets overview
## 2.91.0 - 2024-06-26
### Added
- Added a benchmarks preset to the historical market data table of the admin control panel
### Changed
- Upgraded `angular` from version `18.0.2` to `18.0.4`
### Fixed
- Fixed the dialog position (center) on mobile
- Fixed the horizontal overflow in the historical market data table of the admin control panel
- Changed the mechanism of the `INTRADAY` data gathering to persist data only if the market state is `OPEN`
- Fixed the creation of activities with `MANUAL` data source (with no historical market data)
## 2.90.0 - 2024-06-22
### Added
- Added a dialog for the benchmarks in the markets overview
- Extended the asset profile details dialog of the admin control for currencies
- Extended the content of the _Self-Hosting_ section by the mobile app question on the Frequently Asked Questions (FAQ) page
### Changed
- Moved the indicator for active filters from experimental to general availability
- Improved the error handling in the biometric authentication registration
- Improved the language localization for German (`de`)
- Set up SSL for local development
- Upgraded the _Stripe_ dependencies
- Upgraded `marked` from version `9.1.6` to `13.0.0`
- Upgraded `ngx-device-detector` from version `5.0.1` to `8.0.0`
- Upgraded `ngx-markdown` from version `17.1.1` to `18.0.0`
- Upgraded `zone.js` from version `0.14.5` to `0.14.7`
## 2.89.0 - 2024-06-14
### Added
- Extended the historical market data table with currencies preset by date and activities count in the admin control panel
### Changed
- Improved the date validation in the create, import and update activities endpoints
- Improved the language localization for German (`de`)
## 2.88.0 - 2024-06-11
### Added
- Set the image source label in `Dockerfile`
### Changed
- Improved the style of the blog post list
- Migrated the `@ghostfolio/client` components to control flow
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `17.3.10` to `18.0.2`
- Upgraded `Nx` from version `19.0.5` to `19.2.2`
## 2.87.0 - 2024-06-08
### Changed
- Improved the portfolio summary
- Improved the allocations by ETF holding on the allocations page (experimental)
- Improved the error handling in the `HttpResponseInterceptor`
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `5.14.0` to `5.15.0`
### Fixed
- Fixed an issue in the _FIRE_ calculator
## 2.86.0 - 2024-06-07
### Added
- Introduced the allocations by ETF holding on the allocations page (experimental)
### Changed
- Upgraded `prettier` from version `3.2.5` to `3.3.1`
## 2.85.0 - 2024-06-06
### Added ### Added
@ -16,6 +315,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved the language localization for German (`de`) - Improved the language localization for German (`de`)
- Upgraded `ng-extract-i18n-merge` from version `2.10.0` to `2.12.0` - Upgraded `ng-extract-i18n-merge` from version `2.10.0` to `2.12.0`
### Fixed
- Fixed an issue with the default locale in the value component
## 2.84.0 - 2024-06-01 ## 2.84.0 - 2024-06-01
### Added ### Added
@ -4656,7 +4959,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added the attribute `precision` in the value component - Added the attribute `precision` to the value component
### Fixed ### Fixed

63
DEVELOPMENT.md

@ -1,5 +1,54 @@
# Ghostfolio Development Guide # Ghostfolio Development Guide
## Development Environment
### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 20+)
- Create a local copy of this Git repository (clone)
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
### Setup
1. Run `npm install`
1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `npm run database:setup` to initialize the database schema
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
1. Start the [server](#start-server) and the [client](#start-client)
1. Open https://localhost:4200/en in your browser
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
### Start Server
#### Debug
Run `npm run watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
#### Serve
Run `npm run start:server`
### Start Client
Run `npm run start:client` and open https://localhost:4200/en in your browser
### Start _Storybook_
Run `npm run start:storybook`
### Migrate Database
With the following command you can keep your database schema in sync:
```bash
npm run database:push
```
## Testing
Run `npm test`
## Experimental Features ## Experimental Features
New functionality can be enabled using a feature flag switch from the user settings. New functionality can be enabled using a feature flag switch from the user settings.
@ -10,7 +59,7 @@ Remove permission in `UserService` using `without()`
### Frontend ### Frontend
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
## Git ## Git
@ -30,26 +79,26 @@ Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
#### Upgrade #### Upgrade
1. Run `yarn nx migrate latest` 1. Run `npx nx migrate latest`
1. Make sure `package.json` changes make sense and then run `yarn install` 1. Make sure `package.json` changes make sense and then run `npm install`
1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338) 1. Run `npx nx migrate --run-migrations`
### Prisma ### Prisma
#### Access database via GUI #### Access database via GUI
Run `yarn database:gui` Run `npm run database:gui`
https://www.prisma.io/studio https://www.prisma.io/studio
#### Synchronize schema with database for prototyping #### Synchronize schema with database for prototyping
Run `yarn database:push` Run `npm run database:push`
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
#### Create schema migration #### Create schema migration
Run `yarn prisma migrate dev --name added_job_title` Run `npm run prisma migrate dev --name added_job_title`
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate

44
Dockerfile

@ -1,25 +1,25 @@
FROM --platform=$BUILDPLATFORM node:18-slim as builder FROM --platform=$BUILDPLATFORM node:20-slim AS builder
# Build application and add additional files # Build application and add additional files
WORKDIR /ghostfolio WORKDIR /ghostfolio
RUN apt-get update && apt-get install -y --no-install-suggests \
g++ \
git \
make \
openssl \
python3 \
&& rm -rf /var/lib/apt/lists/*
# Only add basic files without the application itself to avoid rebuilding # Only add basic files without the application itself to avoid rebuilding
# layers when files (package.json etc.) have not changed # layers when files (package.json etc.) have not changed
COPY ./CHANGELOG.md CHANGELOG.md COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE COPY ./LICENSE LICENSE
COPY ./package.json package.json COPY ./package.json package.json
COPY ./yarn.lock yarn.lock COPY ./package-lock.json package-lock.json
COPY ./.yarnrc .yarnrc
COPY ./prisma/schema.prisma prisma/schema.prisma COPY ./prisma/schema.prisma prisma/schema.prisma
RUN apt update && apt install -y \ RUN npm install
g++ \
git \
make \
openssl \
python3 \
&& rm -rf /var/lib/apt/lists/*
RUN yarn install
# See https://github.com/nrwl/nx/issues/6586 for further details # See https://github.com/nrwl/nx/issues/6586 for further details
COPY ./decorate-angular-cli.js decorate-angular-cli.js COPY ./decorate-angular-cli.js decorate-angular-cli.js
@ -33,31 +33,35 @@ COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs COPY ./libs libs
COPY ./apps apps COPY ./apps apps
RUN yarn build:production RUN npm run build:production
# Prepare the dist image with additional node_modules # Prepare the dist image with additional node_modules
WORKDIR /ghostfolio/dist/apps/api WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original # package.json was generated by the build process, however the original
yarn.lock needs to be used to ensure the same versions package-lock.json needs to be used to ensure the same versions
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
RUN yarn RUN npm install
COPY prisma /ghostfolio/dist/apps/api/prisma COPY prisma /ghostfolio/dist/apps/api/prisma
# Overwrite the generated package.json with the original one to ensure having # Overwrite the generated package.json with the original one to ensure having
# all the scripts # all the scripts
COPY package.json /ghostfolio/dist/apps/api COPY package.json /ghostfolio/dist/apps/api
RUN yarn database:generate-typings RUN npm run database:generate-typings
# Image to run, copy everything needed from builder # Image to run, copy everything needed from builder
FROM node:18-slim FROM node:20-slim
RUN apt update && apt install -y \ LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y --no-install-suggests \
curl \ curl \
openssl \ openssl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
WORKDIR /ghostfolio/apps/api WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333} EXPOSE ${PORT:-3333}
USER node
CMD [ "/ghostfolio/entrypoint.sh" ] CMD [ "/ghostfolio/entrypoint.sh" ]

108
README.md

@ -7,7 +7,7 @@
**Open Source Wealth Management Software** **Open Source Wealth Management Software**
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) | [**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](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://x.com/ghostfolio_)
[![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio) [![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio)
[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing) [![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing)
@ -47,7 +47,7 @@ Ghostfolio is for you if you are...
- ✅ Create, update and delete transactions - ✅ Create, update and delete transactions
- ✅ Multi account management - ✅ Multi account management
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max` - ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Various charts - ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio - ✅ Static analysis to identify potential risks in your portfolio
- ✅ Import and export transactions - ✅ Import and export transactions
@ -71,7 +71,7 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
### Frontend ### Frontend
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com). The frontend is built with [Angular](https://angular.dev) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
## Self-hosting ## Self-hosting
@ -87,21 +87,21 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
| Name | Type | Default Value | Description | | Name | Type | Default Value | Description |
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | string | | A random string used as salt for access tokens | | `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | string (`optional`) |   | The _CoinGecko_ Demo API key | | `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API | | `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | string | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | | `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 | | `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | string | | A random string used for _JSON Web Tokens_ (JWT) | | `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | number (`optional`) | `3333` | The port where the Ghostfolio application will run on | | `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | string | | The name of the _PostgreSQL_ database | | `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | string | | The password of the _PostgreSQL_ database | | `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | string | | The user of the _PostgreSQL_ database | | `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | number (`optional`) | `0` | The database index of _Redis_ | | `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | string | | The host where _Redis_ is running | | `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | string | | The password of _Redis_ | | `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | number | | The port where _Redis_ is running | | `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | number (`optional`) | `2000` | The timeout of network requests to data providers in milliseconds | | `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
### Run with Docker Compose ### Run with Docker Compose
@ -146,53 +146,7 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
## Development ## Development
### Prerequisites For detailed information on the environment setup and development process, please refer to [DEVELOPMENT.md](./DEVELOPMENT.md).
- [Docker](https://www.docker.com/products/docker-desktop)
- [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.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
### Setup
1. Run `yarn install`
1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `yarn database:setup` to initialize the database schema
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
1. Start the server and the client (see [_Development_](#Development))
1. Open http://localhost:4200/en in your browser
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
### Start Server
#### Debug
Run `yarn watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
#### Serve
Run `yarn start:server`
### Start Client
Run `yarn start:client` and open http://localhost:4200/en in your browser
### Start _Storybook_
Run `yarn start:storybook`
### Migrate Database
With the following command you can keep your database schema in sync:
```bash
yarn database:push
```
## Testing
Run `yarn test`
## Public API ## Public API
@ -234,17 +188,17 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
``` ```
| Field | Type | Description | | Field | Type | Description |
| ---------- | ------------------- | ----------------------------------------------------------------------------- | | ------------ | ------------------- | ----------------------------------------------------------------------------- |
| accountId | string (`optional`) | Id of the account | | `accountId` | `string` (optional) | Id of the account |
| comment | string (`optional`) | Comment of the activity | | `comment` | `string` (optional) | Comment of the activity |
| currency | string | `CHF` \| `EUR` \| `USD` etc. | | `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
| dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` | | `dataSource` | `string` | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
| date | string | Date in the format `ISO-8601` | | `date` | `string` | Date in the format `ISO-8601` |
| fee | number | Fee of the activity | | `fee` | `number` | Fee of the activity |
| quantity | number | Quantity of the activity | | `quantity` | `number` | Quantity of the activity |
| symbol | string | Symbol of the activity (suitable for `dataSource`) | | `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
| type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` | | `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
| unitPrice | number | Price per unit of the activity | | `unitPrice` | `number` | Price per unit of the activity |
#### Response #### Response
@ -275,7 +229,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you. Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you. Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you.
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio). If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).

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

@ -176,8 +176,8 @@ export class AccountService {
ACCOUNT: filtersByAccount, ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass, ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag TAG: filtersByTag
} = groupBy(filters, (filter) => { } = groupBy(filters, ({ type }) => {
return filter.type; return type;
}); });
if (filtersByAccount?.length > 0) { if (filtersByAccount?.length > 0) {

5
apps/api/src/app/account/create-account.dto.ts

@ -1,7 +1,8 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
IsISO4217CurrencyCode,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
@ -20,7 +21,7 @@ export class CreateAccountDto {
) )
comment?: string; comment?: string;
@IsISO4217CurrencyCode() @IsCurrencyCode()
currency: string; currency: string;
@IsOptional() @IsOptional()

5
apps/api/src/app/account/update-account.dto.ts

@ -1,7 +1,8 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
IsISO4217CurrencyCode,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
@ -20,7 +21,7 @@ export class UpdateAccountDto {
) )
comment?: string; comment?: string;
@IsISO4217CurrencyCode() @IsCurrencyCode()
currency: string; currency: string;
@IsString() @IsString()

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

@ -81,10 +81,11 @@ export class AdminController {
@Post('gather/max') @Post('gather/max')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMax(): Promise<void> { public async gatherMax(): Promise<void> {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return { return {
data: { data: {
dataSource, dataSource,
@ -107,10 +108,11 @@ export class AdminController {
@Post('gather/profile-data') @Post('gather/profile-data')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileData(): Promise<void> { public async gatherProfileData(): Promise<void> {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return { return {
data: { data: {
dataSource, dataSource,

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

@ -1,3 +1,5 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
@ -20,11 +22,13 @@ import { QueueModule } from './queue/queue.module';
@Module({ @Module({
imports: [ imports: [
ApiModule, ApiModule,
BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
OrderModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
QueueModule, QueueModule,

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

@ -1,3 +1,5 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -14,21 +16,24 @@ import {
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { isCurrency, getCurrencyFromSymbol } from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem, AdminMarketDataItem,
Filter, AssetProfileIdentifier,
UniqueAsset EnhancedSymbolProfile,
Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types'; import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
Prisma, Prisma,
PrismaClient,
Property, Property,
SymbolProfile, SymbolProfile,
DataSource, DataSource,
@ -41,10 +46,12 @@ import { groupBy } from 'lodash';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
public constructor( public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
@ -56,7 +63,9 @@ export class AdminService {
currency, currency,
dataSource, dataSource,
symbol symbol
}: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> { }: AssetProfileIdentifier & { currency?: string }): Promise<
SymbolProfile | never
> {
try { try {
if (dataSource === 'MANUAL') { if (dataSource === 'MANUAL') {
return this.symbolProfileService.add({ return this.symbolProfileService.add({
@ -93,7 +102,10 @@ export class AdminService {
} }
} }
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) { public async deleteProfileData({
dataSource,
symbol
}: AssetProfileIdentifier) {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
await this.symbolProfileService.delete({ dataSource, symbol }); await this.symbolProfileService.delete({ dataSource, symbol });
} }
@ -151,7 +163,16 @@ export class AdminService {
[{ symbol: 'asc' }]; [{ symbol: 'asc' }];
const where: Prisma.SymbolProfileWhereInput = {}; const where: Prisma.SymbolProfileWhereInput = {};
if (presetId === 'CURRENCIES') { if (presetId === 'BENCHMARKS') {
const benchmarkAssetProfiles =
await this.benchmarkService.getBenchmarkAssetProfiles();
where.id = {
in: benchmarkAssetProfiles.map(({ id }) => {
return id;
})
};
} else if (presetId === 'CURRENCIES') {
return this.getMarketDataForCurrencies(); return this.getMarketDataForCurrencies();
} else if ( } else if (
presetId === 'ETF_WITHOUT_COUNTRIES' || presetId === 'ETF_WITHOUT_COUNTRIES' ||
@ -201,8 +222,11 @@ export class AdminService {
} }
} }
const extendedPrismaClient = this.getExtendedPrismaClient();
try {
let [assetProfiles, count] = await Promise.all([ let [assetProfiles, count] = await Promise.all([
this.prismaService.symbolProfile.findMany({ extendedPrismaClient.symbolProfile.findMany({
orderBy, orderBy,
skip, skip,
take, take,
@ -218,6 +242,7 @@ export class AdminService {
currency: true, currency: true,
dataSource: true, dataSource: true,
id: true, id: true,
isUsedByUsersWithSubscription: true,
name: true, name: true,
Order: { Order: {
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
@ -233,8 +258,9 @@ export class AdminService {
this.prismaService.symbolProfile.count({ where }) this.prismaService.symbolProfile.count({ where })
]); ]);
let marketData: AdminMarketDataItem[] = assetProfiles.map( let marketData: AdminMarketDataItem[] = await Promise.all(
({ assetProfiles.map(
async ({
_count, _count,
assetClass, assetClass,
assetSubClass, assetSubClass,
@ -243,13 +269,16 @@ export class AdminService {
currency, currency,
dataSource, dataSource,
id, id,
isUsedByUsersWithSubscription,
name, name,
Order, Order,
sectors, sectors,
symbol, symbol,
tags tags
}) => { }) => {
const countriesCount = countries ? Object.keys(countries).length : 0; const countriesCount = countries
? Object.keys(countries).length
: 0;
const marketDataItemCount = const marketDataItemCount =
marketDataItems.find((marketDataItem) => { marketDataItems.find((marketDataItem) => {
return ( return (
@ -273,9 +302,12 @@ export class AdminService {
sectorsCount, sectorsCount,
activitiesCount: _count.Order, activitiesCount: _count.Order,
date: Order?.[0]?.date, date: Order?.[0]?.date,
isUsedByUsersWithSubscription:
await isUsedByUsersWithSubscription,
tags tags
}; };
} }
)
); );
if (presetId) { if (presetId) {
@ -296,12 +328,27 @@ export class AdminService {
count, count,
marketData marketData
}; };
} finally {
await extendedPrismaClient.$disconnect();
Logger.debug('Disconnect extended prisma client', 'AdminService');
}
} }
public async getMarketDataBySymbol({ public async getMarketDataBySymbol({
dataSource, dataSource,
symbol symbol
}: UniqueAsset): Promise<AdminMarketDataDetails> { }: AssetProfileIdentifier): Promise<AdminMarketDataDetails> {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency));
}
const [[assetProfile], marketData] = await Promise.all([ const [[assetProfile], marketData] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([ this.symbolProfileService.getSymbolProfiles([
{ {
@ -329,8 +376,11 @@ export class AdminService {
return { return {
marketData, marketData,
assetProfile: assetProfile ?? { assetProfile: assetProfile ?? {
symbol, activitiesCount,
currency: '-' currency,
dataSource,
dateOfFirstActivity,
symbol
} }
}; };
} }
@ -342,6 +392,7 @@ export class AdminService {
countries, countries,
currency, currency,
dataSource, dataSource,
holdings,
name, name,
tags, tags,
scraperConfiguration, scraperConfiguration,
@ -349,71 +400,38 @@ export class AdminService {
symbol, symbol,
symbolMapping, symbolMapping,
url url
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) { }: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
if (dataSource === 'MANUAL') { const symbolProfileOverrides = {
await this.symbolProfileService.updateSymbolProfile({ assetClass: assetClass as AssetClass,
assetClass, assetSubClass: assetSubClass as AssetSubClass,
assetSubClass, name: name as string,
url: url as string
};
const updatedSymbolProfile: AssetProfileIdentifier &
Prisma.SymbolProfileUpdateInput = {
comment, comment,
countries, countries,
currency, currency,
dataSource, dataSource,
name, holdings,
tags,
scraperConfiguration,
symbol,
sectors,
symbolMapping
});
} else {
await this.symbolProfileService.updateSymbolProfile({
comment,
countries,
dataSource,
name,
tags,
scraperConfiguration, scraperConfiguration,
sectors, sectors,
symbol, symbol,
symbolMapping symbolMapping,
}); ...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url }
let symbolProfileId = : {
await this.symbolProfileOverwriteService.GetSymbolProfileId( SymbolProfileOverrides: {
symbol, upsert: {
dataSource create: symbolProfileOverrides,
); update: symbolProfileOverrides
if (symbolProfileId) {
await this.symbolProfileOverwriteService.updateSymbolProfileOverrides({
assetClass,
assetSubClass,
symbolProfileId
});
} else {
let profiles = await this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]);
symbolProfileId = profiles[0].id;
await this.symbolProfileOverwriteService.add({
SymbolProfile: {
connect: {
dataSource_symbol: {
dataSource,
symbol
}
}
}
});
await this.symbolProfileOverwriteService.updateSymbolProfileOverrides({
assetClass,
assetSubClass,
symbolProfileId
});
} }
} }
})
};
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([ const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{ {
@ -443,15 +461,72 @@ export class AdminService {
return response; return response;
} }
private getExtendedPrismaClient() {
Logger.debug('Connect extended prisma client', 'AdminService');
const symbolProfileExtension = Prisma.defineExtension((client) => {
return client.$extends({
result: {
symbolProfile: {
isUsedByUsersWithSubscription: {
compute: async ({ id }) => {
const { _count } =
await this.prismaService.symbolProfile.findUnique({
select: {
_count: {
select: {
Order: {
where: {
User: {
Subscription: {
some: {
expiresAt: {
gt: new Date()
}
}
}
}
}
}
}
}
},
where: {
id
}
});
return _count.Order > 0;
}
}
}
}
});
});
return new PrismaClient().$extends(symbolProfileExtension);
}
private async getMarketDataForCurrencies(): Promise<AdminMarketData> { private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
const marketDataItems = await this.prismaService.marketData.groupBy({ const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true, _count: true,
by: ['dataSource', 'symbol'] by: ['dataSource', 'symbol']
}); });
const marketData: AdminMarketDataItem[] = this.exchangeRateDataService const marketDataPromise: Promise<AdminMarketDataItem>[] =
this.exchangeRateDataService
.getCurrencyPairs() .getCurrencyPairs()
.map(({ dataSource, symbol }) => { .map(async ({ dataSource, symbol }) => {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency));
}
const marketDataItemCount = const marketDataItemCount =
marketDataItems.find((marketDataItem) => { marketDataItems.find((marketDataItem) => {
return ( return (
@ -461,6 +536,8 @@ export class AdminService {
})?._count ?? 0; })?._count ?? 0;
return { return {
activitiesCount,
date: dateOfFirstActivity,
dataSource, dataSource,
marketDataItemCount, marketDataItemCount,
symbol, symbol,
@ -474,6 +551,7 @@ export class AdminService {
}; };
}); });
const marketData = await Promise.all(marketDataPromise);
return { marketData, count: marketData.length }; return { marketData, count: marketData.length };
} }

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

@ -1,8 +1,9 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { AssetClass, AssetSubClass, Prisma, Tag } from '@prisma/client'; import { AssetClass, AssetSubClass, Prisma, Tag } from '@prisma/client';
import { import {
IsArray, IsArray,
IsEnum, IsEnum,
IsISO4217CurrencyCode,
IsObject, IsObject,
IsOptional, IsOptional,
IsString, IsString,
@ -26,7 +27,7 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
countries?: Prisma.InputJsonArray; countries?: Prisma.InputJsonArray;
@IsISO4217CurrencyCode() @IsCurrencyCode()
@IsOptional() @IsOptional()
currency?: string; currency?: string;

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

@ -25,6 +25,7 @@ import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module'; import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AssetModule } from './asset/asset.module';
import { AuthDeviceModule } from './auth-device/auth-device.module'; import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module'; import { BenchmarkModule } from './benchmark/benchmark.module';
@ -51,6 +52,7 @@ import { UserModule } from './user/user.module';
AdminModule, AdminModule,
AccessModule, AccessModule,
AccountModule, AccountModule,
AssetModule,
AuthDeviceModule, AuthDeviceModule,
AuthModule, AuthModule,
BenchmarkModule, BenchmarkModule,

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

@ -0,0 +1,29 @@
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import type { AdminMarketDataDetails } from '@ghostfolio/common/interfaces';
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { pick } from 'lodash';
@Controller('asset')
export class AssetController {
public constructor(private readonly adminService: AdminService) {}
@Get(':dataSource/:symbol')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAsset(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> {
const { assetProfile, marketData } =
await this.adminService.getMarketDataBySymbol({ dataSource, symbol });
return {
marketData,
assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol'])
};
}
}

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

@ -0,0 +1,17 @@
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { Module } from '@nestjs/common';
import { AssetController } from './asset.controller';
@Module({
controllers: [AssetController],
imports: [
AdminModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
]
})
export class AssetModule {}

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

@ -1,12 +1,12 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import type { import type {
AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkResponse, BenchmarkResponse
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
@ -41,7 +41,9 @@ export class BenchmarkController {
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) { public async addBenchmark(
@Body() { dataSource, symbol }: AssetProfileIdentifier
) {
try { try {
const benchmark = await this.benchmarkService.addBenchmark({ const benchmark = await this.benchmarkService.addBenchmark({
dataSource, dataSource,
@ -105,19 +107,19 @@ export class BenchmarkController {
@Get(':dataSource/:symbol/:startDateString') @Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataBySymbol( public async getBenchmarkMarketDataForUser(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string, @Param('startDateString') startDateString: string,
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Query('range') dateRange: DateRange = 'max' @Query('range') dateRange: DateRange = 'max'
): Promise<BenchmarkMarketDataDetails> { ): Promise<BenchmarkMarketDataDetails> {
const { endDate, startDate } = getInterval( const { endDate, startDate } = getIntervalFromDateRange(
dateRange, dateRange,
new Date(startDateString) new Date(startDateString)
); );
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.Settings.settings.baseCurrency;
return this.benchmarkService.getMarketDataBySymbol({ return this.benchmarkService.getMarketDataForUser({
dataSource, dataSource,
endDate, endDate,
startDate, startDate,

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

@ -2,6 +2,7 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
@ -18,6 +19,7 @@ import { BenchmarkService } from './benchmark.service';
controllers: [BenchmarkController], controllers: [BenchmarkController],
exports: [BenchmarkService], exports: [BenchmarkService],
imports: [ imports: [
ConfigurationModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,

1
apps/api/src/app/benchmark/benchmark.service.spec.ts

@ -12,6 +12,7 @@ describe('BenchmarkService', () => {
null, null,
null, null,
null, null,
null,
null null
); );
}); });

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

@ -1,15 +1,13 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
MAX_CHART_ITEMS,
PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
calculateBenchmarkTrend, calculateBenchmarkTrend,
@ -17,11 +15,11 @@ import {
resetHours resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier,
Benchmark, Benchmark,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkProperty, BenchmarkProperty,
BenchmarkResponse, BenchmarkResponse
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { BenchmarkTrend } from '@ghostfolio/common/types'; import { BenchmarkTrend } from '@ghostfolio/common/types';
@ -29,20 +27,25 @@ import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { import {
addHours,
differenceInDays, differenceInDays,
eachDayOfInterval, eachDayOfInterval,
format, format,
isAfter,
isSameDay, isSameDay,
subDays subDays
} from 'date-fns'; } from 'date-fns';
import { isNumber, last, uniqBy } from 'lodash'; import { isNumber, last, uniqBy } from 'lodash';
import ms from 'ms'; import ms from 'ms';
import { BenchmarkValue } from './interfaces/benchmark-value.interface';
@Injectable() @Injectable()
export class BenchmarkService { export class BenchmarkService {
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS'; private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@ -61,7 +64,10 @@ export class BenchmarkService {
return 0; return 0;
} }
public async getBenchmarkTrends({ dataSource, symbol }: UniqueAsset) { public async getBenchmarkTrends({
dataSource,
symbol
}: AssetProfileIdentifier) {
const historicalData = await this.marketDataService.marketDataItems({ const historicalData = await this.marketDataService.marketDataItems({
orderBy: { orderBy: {
date: 'desc' date: 'desc'
@ -89,94 +95,28 @@ export class BenchmarkService {
enableSharing = false, enableSharing = false,
useCache = true useCache = true
} = {}): Promise<BenchmarkResponse['benchmarks']> { } = {}): Promise<BenchmarkResponse['benchmarks']> {
let benchmarks: BenchmarkResponse['benchmarks'];
if (useCache) { if (useCache) {
try { try {
benchmarks = JSON.parse( const cachedBenchmarkValue = await this.redisCacheService.get(
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS) this.CACHE_KEY_BENCHMARKS
); );
if (benchmarks) { const { benchmarks, expiration }: BenchmarkValue =
return benchmarks; JSON.parse(cachedBenchmarkValue);
}
} catch {}
}
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
enableSharing
});
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] = Logger.debug('Fetched benchmarks from cache', 'BenchmarkService');
[];
const promisesBenchmarkTrends: Promise<{
trend50d: BenchmarkTrend;
trend200d: BenchmarkTrend;
}>[] = [];
const quotes = await this.dataProviderService.getQuotes({ if (isAfter(new Date(), new Date(expiration))) {
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => { this.calculateAndCacheBenchmarks({
return { dataSource, symbol }; enableSharing
}),
requestTimeout: ms('30 seconds'),
useCache: false
}); });
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
promisesAllTimeHighs.push(
this.marketDataService.getMax({ dataSource, symbol })
);
promisesBenchmarkTrends.push(
this.getBenchmarkTrends({ dataSource, symbol })
);
} }
const [allTimeHighs, benchmarkTrends] = await Promise.all([ return benchmarks;
Promise.all(promisesAllTimeHighs), } catch {}
Promise.all(promisesBenchmarkTrends)
]);
let storeInCache = true;
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } =
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
let performancePercentFromAllTimeHigh = 0;
if (allTimeHigh?.marketPrice && marketPrice) {
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
allTimeHigh.marketPrice,
marketPrice
);
} else {
storeInCache = false;
}
return {
marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh
),
name: benchmarkAssetProfiles[index].name,
performances: {
allTimeHigh: {
date: allTimeHigh?.date,
performancePercent: performancePercentFromAllTimeHigh
}
},
trend50d: benchmarkTrends[index].trend50d,
trend200d: benchmarkTrends[index].trend200d
};
});
if (storeInCache) {
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks),
ms('2 hours') / 1000
);
} }
return benchmarks; return this.calculateAndCacheBenchmarks({ enableSharing });
} }
public async getBenchmarkAssetProfiles({ public async getBenchmarkAssetProfiles({
@ -213,7 +153,7 @@ export class BenchmarkService {
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
} }
public async getMarketDataBySymbol({ public async getMarketDataForUser({
dataSource, dataSource,
endDate = new Date(), endDate = new Date(),
startDate, startDate,
@ -223,7 +163,7 @@ export class BenchmarkService {
endDate?: Date; endDate?: Date;
startDate: Date; startDate: Date;
userCurrency: string; userCurrency: string;
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> { } & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
const marketData: { date: string; value: number }[] = []; const marketData: { date: string; value: number }[] = [];
const days = differenceInDays(endDate, startDate) + 1; const days = differenceInDays(endDate, startDate) + 1;
@ -232,7 +172,12 @@ export class BenchmarkService {
start: startDate, start: startDate,
end: endDate end: endDate
}, },
{ step: Math.round(days / Math.min(days, MAX_CHART_ITEMS)) } {
step: Math.round(
days /
Math.min(days, this.configurationService.get('MAX_CHART_ITEMS'))
)
}
).map((date) => { ).map((date) => {
return resetHours(date); return resetHours(date);
}); });
@ -343,7 +288,7 @@ export class BenchmarkService {
public async addBenchmark({ public async addBenchmark({
dataSource, dataSource,
symbol symbol
}: UniqueAsset): Promise<Partial<SymbolProfile>> { }: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({ const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: { where: {
dataSource, dataSource,
@ -380,7 +325,7 @@ export class BenchmarkService {
public async deleteBenchmark({ public async deleteBenchmark({
dataSource, dataSource,
symbol symbol
}: UniqueAsset): Promise<Partial<SymbolProfile>> { }: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({ const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: { where: {
dataSource, dataSource,
@ -414,10 +359,101 @@ export class BenchmarkService {
}; };
} }
private async calculateAndCacheBenchmarks({
enableSharing = false
}): Promise<BenchmarkResponse['benchmarks']> {
Logger.debug('Calculate benchmarks', 'BenchmarkService');
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
enableSharing
});
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
[];
const promisesBenchmarkTrends: Promise<{
trend50d: BenchmarkTrend;
trend200d: BenchmarkTrend;
}>[] = [];
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) {
promisesAllTimeHighs.push(
this.marketDataService.getMax({ dataSource, symbol })
);
promisesBenchmarkTrends.push(
this.getBenchmarkTrends({ dataSource, symbol })
);
}
const [allTimeHighs, benchmarkTrends] = await Promise.all([
Promise.all(promisesAllTimeHighs),
Promise.all(promisesBenchmarkTrends)
]);
let storeInCache = true;
const benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } =
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
let performancePercentFromAllTimeHigh = 0;
if (allTimeHigh?.marketPrice && marketPrice) {
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
allTimeHigh.marketPrice,
marketPrice
);
} else {
storeInCache = false;
}
return {
dataSource: benchmarkAssetProfiles[index].dataSource,
marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh
),
name: benchmarkAssetProfiles[index].name,
performances: {
allTimeHigh: {
date: allTimeHigh?.date,
performancePercent:
performancePercentFromAllTimeHigh >= 0
? 0
: performancePercentFromAllTimeHigh
}
},
symbol: benchmarkAssetProfiles[index].symbol,
trend50d: benchmarkTrends[index].trend50d,
trend200d: benchmarkTrends[index].trend200d
};
});
if (storeInCache) {
const expiration = addHours(new Date(), 2);
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(<BenchmarkValue>{
benchmarks,
expiration: expiration.getTime()
}),
ms('12 hours') / 1000
);
}
return benchmarks;
}
private getMarketCondition( private getMarketCondition(
aPerformanceInPercent: number aPerformanceInPercent: number
): Benchmark['marketCondition'] { ): Benchmark['marketCondition'] {
if (aPerformanceInPercent === 0) { if (aPerformanceInPercent >= 0) {
return 'ALL_TIME_HIGH'; return 'ALL_TIME_HIGH';
} else if (aPerformanceInPercent <= -0.2) { } else if (aPerformanceInPercent <= -0.2) {
return 'BEAR_MARKET'; return 'BEAR_MARKET';

6
apps/api/src/app/benchmark/interfaces/benchmark-value.interface.ts

@ -0,0 +1,6 @@
import { BenchmarkResponse } from '@ghostfolio/common/interfaces';
export interface BenchmarkValue {
benchmarks: BenchmarkResponse['benchmarks'];
expiration: number;
}

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

@ -14,6 +14,6 @@ export class CacheController {
@Post('flush') @Post('flush')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async flushCache(): Promise<void> { public async flushCache(): Promise<void> {
return this.redisCacheService.reset(); await this.redisCacheService.reset();
} }
} }

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

@ -13,16 +13,13 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/da
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
} from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
getAssetProfileIdentifier, getAssetProfileIdentifier,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { import {
AccountWithPlatform, AccountWithPlatform,
OrderWithAccount, OrderWithAccount,
@ -54,7 +51,7 @@ export class ImportService {
dataSource, dataSource,
symbol, symbol,
userCurrency userCurrency
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> { }: AssetProfileIdentifier & { userCurrency: string }): Promise<Activity[]> {
try { try {
const { firstBuyDate, historicalData, orders } = const { firstBuyDate, historicalData, orders } =
await this.portfolioService.getPosition(dataSource, undefined, symbol); await this.portfolioService.getPosition(dataSource, undefined, symbol);
@ -75,13 +72,18 @@ export class ImportService {
}) })
]); ]);
const accounts = orders.map((order) => { const accounts = orders
return order.Account; .filter(({ Account }) => {
return !!Account;
})
.map(({ Account }) => {
return Account;
}); });
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined; const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
return Object.entries(dividends).map(([dateString, { marketPrice }]) => { return await Promise.all(
Object.entries(dividends).map(async ([dateString, { marketPrice }]) => {
const quantity = const quantity =
historicalData.find((historicalDataItem) => { historicalData.find((historicalDataItem) => {
return historicalDataItem.date === dateString; return historicalDataItem.date === dateString;
@ -128,13 +130,16 @@ export class ImportService {
unitPrice: marketPrice, unitPrice: marketPrice,
updatedAt: undefined, updatedAt: undefined,
userId: Account?.userId, userId: Account?.userId,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
value, value,
assetProfile.currency, assetProfile.currency,
userCurrency userCurrency,
date
) )
}; };
}); })
);
} catch { } catch {
return []; return [];
} }
@ -295,6 +300,7 @@ export class ImportService {
figi, figi,
figiComposite, figiComposite,
figiShareClass, figiShareClass,
holdings,
id, id,
isin, isin,
name, name,
@ -367,6 +373,7 @@ export class ImportService {
figi, figi,
figiComposite, figiComposite,
figiShareClass, figiShareClass,
holdings,
id, id,
isin, isin,
name, name,
@ -429,17 +436,20 @@ export class ImportService {
...order, ...order,
error, error,
value, value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency( feeInBaseCurrency: await this.exchangeRateDataService.toCurrencyAtDate(
fee, fee,
assetProfile.currency, assetProfile.currency,
userCurrency userCurrency,
date
), ),
// @ts-ignore // @ts-ignore
SymbolProfile: assetProfile, SymbolProfile: assetProfile,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
value, value,
assetProfile.currency, assetProfile.currency,
userCurrency userCurrency,
date
) )
}); });
} }
@ -538,6 +548,7 @@ export class ImportService {
assetSubClass: undefined, assetSubClass: undefined,
countries: undefined, countries: undefined,
createdAt: undefined, createdAt: undefined,
holdings: undefined,
id: undefined, id: undefined,
sectors: undefined, sectors: undefined,
updatedAt: undefined updatedAt: undefined

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

@ -7,7 +7,6 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration/conf
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { TagModule } from '@ghostfolio/api/services/tag/tag.module';

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

@ -1,6 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -17,7 +17,7 @@ export class LogoService {
public async getLogoByDataSourceAndSymbol({ public async getLogoByDataSourceAndSymbol({
dataSource, dataSource,
symbol symbol
}: UniqueAsset) { }: AssetProfileIdentifier) {
if (!DataSource[dataSource]) { if (!DataSource[dataSource]) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),

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

@ -1,3 +1,6 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -10,12 +13,12 @@ import {
IsArray, IsArray,
IsBoolean, IsBoolean,
IsEnum, IsEnum,
IsISO4217CurrencyCode,
IsISO8601, IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
Min Min,
Validate
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
@ -39,10 +42,10 @@ export class CreateOrderDto {
) )
comment?: string; comment?: string;
@IsISO4217CurrencyCode() @IsCurrencyCode()
currency: string; currency: string;
@IsISO4217CurrencyCode() @IsCurrencyCode()
@IsOptional() @IsOptional()
customCurrency?: string; customCurrency?: string;
@ -51,6 +54,7 @@ export class CreateOrderDto {
dataSource?: DataSource; dataSource?: DataSource;
@IsISO8601() @IsISO8601()
@Validate(IsAfter1970Constraint)
date: string; date: string;
@IsNumber() @IsNumber()

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

@ -1,12 +1,12 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
HEADER_KEY_IMPERSONATION HEADER_KEY_IMPERSONATION
@ -36,7 +36,7 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateOrderDto } from './create-order.dto'; import { CreateOrderDto } from './create-order.dto';
import { Activities } from './interfaces/activities.interface'; import { Activities, Activity } from './interfaces/activities.interface';
import { OrderService } from './order.service'; import { OrderService } from './order.service';
import { UpdateOrderDto } from './update-order.dto'; import { UpdateOrderDto } from './update-order.dto';
@ -66,7 +66,6 @@ export class OrderController {
return this.orderService.deleteOrders({ return this.orderService.deleteOrders({
filters, filters,
userCurrency: this.request.user.Settings.settings.baseCurrency,
userId: this.request.user.id userId: this.request.user.id
}); });
} }
@ -111,7 +110,7 @@ export class OrderController {
let startDate: Date; let startDate: Date;
if (dateRange) { if (dateRange) {
({ endDate, startDate } = getInterval(dateRange)); ({ endDate, startDate } = getIntervalFromDateRange(dateRange));
} }
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
@ -141,6 +140,38 @@ export class OrderController {
return { activities, count }; return { activities, count };
} }
@Get(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getOrderById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Param('id') id: string
): Promise<Activity> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const { activities } = await this.orderService.getOrders({
userCurrency,
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true
});
const activity = activities.find((activity) => {
return activity.id === id;
});
if (!activity) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return activity;
}
@HasPermission(permissions.createOrder) @HasPermission(permissions.createOrder)
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)

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

@ -11,7 +11,11 @@ import {
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter
} from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -43,6 +47,39 @@ export class OrderService {
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public async assignTags({
dataSource,
symbol,
tags,
userId
}: { tags: Tag[]; userId: string } & AssetProfileIdentifier) {
const orders = await this.prismaService.order.findMany({
where: {
userId,
SymbolProfile: {
dataSource,
symbol
}
}
});
return Promise.all(
orders.map(({ id }) =>
this.prismaService.order.update({
data: {
tags: {
// The set operation replaces all existing connections with the provided ones
set: tags.map(({ id }) => {
return { id };
})
}
},
where: { id }
})
)
);
}
public async createOrder( public async createOrder(
data: Prisma.OrderCreateInput & { data: Prisma.OrderCreateInput & {
accountId?: string; accountId?: string;
@ -181,7 +218,15 @@ export class OrderService {
where where
}); });
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type)) { const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesByIds([
order.symbolProfileId
]);
if (
['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type) ||
symbolProfile.activitiesCount === 0
) {
await this.symbolProfileService.deleteById(order.symbolProfileId); await this.symbolProfileService.deleteById(order.symbolProfileId);
} }
@ -197,18 +242,16 @@ export class OrderService {
public async deleteOrders({ public async deleteOrders({
filters, filters,
userCurrency,
userId userId
}: { }: {
filters?: Filter[]; filters?: Filter[];
userCurrency: string;
userId: string; userId: string;
}): Promise<number> { }): Promise<number> {
const { activities } = await this.getOrders({ const { activities } = await this.getOrders({
filters, filters,
userId, userId,
userCurrency,
includeDrafts: true, includeDrafts: true,
userCurrency: undefined,
withExcludedAccounts: true withExcludedAccounts: true
}); });
@ -222,6 +265,19 @@ export class OrderService {
} }
}); });
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesByIds(
activities.map(({ symbolProfileId }) => {
return symbolProfileId;
})
);
for (const { activitiesCount, id } of symbolProfiles) {
if (activitiesCount === 0) {
await this.symbolProfileService.deleteById(id);
}
}
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ userId }) new PortfolioChangedEvent({ userId })
@ -230,7 +286,7 @@ export class OrderService {
return count; return count;
} }
public async getLatestOrder({ dataSource, symbol }: UniqueAsset) { public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.order.findFirst({ return this.prismaService.order.findFirst({
orderBy: { orderBy: {
date: 'desc' date: 'desc'
@ -270,7 +326,8 @@ export class OrderService {
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<Activities> { }): Promise<Activities> {
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [ let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
{ date: 'asc' } { date: 'asc' },
{ id: 'asc' }
]; ];
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
@ -290,10 +347,14 @@ export class OrderService {
ACCOUNT: filtersByAccount, ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass, ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag TAG: filtersByTag
} = groupBy(filters, (filter) => { } = groupBy(filters, ({ type }) => {
return filter.type; return type;
}); });
const searchQuery = filters?.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
if (filtersByAccount?.length > 0) { if (filtersByAccount?.length > 0) {
where.accountId = { where.accountId = {
in: filtersByAccount.map(({ id }) => { in: filtersByAccount.map(({ id }) => {
@ -335,6 +396,30 @@ export class OrderService {
}; };
} }
if (searchQuery) {
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [
{ id: { mode: 'insensitive', startsWith: searchQuery } },
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
];
if (where.SymbolProfile) {
where.SymbolProfile = {
AND: [
where.SymbolProfile,
{
OR: searchQueryWhereInput
}
]
};
} else {
where.SymbolProfile = {
OR: searchQueryWhereInput
};
}
}
if (filtersByTag?.length > 0) { if (filtersByTag?.length > 0) {
where.AND = [ where.AND = [
{ {
@ -367,7 +452,7 @@ export class OrderService {
} }
if (sortColumn) { if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }, { id: sortDirection }];
} }
if (types) { if (types) {
@ -406,7 +491,7 @@ export class OrderService {
this.prismaService.order.count({ where }) this.prismaService.order.count({ where })
]); ]);
const uniqueAssets = uniqBy( const assetProfileIdentifiers = uniqBy(
orders.map(({ SymbolProfile }) => { orders.map(({ SymbolProfile }) => {
return { return {
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
@ -421,10 +506,12 @@ export class OrderService {
} }
); );
const assetProfiles = const assetProfiles = await this.symbolProfileService.getSymbolProfiles(
await this.symbolProfileService.getSymbolProfiles(uniqueAssets); assetProfileIdentifiers
);
const activities = orders.map((order) => { const activities = await Promise.all(
orders.map(async (order) => {
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => { const assetProfile = assetProfiles.find(({ dataSource, symbol }) => {
return ( return (
dataSource === order.SymbolProfile.dataSource && dataSource === order.SymbolProfile.dataSource &&
@ -437,25 +524,65 @@ export class OrderService {
return { return {
...order, ...order,
value, value,
// TODO: Use exchange rate of date feeInBaseCurrency:
feeInBaseCurrency: this.exchangeRateDataService.toCurrency( await this.exchangeRateDataService.toCurrencyAtDate(
order.fee, order.fee,
order.SymbolProfile.currency, order.SymbolProfile.currency,
userCurrency userCurrency,
order.date
), ),
SymbolProfile: assetProfile, SymbolProfile: assetProfile,
// TODO: Use exchange rate of date valueInBaseCurrency:
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( await this.exchangeRateDataService.toCurrencyAtDate(
value, value,
order.SymbolProfile.currency, order.SymbolProfile.currency,
userCurrency userCurrency,
order.date
) )
}; };
}); })
);
return { activities, count }; return { activities, count };
} }
public async getOrdersForPortfolioCalculator({
filters,
userCurrency,
userId
}: {
filters?: Filter[];
userCurrency: string;
userId: string;
}) {
return this.getOrders({
filters,
userCurrency,
userId,
withExcludedAccounts: false // TODO
});
}
public async getStatisticsByCurrency(
currency: EnhancedSymbolProfile['currency']
): Promise<{
activitiesCount: EnhancedSymbolProfile['activitiesCount'];
dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
}> {
const { _count, _min } = await this.prismaService.order.aggregate({
_count: true,
_min: {
date: true
},
where: { SymbolProfile: { currency } }
});
return {
activitiesCount: _count as number,
dateOfFirstActivity: _min.date
};
}
public async order( public async order(
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
): Promise<Order | null> { ): Promise<Order | null> {

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

@ -1,3 +1,6 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -9,12 +12,12 @@ import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsArray, IsArray,
IsEnum, IsEnum,
IsISO4217CurrencyCode,
IsISO8601, IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
Min Min,
Validate
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
@ -38,10 +41,10 @@ export class UpdateOrderDto {
) )
comment?: string; comment?: string;
@IsISO4217CurrencyCode() @IsCurrencyCode()
currency: string; currency: string;
@IsISO4217CurrencyCode() @IsCurrencyCode()
@IsOptional() @IsOptional()
customCurrency?: string; customCurrency?: string;
@ -49,6 +52,7 @@ export class UpdateOrderDto {
dataSource: DataSource; dataSource: DataSource;
@IsISO8601() @IsISO8601()
@Validate(IsAfter1970Constraint)
date: string; date: string;
@IsNumber() @IsNumber()

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

@ -2,16 +2,13 @@ import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
getFactor,
getInterval
} from '@ghostfolio/api/helper/portfolio.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MAX_CHART_ITEMS } from '@ghostfolio/common/config'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { Inject, Logger } from '@nestjs/common'; import { Inject, Logger } from '@nestjs/common';
@ -43,21 +40,19 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator {
configurationService, configurationService,
currency, currency,
currentRateService, currentRateService,
dateRange,
exchangeRateDataService, exchangeRateDataService,
redisCacheService, redisCacheService,
useCache, userId,
userId filters
}: { }: {
accountBalanceItems: HistoricalDataItem[]; accountBalanceItems: HistoricalDataItem[];
activities: Activity[]; activities: Activity[];
configurationService: ConfigurationService; configurationService: ConfigurationService;
currency: string; currency: string;
currentRateService: CurrentRateService; currentRateService: CurrentRateService;
dateRange: DateRange;
exchangeRateDataService: ExchangeRateDataService; exchangeRateDataService: ExchangeRateDataService;
redisCacheService: RedisCacheService; redisCacheService: RedisCacheService;
useCache: boolean; filters: Filter[];
userId: string; userId: string;
}, },
@Inject() @Inject()
@ -68,11 +63,10 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator {
activities, activities,
configurationService, configurationService,
currency, currency,
filters,
currentRateService, currentRateService,
dateRange,
exchangeRateDataService, exchangeRateDataService,
redisCacheService, redisCacheService,
useCache,
userId userId
}); });
} }
@ -87,23 +81,31 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator {
withDataDecimation?: boolean; withDataDecimation?: boolean;
withTimeWeightedReturn?: boolean; withTimeWeightedReturn?: boolean;
}): Promise<HistoricalDataItem[]> { }): Promise<HistoricalDataItem[]> {
const { endDate, startDate } = getInterval(dateRange, this.getStartDate()); const { endDate, startDate } = getIntervalFromDateRange(
dateRange,
this.getStartDate()
);
const daysInMarket = differenceInDays(endDate, startDate) + 1; const daysInMarket = differenceInDays(endDate, startDate) + 1;
const step = withDataDecimation const step = withDataDecimation
? Math.round(daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS)) ? Math.round(
daysInMarket /
Math.min(
daysInMarket,
this.configurationService.get('MAX_CHART_ITEMS')
)
)
: 1; : 1;
let item = super.getChartData({ let item = await super.getPerformance({
step,
end: endDate, end: endDate,
start: startDate start: startDate
}); });
if (!withTimeWeightedReturn) { if (!withTimeWeightedReturn) {
return item; return item.chart;
} else { } else {
let itemResult = await item; let itemResult = item.chart;
let dates = itemResult.map((item) => parseDate(item.date)); let dates = itemResult.map((item) => parseDate(item.date));
let timeWeighted = await this.getTimeWeightedChartData({ let timeWeighted = await this.getTimeWeightedChartData({
dates dates
@ -145,13 +147,14 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator {
const end = new Date(Date.now()); const end = new Date(Date.now());
const holdings = await this.getHoldings(orders, parseDate(start), end); const holdings = await this.getHoldings(orders, parseDate(start), end);
const marketMap = await this.currentRateService.getToday( const marketMap = await this.currentRateService.getValues({
this.mapToDataGatheringItems(orders) dataGatheringItems: this.mapToDataGatheringItems(orders),
); dateQuery: { in: [end] }
});
const endString = format(end, DATE_FORMAT); const endString = format(end, DATE_FORMAT);
let exchangeRates = await Promise.all( let exchangeRates = await Promise.all(
Object.keys(holdings[endString]).map(async (holding) => { Object.keys(holdings[endString]).map(async (holding) => {
let symbol = marketMap.find((m) => m.symbol === holding); let symbol = marketMap.values.find((m) => m.symbol === holding);
let symbolCurrency = this.getCurrencyFromActivities(orders, holding); let symbolCurrency = this.getCurrencyFromActivities(orders, holding);
let exchangeRate = await this.exchangeRateDataService.toCurrencyAtDate( let exchangeRate = await this.exchangeRateDataService.toCurrencyAtDate(
1, 1,
@ -175,7 +178,7 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator {
if (!holdings[endString][holding].toNumber()) { if (!holdings[endString][holding].toNumber()) {
return sum; return sum;
} }
let symbol = marketMap.find((m) => m.symbol === holding); let symbol = marketMap.values.find((m) => m.symbol === holding);
if (symbol?.marketPrice === undefined) { if (symbol?.marketPrice === undefined) {
Logger.warn( Logger.warn(

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

@ -1,5 +1,8 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
export class MWRPortfolioCalculator extends PortfolioCalculator { export class MWRPortfolioCalculator extends PortfolioCalculator {
@ -13,7 +16,6 @@ export class MWRPortfolioCalculator extends PortfolioCalculator {
dataSource, dataSource,
end, end,
exchangeRates, exchangeRates,
isChartMode = false,
marketSymbolMap, marketSymbolMap,
start, start,
step = 1, step = 1,
@ -21,13 +23,12 @@ export class MWRPortfolioCalculator extends PortfolioCalculator {
}: { }: {
end: Date; end: Date;
exchangeRates: { [dateString: string]: number }; exchangeRates: { [dateString: string]: number };
isChartMode?: boolean;
marketSymbolMap: { marketSymbolMap: {
[date: string]: { [symbol: string]: Big }; [date: string]: { [symbol: string]: Big };
}; };
start: Date; start: Date;
step?: number; step?: number;
} & UniqueAsset): SymbolMetrics { } & AssetProfileIdentifier): SymbolMetrics {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
} }

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

@ -20,6 +20,7 @@ export const symbolProfileDummyData = {
assetSubClass: undefined, assetSubClass: undefined,
countries: [], countries: [],
createdAt: undefined, createdAt: undefined,
holdings: [],
id: undefined, id: undefined,
sectors: [], sectors: [],
updatedAt: undefined updatedAt: undefined

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

@ -4,8 +4,7 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -37,30 +36,23 @@ export class PortfolioCalculatorFactory {
activities, activities,
calculationType, calculationType,
currency, currency,
dateRange = 'max', filters = [],
hasFilters,
isExperimentalFeatures = false,
userId userId
}: { }: {
accountBalanceItems?: HistoricalDataItem[]; accountBalanceItems?: HistoricalDataItem[];
activities: Activity[]; activities: Activity[];
calculationType: PerformanceCalculationType; calculationType: PerformanceCalculationType;
currency: string; currency: string;
dateRange?: DateRange; filters?: Filter[];
hasFilters: boolean;
isExperimentalFeatures?: boolean;
userId: string; userId: string;
}): PortfolioCalculator { }): PortfolioCalculator {
const useCache = !hasFilters && isExperimentalFeatures;
switch (calculationType) { switch (calculationType) {
case PerformanceCalculationType.MWR: case PerformanceCalculationType.MWR:
return new MWRPortfolioCalculator({ return new MWRPortfolioCalculator({
accountBalanceItems, accountBalanceItems,
activities, activities,
currency, currency,
dateRange, filters,
useCache,
userId, userId,
configurationService: this.configurationService, configurationService: this.configurationService,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
@ -74,12 +66,11 @@ export class PortfolioCalculatorFactory {
activities, activities,
currency, currency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
dateRange,
useCache,
userId, userId,
configurationService: this.configurationService, configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService, exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService redisCacheService: this.redisCacheService,
filters
}, },
this.orderservice this.orderservice
); );
@ -90,12 +81,11 @@ export class PortfolioCalculatorFactory {
activities, activities,
currency, currency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
dateRange,
useCache,
userId, userId,
configurationService: this.configurationService, configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService, exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService redisCacheService: this.redisCacheService,
filters
}, },
this.orderservice this.orderservice
); );

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

File diff suppressed because it is too large

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

@ -17,6 +17,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -68,9 +69,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => { describe('get current positions', () => {
it.only('with BALN.SW buy and sell in two activities', async () => { it.only('with BALN.SW buy and sell in two activities', async () => {
const spy = jest jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const activities: Activity[] = [ const activities: Activity[] = [
{ {
@ -124,43 +123,22 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF', currency: 'CHF',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });
const chartData = await portfolioCalculator.getChartData({ const portfolioSnapshot = await portfolioCalculator.getSnapshot();
start: parseDate('2021-11-22')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-22')
);
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData, data: portfolioSnapshot.historicalData,
groupBy: 'month' groupBy: 'month'
}); });
spy.mockRestore(); expect(portfolioSnapshot).toMatchObject({
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'), currentValueInBaseCurrency: new Big('0'),
errors: [], errors: [],
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.04408677396780965649'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
hasErrors: false, hasErrors: false,
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.05528341497550734703'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.05528341497550734703'
),
netPerformanceWithCurrencyEffect: new Big('-15.8'),
positions: [ positions: [
{ {
averagePrice: new Big('0'), averagePrice: new Big('0'),
@ -169,6 +147,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'), fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22', firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'), grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'), grossPerformancePercentage: new Big('-0.04408677396780965649'),
@ -178,12 +157,12 @@ describe('PortfolioCalculator', () => {
grossPerformanceWithCurrencyEffect: new Big('-12.6'), grossPerformanceWithCurrencyEffect: new Big('-12.6'),
investment: new Big('0'), investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'), investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('-15.8'), netPerformancePercentageWithCurrencyEffectMap: {
netPerformancePercentage: new Big('-0.05528341497550734703'), max: new Big('-0.0552834149755073478')
netPerformancePercentageWithCurrencyEffect: new Big( },
'-0.05528341497550734703' netPerformanceWithCurrencyEffectMap: {
), max: new Big('-15.8')
netPerformanceWithCurrencyEffect: new Big('-15.8'), },
marketPrice: 148.9, marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9, marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'), quantity: new Big('0'),
@ -205,6 +184,16 @@ describe('PortfolioCalculator', () => {
totalValuablesWithCurrencyEffect: new Big('0') totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
netPerformance: -15.8,
netPerformanceInPercentage: -0.05528341497550734703,
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703,
netPerformanceWithCurrencyEffect: -15.8,
totalInvestmentValueWithCurrencyEffect: 0
})
);
expect(investments).toEqual([ expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') }, { date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('0') } { date: '2021-11-30', investment: new Big('0') }

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

@ -17,6 +17,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -68,9 +69,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => { describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => { it.only('with BALN.SW buy and sell', async () => {
const spy = jest jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const activities: Activity[] = [ const activities: Activity[] = [
{ {
@ -109,43 +108,22 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF', currency: 'CHF',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });
const chartData = await portfolioCalculator.getChartData({ const portfolioSnapshot = await portfolioCalculator.getSnapshot();
start: parseDate('2021-11-22')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-22')
);
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData, data: portfolioSnapshot.historicalData,
groupBy: 'month' groupBy: 'month'
}); });
spy.mockRestore(); expect(portfolioSnapshot).toMatchObject({
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'), currentValueInBaseCurrency: new Big('0'),
errors: [], errors: [],
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.0440867739678096571'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
hasErrors: false, hasErrors: false,
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.0552834149755073478'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.0552834149755073478'
),
netPerformanceWithCurrencyEffect: new Big('-15.8'),
positions: [ positions: [
{ {
averagePrice: new Big('0'), averagePrice: new Big('0'),
@ -154,6 +132,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'), fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22', firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'), grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'), grossPerformancePercentage: new Big('-0.0440867739678096571'),
@ -165,10 +144,12 @@ describe('PortfolioCalculator', () => {
investmentWithCurrencyEffect: new Big('0'), investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('-15.8'), netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.0552834149755073478'), netPerformancePercentage: new Big('-0.0552834149755073478'),
netPerformancePercentageWithCurrencyEffect: new Big( netPerformancePercentageWithCurrencyEffectMap: {
'-0.0552834149755073478' max: new Big('-0.0552834149755073478')
), },
netPerformanceWithCurrencyEffect: new Big('-15.8'), netPerformanceWithCurrencyEffectMap: {
max: new Big('-15.8')
},
marketPrice: 148.9, marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9, marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'), quantity: new Big('0'),
@ -188,6 +169,16 @@ describe('PortfolioCalculator', () => {
totalValuablesWithCurrencyEffect: new Big('0') totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
netPerformance: -15.8,
netPerformanceInPercentage: -0.05528341497550734703,
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703,
netPerformanceWithCurrencyEffect: -15.8,
totalInvestmentValueWithCurrencyEffect: 0
})
);
expect(investments).toEqual([ expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') }, { date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('0') } { date: '2021-11-30', investment: new Big('0') }

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

@ -17,6 +17,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -68,9 +69,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => { describe('get current positions', () => {
it.only('with BALN.SW buy', async () => { it.only('with BALN.SW buy', async () => {
const spy = jest jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const activities: Activity[] = [ const activities: Activity[] = [
{ {
@ -94,43 +93,22 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF', currency: 'CHF',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });
const chartData = await portfolioCalculator.getChartData({ const portfolioSnapshot = await portfolioCalculator.getSnapshot();
start: parseDate('2021-11-30')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-30')
);
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData, data: portfolioSnapshot.historicalData,
groupBy: 'month' groupBy: 'month'
}); });
spy.mockRestore(); expect(portfolioSnapshot).toMatchObject({
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('297.8'), currentValueInBaseCurrency: new Big('297.8'),
errors: [], errors: [],
grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.09004392386530014641'
),
grossPerformanceWithCurrencyEffect: new Big('24.6'),
hasErrors: false, hasErrors: false,
netPerformance: new Big('23.05'),
netPerformancePercentage: new Big('0.08437042459736456808'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.08437042459736456808'
),
netPerformanceWithCurrencyEffect: new Big('23.05'),
positions: [ positions: [
{ {
averagePrice: new Big('136.6'), averagePrice: new Big('136.6'),
@ -139,6 +117,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('1.55'), fee: new Big('1.55'),
feeInBaseCurrency: new Big('1.55'),
firstBuyDate: '2021-11-30', firstBuyDate: '2021-11-30',
grossPerformance: new Big('24.6'), grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'), grossPerformancePercentage: new Big('0.09004392386530014641'),
@ -150,10 +129,18 @@ describe('PortfolioCalculator', () => {
investmentWithCurrencyEffect: new Big('273.2'), investmentWithCurrencyEffect: new Big('273.2'),
netPerformance: new Big('23.05'), netPerformance: new Big('23.05'),
netPerformancePercentage: new Big('0.08437042459736456808'), netPerformancePercentage: new Big('0.08437042459736456808'),
netPerformancePercentageWithCurrencyEffect: new Big( netPerformancePercentageWithCurrencyEffectMap: {
'0.08437042459736456808' max: new Big('0.08437042459736456808')
), },
netPerformanceWithCurrencyEffect: new Big('23.05'), netPerformanceWithCurrencyEffectMap: {
'1d': new Big('10.00'), // 2 * (148.9 - 143.9) -> no fees in this time period
'1y': new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55
'5y': new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55
max: new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55
mtd: new Big('24.60'), // 2 * (148.9 - 136.6) -> no fees in this time period
wtd: new Big('13.80'), // 2 * (148.9 - 142.0) -> no fees in this time period
ytd: new Big('23.05') // 2 * (148.9 - 136.6) - 1.55
},
marketPrice: 148.9, marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9, marketPriceInBaseCurrency: 148.9,
quantity: new Big('2'), quantity: new Big('2'),
@ -173,6 +160,16 @@ describe('PortfolioCalculator', () => {
totalValuablesWithCurrencyEffect: new Big('0') totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
netPerformance: 23.05,
netPerformanceInPercentage: 0.08437042459736457,
netPerformanceInPercentageWithCurrencyEffect: 0.08437042459736457,
netPerformanceWithCurrencyEffect: 23.05,
totalInvestmentValueWithCurrencyEffect: 273.2
})
);
expect(investments).toEqual([ expect(investments).toEqual([
{ date: '2021-11-30', investment: new Big('273.2') } { date: '2021-11-30', investment: new Big('273.2') }
]); ]);

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

@ -18,6 +18,7 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -79,11 +80,10 @@ describe('PortfolioCalculator', () => {
); );
}); });
describe('get current positions', () => { // TODO
describe.skip('get current positions', () => {
it.only('with BTCUSD buy and sell partially', async () => { it.only('with BTCUSD buy and sell partially', async () => {
const spy = jest jest.useFakeTimers().setSystemTime(parseDate('2018-01-01').getTime());
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2018-01-01').getTime());
const activities: Activity[] = [ const activities: Activity[] = [
{ {
@ -122,43 +122,23 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF', currency: 'CHF',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });
const chartData = await portfolioCalculator.getChartData({ const portfolioSnapshot = await portfolioCalculator.getSnapshot();
start: parseDate('2015-01-01')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2015-01-01')
);
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData, data: portfolioSnapshot.historicalData,
groupBy: 'month' groupBy: 'month'
}); });
spy.mockRestore(); expect(portfolioSnapshot).toMatchObject({
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('13298.425356'), currentValueInBaseCurrency: new Big('13298.425356'),
errors: [], errors: [],
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.41978276196153750666'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'41.6401219622042072686'
),
grossPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'), grossPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'),
hasErrors: false, hasErrors: false,
netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.41978276196153750666'),
netPerformancePercentageWithCurrencyEffect: new Big(
'41.6401219622042072686'
),
netPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'),
positions: [ positions: [
{ {
averagePrice: new Big('320.43'), averagePrice: new Big('320.43'),
@ -167,33 +147,34 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('0'), fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2015-01-01', firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'), grossPerformance: new Big('27172.74').mul(0.97373),
grossPerformancePercentage: new Big('42.41978276196153750666'), grossPerformancePercentage: new Big('0.4241983590271396608571'),
grossPerformancePercentageWithCurrencyEffect: new Big( grossPerformancePercentageWithCurrencyEffect: new Big(
'41.6401219622042072686' '0.4164017412624815597008'
), ),
grossPerformanceWithCurrencyEffect: new Big( grossPerformanceWithCurrencyEffect: new Big(
'26516.208701400000064086' '26516.208701400000064086'
), ),
investment: new Big('320.43'), investment: new Big('320.43').mul(0.97373),
investmentWithCurrencyEffect: new Big('318.542667299999967957'), investmentWithCurrencyEffect: new Big('318.542667299999967957'),
marketPrice: 13657.2, marketPrice: 13657.2,
marketPriceInBaseCurrency: 13298.425356, marketPriceInBaseCurrency: 13298.425356,
netPerformance: new Big('27172.74'), netPerformance: new Big('27172.74').mul(0.97373),
netPerformancePercentage: new Big('42.41978276196153750666'), netPerformancePercentage: new Big('0.4241983590271396608571'),
netPerformancePercentageWithCurrencyEffect: new Big( netPerformancePercentageWithCurrencyEffectMap: {
'41.6401219622042072686' max: new Big('0.417188277288666871633')
), },
netPerformanceWithCurrencyEffect: new Big( netPerformanceWithCurrencyEffectMap: {
'26516.208701400000064086' max: new Big('26516.208701400000064086')
), },
quantity: new Big('1'), quantity: new Big('1'),
symbol: 'BTCUSD', symbol: 'BTCUSD',
tags: [], tags: [],
timeWeightedInvestment: new Big('640.56763686131386861314'), timeWeightedInvestment: new Big('623.73914366102470265325'),
timeWeightedInvestmentWithCurrencyEffect: new Big( timeWeightedInvestmentWithCurrencyEffect: new Big(
'636.79469348020066587024' '636.79389574611155533947'
), ),
transactionCount: 2, transactionCount: 2,
valueInBaseCurrency: new Big('13298.425356') valueInBaseCurrency: new Big('13298.425356')
@ -201,12 +182,22 @@ describe('PortfolioCalculator', () => {
], ],
totalFeesWithCurrencyEffect: new Big('0'), totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('320.43'), totalInvestment: new Big('320.43').mul(0.97373),
totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957'), totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0') totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
netPerformance: new Big('27172.74').mul(0.97373).toNumber(),
netPerformanceInPercentage: 42.41983590271396609433,
netPerformanceInPercentageWithCurrencyEffect: 41.64017412624815597854,
netPerformanceWithCurrencyEffect: 26516.208701400000064086,
totalInvestmentValueWithCurrencyEffect: 318.542667299999967957
})
);
expect(investments).toEqual([ expect(investments).toEqual([
{ date: '2015-01-01', investment: new Big('640.86') }, { date: '2015-01-01', investment: new Big('640.86') },
{ date: '2017-12-31', investment: new Big('320.43') } { date: '2017-12-31', investment: new Big('320.43') }

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

@ -17,6 +17,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -68,9 +69,7 @@ describe('PortfolioCalculator', () => {
describe('compute portfolio snapshot', () => { describe('compute portfolio snapshot', () => {
it.only('with fee activity', async () => { it.only('with fee activity', async () => {
const spy = jest jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const activities: Activity[] = [ const activities: Activity[] = [
{ {
@ -94,28 +93,15 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'USD', currency: 'USD',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });
const portfolioSnapshot = await portfolioCalculator.computeSnapshot( const portfolioSnapshot = await portfolioCalculator.getSnapshot();
parseDate('2021-11-30')
);
spy.mockRestore();
expect(portfolioSnapshot).toEqual({ expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'), currentValueInBaseCurrency: new Big('0'),
errors: [], errors: [],
grossPerformance: new Big('0'),
grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('0'),
hasErrors: true, hasErrors: true,
netPerformance: new Big('0'),
netPerformancePercentage: new Big('0'),
netPerformancePercentageWithCurrencyEffect: new Big('0'),
netPerformanceWithCurrencyEffect: new Big('0'),
positions: [ positions: [
{ {
averagePrice: new Big('0'), averagePrice: new Big('0'),
@ -124,6 +110,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('49'), fee: new Big('49'),
feeInBaseCurrency: new Big('49'),
firstBuyDate: '2021-09-01', firstBuyDate: '2021-09-01',
grossPerformance: null, grossPerformance: null,
grossPerformancePercentage: null, grossPerformancePercentage: null,
@ -135,8 +122,8 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 0, marketPriceInBaseCurrency: 0,
netPerformance: null, netPerformance: null,
netPerformancePercentage: null, netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffect: null, netPerformancePercentageWithCurrencyEffectMap: null,
netPerformanceWithCurrencyEffect: null, netPerformanceWithCurrencyEffectMap: null,
quantity: new Big('0'), quantity: new Big('0'),
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141', symbol: '2c463fb3-af07-486e-adb0-8301b3d72141',
tags: [], tags: [],
@ -153,6 +140,16 @@ describe('PortfolioCalculator', () => {
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0') totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
totalInvestmentValueWithCurrencyEffect: 0
})
);
}); });
}); });
}); });

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

@ -18,6 +18,7 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -81,9 +82,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => { describe('get current positions', () => {
it.only('with GOOGL buy', async () => { it.only('with GOOGL buy', async () => {
const spy = jest jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime());
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2023-07-10').getTime());
const activities: Activity[] = [ const activities: Activity[] = [
{ {
@ -107,43 +106,22 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF', currency: 'CHF',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });
const chartData = await portfolioCalculator.getChartData({ const portfolioSnapshot = await portfolioCalculator.getSnapshot();
start: parseDate('2023-01-03')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2023-01-03')
);
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData, data: portfolioSnapshot.historicalData,
groupBy: 'month' groupBy: 'month'
}); });
spy.mockRestore(); expect(portfolioSnapshot).toMatchObject({
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('103.10483'), currentValueInBaseCurrency: new Big('103.10483'),
errors: [], errors: [],
grossPerformance: new Big('27.33'),
grossPerformancePercentage: new Big('0.3066651705565529623'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.25235044599563974109'
),
grossPerformanceWithCurrencyEffect: new Big('20.775774'),
hasErrors: false, hasErrors: false,
netPerformance: new Big('26.33'),
netPerformancePercentage: new Big('0.29544434470377019749'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.24112962014285697628'
),
netPerformanceWithCurrencyEffect: new Big('19.851974'),
positions: [ positions: [
{ {
averagePrice: new Big('89.12'), averagePrice: new Big('89.12'),
@ -152,40 +130,53 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('1'), fee: new Big('1'),
feeInBaseCurrency: new Big('0.9238'),
firstBuyDate: '2023-01-03', firstBuyDate: '2023-01-03',
grossPerformance: new Big('27.33'), grossPerformance: new Big('27.33').mul(0.8854),
grossPerformancePercentage: new Big('0.3066651705565529623'), grossPerformancePercentage: new Big('0.3066651705565529623'),
grossPerformancePercentageWithCurrencyEffect: new Big( grossPerformancePercentageWithCurrencyEffect: new Big(
'0.25235044599563974109' '0.25235044599563974109'
), ),
grossPerformanceWithCurrencyEffect: new Big('20.775774'), grossPerformanceWithCurrencyEffect: new Big('20.775774'),
investment: new Big('89.12'), investment: new Big('89.12').mul(0.8854),
investmentWithCurrencyEffect: new Big('82.329056'), investmentWithCurrencyEffect: new Big('82.329056'),
netPerformance: new Big('26.33'), netPerformance: new Big('26.33').mul(0.8854),
netPerformancePercentage: new Big('0.29544434470377019749'), netPerformancePercentage: new Big('0.29544434470377019749'),
netPerformancePercentageWithCurrencyEffect: new Big( netPerformancePercentageWithCurrencyEffectMap: {
'0.24112962014285697628' max: new Big('0.24112962014285697628')
), },
netPerformanceWithCurrencyEffect: new Big('19.851974'), netPerformanceWithCurrencyEffectMap: {
max: new Big('19.851974')
},
marketPrice: 116.45, marketPrice: 116.45,
marketPriceInBaseCurrency: 103.10483, marketPriceInBaseCurrency: 103.10483,
quantity: new Big('1'), quantity: new Big('1'),
symbol: 'GOOGL', symbol: 'GOOGL',
tags: [], tags: [],
timeWeightedInvestment: new Big('89.12'), timeWeightedInvestment: new Big('89.12').mul(0.8854),
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'), timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
transactionCount: 1, transactionCount: 1,
valueInBaseCurrency: new Big('103.10483') valueInBaseCurrency: new Big('103.10483')
} }
], ],
totalFeesWithCurrencyEffect: new Big('1'), totalFeesWithCurrencyEffect: new Big('0.9238'),
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('89.12'), totalInvestment: new Big('89.12').mul(0.8854),
totalInvestmentWithCurrencyEffect: new Big('82.329056'), totalInvestmentWithCurrencyEffect: new Big('82.329056'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0') totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
netPerformance: new Big('26.33').mul(0.8854).toNumber(),
netPerformanceInPercentage: 0.29544434470377019749,
netPerformanceInPercentageWithCurrencyEffect: 0.24112962014285697628,
netPerformanceWithCurrencyEffect: 19.851974,
totalInvestmentValueWithCurrencyEffect: 82.329056
})
);
expect(investments).toEqual([ expect(investments).toEqual([
{ date: '2023-01-03', investment: new Big('89.12') } { date: '2023-01-03', investment: new Big('89.12') }
]); ]);

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

@ -17,6 +17,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -68,9 +69,7 @@ describe('PortfolioCalculator', () => {
describe('compute portfolio snapshot', () => { describe('compute portfolio snapshot', () => {
it.only('with item activity', async () => { it.only('with item activity', async () => {
const spy = jest jest.useFakeTimers().setSystemTime(parseDate('2022-01-31').getTime());
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-01-31').getTime());
const activities: Activity[] = [ const activities: Activity[] = [
{ {
@ -94,28 +93,15 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'USD', currency: 'USD',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });
const portfolioSnapshot = await portfolioCalculator.computeSnapshot( const portfolioSnapshot = await portfolioCalculator.getSnapshot();
parseDate('2022-01-01')
);
spy.mockRestore();
expect(portfolioSnapshot).toEqual({ expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'), currentValueInBaseCurrency: new Big('0'),
errors: [], errors: [],
grossPerformance: new Big('0'),
grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('0'),
hasErrors: true, hasErrors: true,
netPerformance: new Big('0'),
netPerformancePercentage: new Big('0'),
netPerformancePercentageWithCurrencyEffect: new Big('0'),
netPerformanceWithCurrencyEffect: new Big('0'),
positions: [ positions: [
{ {
averagePrice: new Big('500000'), averagePrice: new Big('500000'),
@ -124,6 +110,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('0'), fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-01-01', firstBuyDate: '2022-01-01',
grossPerformance: null, grossPerformance: null,
grossPerformancePercentage: null, grossPerformancePercentage: null,
@ -135,8 +122,8 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 500000, marketPriceInBaseCurrency: 500000,
netPerformance: null, netPerformance: null,
netPerformancePercentage: null, netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffect: null, netPerformancePercentageWithCurrencyEffectMap: null,
netPerformanceWithCurrencyEffect: null, netPerformanceWithCurrencyEffectMap: null,
quantity: new Big('0'), quantity: new Big('0'),
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde', symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde',
tags: [], tags: [],
@ -153,6 +140,16 @@ describe('PortfolioCalculator', () => {
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0') totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
totalInvestmentValueWithCurrencyEffect: 0
})
);
}); });
}); });
}); });

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

@ -68,9 +68,7 @@ describe('PortfolioCalculator', () => {
describe('compute portfolio snapshot', () => { describe('compute portfolio snapshot', () => {
it.only('with liability activity', async () => { it.only('with liability activity', async () => {
const spy = jest jest.useFakeTimers().setSystemTime(parseDate('2022-01-31').getTime());
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-01-31').getTime());
const activities: Activity[] = [ const activities: Activity[] = [
{ {
@ -94,12 +92,9 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'USD', currency: 'USD',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });
spy.mockRestore();
const liabilitiesInBaseCurrency = const liabilitiesInBaseCurrency =
await portfolioCalculator.getLiabilitiesInBaseCurrency(); await portfolioCalculator.getLiabilitiesInBaseCurrency();

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

@ -18,6 +18,7 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -81,9 +82,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => { describe('get current positions', () => {
it.only('with MSFT buy', async () => { it.only('with MSFT buy', async () => {
const spy = jest jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime());
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2023-07-10').getTime());
const activities: Activity[] = [ const activities: Activity[] = [
{ {
@ -122,15 +121,10 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'USD', currency: 'USD',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });
const portfolioSnapshot = await portfolioCalculator.computeSnapshot( const portfolioSnapshot = await portfolioCalculator.getSnapshot();
parseDate('2023-07-10')
);
spy.mockRestore();
expect(portfolioSnapshot).toMatchObject({ expect(portfolioSnapshot).toMatchObject({
errors: [], errors: [],
@ -161,6 +155,12 @@ describe('PortfolioCalculator', () => {
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0') totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
totalInvestmentValueWithCurrencyEffect: 298.58
})
);
}); });
}); });
}); });

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

@ -13,6 +13,7 @@ import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { subDays } from 'date-fns'; import { subDays } from 'date-fns';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -64,45 +65,28 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => { describe('get current positions', () => {
it('with no orders', async () => { it('with no orders', async () => {
const spy = jest jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const portfolioCalculator = factory.createCalculator({ const portfolioCalculator = factory.createCalculator({
activities: [], activities: [],
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF', currency: 'CHF',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });
const start = subDays(new Date(Date.now()), 10); const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const chartData = await portfolioCalculator.getChartData({ start });
const portfolioSnapshot =
await portfolioCalculator.computeSnapshot(start);
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData, data: portfolioSnapshot.historicalData,
groupBy: 'month' groupBy: 'month'
}); });
spy.mockRestore(); expect(portfolioSnapshot).toMatchObject({
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big(0), currentValueInBaseCurrency: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false, hasErrors: false,
netPerformance: new Big(0), historicalData: [],
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffect: new Big(0),
netPerformanceWithCurrencyEffect: new Big(0),
positions: [], positions: [],
totalFeesWithCurrencyEffect: new Big('0'), totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
@ -114,12 +98,7 @@ describe('PortfolioCalculator', () => {
expect(investments).toEqual([]); expect(investments).toEqual([]);
expect(investmentsByMonth).toEqual([ expect(investmentsByMonth).toEqual([]);
{
date: '2021-12-01',
investment: 0
}
]);
}); });
}); });
}); });

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

@ -17,6 +17,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -68,9 +69,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => { describe('get current positions', () => {
it.only('with NOVN.SW buy and sell partially', async () => { it.only('with NOVN.SW buy and sell partially', async () => {
const spy = jest jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
const activities: Activity[] = [ const activities: Activity[] = [
{ {
@ -109,43 +108,22 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF', currency: 'CHF',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });
const chartData = await portfolioCalculator.getChartData({ const portfolioSnapshot = await portfolioCalculator.getSnapshot();
start: parseDate('2022-03-07')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-03-07')
);
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData, data: portfolioSnapshot.historicalData,
groupBy: 'month' groupBy: 'month'
}); });
spy.mockRestore(); expect(portfolioSnapshot).toMatchObject({
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('87.8'), currentValueInBaseCurrency: new Big('87.8'),
errors: [], errors: [],
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.15113417083448194384'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.15113417083448194384'
),
grossPerformanceWithCurrencyEffect: new Big('21.93'),
hasErrors: false, hasErrors: false,
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.12184460284330327256'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.12184460284330327256'
),
netPerformanceWithCurrencyEffect: new Big('17.68'),
positions: [ positions: [
{ {
averagePrice: new Big('75.80'), averagePrice: new Big('75.80'),
@ -154,6 +132,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('4.25'), fee: new Big('4.25'),
feeInBaseCurrency: new Big('4.25'),
firstBuyDate: '2022-03-07', firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'), grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.15113417083448194384'), grossPerformancePercentage: new Big('0.15113417083448194384'),
@ -165,10 +144,12 @@ describe('PortfolioCalculator', () => {
investmentWithCurrencyEffect: new Big('75.80'), investmentWithCurrencyEffect: new Big('75.80'),
netPerformance: new Big('17.68'), netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.12184460284330327256'), netPerformancePercentage: new Big('0.12184460284330327256'),
netPerformancePercentageWithCurrencyEffect: new Big( netPerformancePercentageWithCurrencyEffectMap: {
'0.12184460284330327256' max: new Big('0.12348284960422163588')
), },
netPerformanceWithCurrencyEffect: new Big('17.68'), netPerformanceWithCurrencyEffectMap: {
max: new Big('17.68')
},
marketPrice: 87.8, marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8, marketPriceInBaseCurrency: 87.8,
quantity: new Big('1'), quantity: new Big('1'),
@ -190,6 +171,16 @@ describe('PortfolioCalculator', () => {
totalValuablesWithCurrencyEffect: new Big('0') totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
netPerformance: 17.68,
netPerformanceInPercentage: 0.12184460284330327256,
netPerformanceInPercentageWithCurrencyEffect: 0.12184460284330327256,
netPerformanceWithCurrencyEffect: 17.68,
totalInvestmentValueWithCurrencyEffect: 75.8
})
);
expect(investments).toEqual([ expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') }, { date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('75.8') } { date: '2022-04-08', investment: new Big('75.8') }

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

@ -17,6 +17,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -68,9 +69,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => { describe('get current positions', () => {
it.only('with NOVN.SW buy and sell', async () => { it.only('with NOVN.SW buy and sell', async () => {
const spy = jest jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
const activities: Activity[] = [ const activities: Activity[] = [
{ {
@ -109,28 +108,34 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF', currency: 'CHF',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });
const chartData = await portfolioCalculator.getChartData({ const portfolioSnapshot = await portfolioCalculator.getSnapshot();
start: parseDate('2022-03-07')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-03-07')
);
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData, data: portfolioSnapshot.historicalData,
groupBy: 'month' groupBy: 'month'
}); });
spy.mockRestore(); expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2022-03-06',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
expect(chartData[0]).toEqual({ expect(portfolioSnapshot.historicalData[1]).toEqual({
date: '2022-03-07', date: '2022-03-07',
investmentValueWithCurrencyEffect: 151.6, investmentValueWithCurrencyEffect: 151.6,
netPerformance: 0, netPerformance: 0,
@ -145,12 +150,16 @@ describe('PortfolioCalculator', () => {
valueWithCurrencyEffect: 151.6 valueWithCurrencyEffect: 151.6
}); });
expect(chartData[chartData.length - 1]).toEqual({ expect(
portfolioSnapshot.historicalData[
portfolioSnapshot.historicalData.length - 1
]
).toEqual({
date: '2022-04-11', date: '2022-04-11',
investmentValueWithCurrencyEffect: 0, investmentValueWithCurrencyEffect: 0,
netPerformance: 19.86, netPerformance: 19.86,
netPerformanceInPercentage: 13.100263852242744, netPerformanceInPercentage: 0.13100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744, netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
netPerformanceWithCurrencyEffect: 19.86, netPerformanceWithCurrencyEffect: 19.86,
netWorth: 0, netWorth: 0,
totalAccountBalance: 0, totalAccountBalance: 0,
@ -160,22 +169,10 @@ describe('PortfolioCalculator', () => {
valueWithCurrencyEffect: 0 valueWithCurrencyEffect: 0
}); });
expect(portfolioSnapshot).toEqual({ expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'), currentValueInBaseCurrency: new Big('0'),
errors: [], errors: [],
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.13100263852242744063'
),
grossPerformanceWithCurrencyEffect: new Big('19.86'),
hasErrors: false, hasErrors: false,
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.13100263852242744063'
),
netPerformanceWithCurrencyEffect: new Big('19.86'),
positions: [ positions: [
{ {
averagePrice: new Big('0'), averagePrice: new Big('0'),
@ -184,6 +181,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('0'), fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-03-07', firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'), grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'), grossPerformancePercentage: new Big('0.13100263852242744063'),
@ -195,10 +193,12 @@ describe('PortfolioCalculator', () => {
investmentWithCurrencyEffect: new Big('0'), investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('19.86'), netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'), netPerformancePercentage: new Big('0.13100263852242744063'),
netPerformancePercentageWithCurrencyEffect: new Big( netPerformancePercentageWithCurrencyEffectMap: {
'0.13100263852242744063' max: new Big('0.13100263852242744063')
), },
netPerformanceWithCurrencyEffect: new Big('19.86'), netPerformanceWithCurrencyEffectMap: {
max: new Big('19.86')
},
marketPrice: 87.8, marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8, marketPriceInBaseCurrency: 87.8,
quantity: new Big('0'), quantity: new Big('0'),
@ -218,6 +218,16 @@ describe('PortfolioCalculator', () => {
totalValuablesWithCurrencyEffect: new Big('0') totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect.objectContaining({
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744063,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063,
netPerformanceWithCurrencyEffect: 19.86,
totalInvestmentValueWithCurrencyEffect: 0
})
);
expect(investments).toEqual([ expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') }, { date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('0') } { date: '2022-04-08', investment: new Big('0') }

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

File diff suppressed because it is too large

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

@ -1,6 +1,12 @@
import { parseDate, resetHours } from '@ghostfolio/common/helper'; import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns'; import {
addDays,
eachDayOfInterval,
endOfDay,
isBefore,
isSameDay
} from 'date-fns';
import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValuesObject } from './interfaces/get-values-object.interface'; import { GetValuesObject } from './interfaces/get-values-object.interface';
@ -24,6 +30,10 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 139.9 }; return { marketPrice: 139.9 };
} else if (isSameDay(parseDate('2021-11-30'), date)) { } else if (isSameDay(parseDate('2021-11-30'), date)) {
return { marketPrice: 136.6 }; return { marketPrice: 136.6 };
} else if (isSameDay(parseDate('2021-12-12'), date)) {
return { marketPrice: 142.0 };
} else if (isSameDay(parseDate('2021-12-17'), date)) {
return { marketPrice: 143.9 };
} else if (isSameDay(parseDate('2021-12-18'), date)) { } else if (isSameDay(parseDate('2021-12-18'), date)) {
return { marketPrice: 148.9 }; return { marketPrice: 148.9 };
} }
@ -97,7 +107,10 @@ export const CurrentRateServiceMock = {
} }
} }
} else { } else {
for (const date of dateQuery.in) { for (const date of eachDayOfInterval({
end: dateQuery.lt,
start: dateQuery.gte
})) {
for (const dataGatheringItem of dataGatheringItems) { for (const dataGatheringItem of dataGatheringItems) {
values.push({ values.push({
date, date,

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

@ -1,7 +1,7 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
@ -24,32 +24,32 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
}); });
}, },
getRange: ({ getRange: ({
assetProfileIdentifiers,
dateRangeEnd, dateRangeEnd,
dateRangeStart, dateRangeStart
uniqueAssets
}: { }: {
assetProfileIdentifiers: AssetProfileIdentifier[];
dateRangeEnd: Date; dateRangeEnd: Date;
dateRangeStart: Date; dateRangeStart: Date;
uniqueAssets: UniqueAsset[];
}) => { }) => {
return Promise.resolve<MarketData[]>([ return Promise.resolve<MarketData[]>([
{ {
createdAt: dateRangeStart, createdAt: dateRangeStart,
dataSource: uniqueAssets[0].dataSource, dataSource: assetProfileIdentifiers[0].dataSource,
date: dateRangeStart, date: dateRangeStart,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d', id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: 1841.823902, marketPrice: 1841.823902,
state: 'CLOSE', state: 'CLOSE',
symbol: uniqueAssets[0].symbol symbol: assetProfileIdentifiers[0].symbol
}, },
{ {
createdAt: dateRangeEnd, createdAt: dateRangeEnd,
dataSource: uniqueAssets[0].dataSource, dataSource: assetProfileIdentifiers[0].dataSource,
date: dateRangeEnd, date: dateRangeEnd,
id: '082d6893-df27-4c91-8a5d-092e84315b56', id: '082d6893-df27-4c91-8a5d-092e84315b56',
marketPrice: 1847.839966, marketPrice: 1847.839966,
state: 'CLOSE', state: 'CLOSE',
symbol: uniqueAssets[0].symbol symbol: assetProfileIdentifiers[0].symbol
} }
]); ]);
} }

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

@ -1,13 +1,11 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { DateQueryHelper } from '@ghostfolio/api/helper/dateQueryHelper';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier,
DataProviderInfo, DataProviderInfo,
ResponseError, ResponseError
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -22,8 +20,6 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
@Injectable() @Injectable()
export class CurrentRateService { export class CurrentRateService {
private dateQueryHelper = new DateQueryHelper();
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@ -42,33 +38,58 @@ export class CurrentRateService {
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) && (!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) && (!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
(!dateQuery.in || this.containsToday(dateQuery.in)); (!dateQuery.in || this.containsToday(dateQuery.in));
let { query, dates } = this.dateQueryHelper.handleDateQueryIn(dateQuery);
const promises: Promise<GetValueObject[]>[] = []; const promises: Promise<GetValueObject[]>[] = [];
const quoteErrors: ResponseError['errors'] = []; const quoteErrors: ResponseError['errors'] = [];
const today = resetHours(new Date()); const today = resetHours(new Date());
if (includesToday) { if (includesToday) {
promises.push( promises.push(
this.getTodayPrivate( this.dataProviderService
dataGatheringItems, .getQuotes({ items: dataGatheringItems, user: this.request?.user })
dataProviderInfos, .then((dataResultProvider) => {
today, const result: GetValueObject[] = [];
quoteErrors
) for (const dataGatheringItem of dataGatheringItems) {
if (
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
) {
dataProviderInfos.push(
dataResultProvider[dataGatheringItem.symbol].dataProviderInfo
); );
} }
const uniqueAssets: UniqueAsset[] = dataGatheringItems.map( if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
({ dataSource, symbol }) => { result.push({
return { dataSource, symbol }; dataSource: dataGatheringItem.dataSource,
date: today,
marketPrice:
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice,
symbol: dataGatheringItem.symbol
});
} else {
quoteErrors.push({
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
});
} }
}
return result;
})
); );
}
const assetProfileIdentifiers: AssetProfileIdentifier[] =
dataGatheringItems.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
});
promises.push( promises.push(
this.marketDataService this.marketDataService
.getRange({ .getRange({
dateQuery: query, assetProfileIdentifiers,
uniqueAssets dateQuery
}) })
.then((data) => { .then((data) => {
return data.map(({ dataSource, date, marketPrice, symbol }) => { return data.map(({ dataSource, date, marketPrice, symbol }) => {
@ -89,12 +110,9 @@ export class CurrentRateService {
errors: quoteErrors.map(({ dataSource, symbol }) => { errors: quoteErrors.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
}), }),
values: uniqBy(values, ({ date, symbol }) => `${date}-${symbol}`).filter( values: uniqBy(values, ({ date, symbol }) => `${date}-${symbol}`)
(v) =>
dates?.length === 0 ||
dates.some((d: Date) => d.getTime() === v.date.getTime())
)
}; };
if (!isEmpty(quoteErrors)) { if (!isEmpty(quoteErrors)) {
for (const { dataSource, symbol } of quoteErrors) { for (const { dataSource, symbol } of quoteErrors) {
try { try {
@ -144,61 +162,6 @@ export class CurrentRateService {
return response; return response;
} }
public async getToday(
dataGatheringItems: IDataGatheringItem[]
): Promise<GetValueObject[]> {
const dataProviderInfos: DataProviderInfo[] = [];
const quoteErrors: UniqueAsset[] = [];
const today = resetHours(new Date());
return this.getTodayPrivate(
dataGatheringItems,
dataProviderInfos,
today,
quoteErrors
);
}
private async getTodayPrivate(
dataGatheringItems: IDataGatheringItem[],
dataProviderInfos: DataProviderInfo[],
today: Date,
quoteErrors: UniqueAsset[]
): Promise<GetValueObject[]> {
return this.dataProviderService
.getQuotes({ items: dataGatheringItems, user: this.request?.user })
.then((dataResultProvider) => {
const result: GetValueObject[] = [];
for (const dataGatheringItem of dataGatheringItems) {
if (
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
) {
dataProviderInfos.push(
dataResultProvider[dataGatheringItem.symbol].dataProviderInfo
);
}
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
result.push({
dataSource: dataGatheringItem.dataSource,
date: today,
marketPrice:
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice,
symbol: dataGatheringItem.symbol
});
} else {
quoteErrors.push({
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
});
}
}
return result;
});
}
private containsToday(dates: Date[]): boolean { private containsToday(dates: Date[]): boolean {
for (const date of dates) { for (const date of dates) {
if (isToday(date)) { if (isToday(date)) {

4
apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts

@ -1,6 +1,6 @@
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
export interface GetValueObject extends UniqueAsset { export interface GetValueObject extends AssetProfileIdentifier {
date: Date; date: Date;
marketPrice: number; marketPrice: number;
} }

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

@ -6,6 +6,7 @@ export interface PortfolioOrderItem extends PortfolioOrder {
feeInBaseCurrency?: Big; feeInBaseCurrency?: Big;
feeInBaseCurrencyWithCurrencyEffect?: Big; feeInBaseCurrencyWithCurrencyEffect?: Big;
itemType?: 'end' | 'start'; itemType?: 'end' | 'start';
unitPriceFromMarketData?: Big;
unitPriceInBaseCurrency?: Big; unitPriceInBaseCurrency?: Big;
unitPriceInBaseCurrencyWithCurrencyEffect?: Big; unitPriceInBaseCurrencyWithCurrencyEffect?: Big;
} }

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

@ -2,12 +2,12 @@ import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
import { AccessService } from '@ghostfolio/api/app/access/access.service'; import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
nullifyValuesInObject nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper'; } from '@ghostfolio/api/helper/object.helper';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
@ -15,6 +15,7 @@ import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
HEADER_KEY_IMPERSONATION HEADER_KEY_IMPERSONATION
@ -30,7 +31,8 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { import {
hasReadRestrictedAccessPermission, hasReadRestrictedAccessPermission,
isRestrictedView isRestrictedView,
permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import type { import type {
DateRange, DateRange,
@ -39,12 +41,14 @@ import type {
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { import {
Body,
Controller, Controller,
Get, Get,
Headers, Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
Put,
Query, Query,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
@ -52,12 +56,13 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AssetClass, AssetSubClass } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface'; import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@Controller('portfolio') @Controller('portfolio')
export class PortfolioController { export class PortfolioController {
@ -224,6 +229,7 @@ export class PortfolioController {
: undefined, : undefined,
countries: hasDetails ? portfolioPosition.countries : [], countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined, currency: hasDetails ? portfolioPosition.currency : undefined,
holdings: hasDetails ? portfolioPosition.holdings : [],
markets: hasDetails ? portfolioPosition.markets : undefined, markets: hasDetails ? portfolioPosition.markets : undefined,
marketsAdvanced: hasDetails marketsAdvanced: hasDetails
? portfolioPosition.marketsAdvanced ? portfolioPosition.marketsAdvanced
@ -261,7 +267,7 @@ export class PortfolioController {
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.Settings.settings.baseCurrency;
const { endDate, startDate } = getInterval(dateRange); const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const { activities } = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
endDate, endDate,
@ -517,9 +523,6 @@ export class PortfolioController {
@Param('accessId') accessId @Param('accessId') accessId
): Promise<PortfolioPublicDetails> { ): Promise<PortfolioPublicDetails> {
const access = await this.accessService.access({ id: accessId }); const access = await this.accessService.access({ id: accessId });
const user = await this.userService.user({
id: access.userId
});
if (!access) { if (!access) {
throw new HttpException( throw new HttpException(
@ -529,6 +532,11 @@ export class PortfolioController {
} }
let hasDetails = true; let hasDetails = true;
const user = await this.userService.user({
id: access.userId
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = user.subscription.type === 'Premium'; hasDetails = user.subscription.type === 'Premium';
} }
@ -585,25 +593,25 @@ export class PortfolioController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPosition( public async getPosition(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol @Param('symbol') symbol: string
): Promise<PortfolioHoldingDetail> { ): Promise<PortfolioHoldingDetail> {
const position = await this.portfolioService.getPosition( const holding = await this.portfolioService.getPosition(
dataSource, dataSource,
impersonationId, impersonationId,
symbol symbol
); );
if (position) { if (!holding) {
return position;
}
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND StatusCodes.NOT_FOUND
); );
} }
return holding;
}
@Get('report') @Get('report')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getReport( public async getReport(
@ -624,4 +632,36 @@ export class PortfolioController {
return report; return report;
} }
@HasPermission(permissions.updateOrder)
@Put('position/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateHoldingTags(
@Body() data: UpdateHoldingTagsDto,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
const holding = await this.portfolioService.getPosition(
dataSource,
impersonationId,
symbol
);
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
await this.portfolioService.updateTags({
dataSource,
impersonationId,
symbol,
tags: data.tags,
userId: this.request.user.id
});
}
} }

78
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -1,78 +0,0 @@
import { Big } from 'big.js';
import { PortfolioService } from './portfolio.service';
describe('PortfolioService', () => {
let portfolioService: PortfolioService;
beforeAll(async () => {
portfolioService = new PortfolioService(
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
);
});
describe('annualized performance percentage', () => {
it('Get annualized performance', async () => {
expect(
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
netPerformancePercentage: new Big(0)
})
.toNumber()
).toEqual(0);
expect(
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 0,
netPerformancePercentage: new Big(0)
})
.toNumber()
).toEqual(0);
/**
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
*/
expect(
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 65, // < 1 year
netPerformancePercentage: new Big(0.1025)
})
.toNumber()
).toBeCloseTo(0.729705);
expect(
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 365, // 1 year
netPerformancePercentage: new Big(0.05)
})
.toNumber()
).toBeCloseTo(0.05);
/**
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
*/
expect(
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 575, // > 1 year
netPerformancePercentage: new Big(0.2374)
})
.toNumber()
).toBeCloseTo(0.145);
});
});
});

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

@ -5,10 +5,7 @@ import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
getFactor,
getInterval
} from '@ghostfolio/api/helper/portfolio.helper';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
@ -20,6 +17,10 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
getAnnualizedPerformancePercent,
getIntervalFromDateRange
} from '@ghostfolio/common/calculation-helper';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
EMERGENCY_FUND_TAG_ID, EMERGENCY_FUND_TAG_ID,
@ -60,19 +61,21 @@ import {
DataSource, DataSource,
Order, Order,
Platform, Platform,
Prisma Prisma,
Tag
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { import {
differenceInDays, differenceInDays,
format, format,
isAfter, isAfter,
isBefore,
isSameMonth, isSameMonth,
isSameYear, isSameYear,
parseISO, parseISO,
set set
} from 'date-fns'; } from 'date-fns';
import { isEmpty, isNumber, last, uniq, uniqBy } from 'lodash'; import { isEmpty, last, uniq, uniqBy } from 'lodash';
import { CPRPortfolioCalculator } from './calculator/constantPortfolioReturn/portfolio-calculator'; import { CPRPortfolioCalculator } from './calculator/constantPortfolioReturn/portfolio-calculator';
import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator';
@ -211,25 +214,6 @@ export class PortfolioService {
}; };
} }
@LogPerformance
public getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercentage
}: {
daysInMarket: number;
netPerformancePercentage: Big;
}): Big {
if (isNumber(daysInMarket) && daysInMarket > 0) {
const exponent = new Big(365).div(daysInMarket).toNumber();
return new Big(
Math.pow(netPerformancePercentage.plus(1).toNumber(), exponent)
).minus(1);
}
return new Big(0);
}
@LogPerformance @LogPerformance
public async getDividends({ public async getDividends({
activities, activities,
@ -268,11 +252,12 @@ export class PortfolioService {
}): Promise<PortfolioInvestments> { }): Promise<PortfolioInvestments> {
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const { activities } = await this.orderService.getOrders({ const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
filters, filters,
userId, userId,
includeDrafts: true,
types: ['BUY', 'SELL'],
userCurrency: this.getUserCurrency() userCurrency: this.getUserCurrency()
}); });
@ -285,18 +270,16 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
activities, activities,
dateRange, filters,
userId, userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency, currency: this.request.user.Settings.settings.baseCurrency
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
}); });
const items = await portfolioCalculator.getChart({ const { historicalData } = await portfolioCalculator.getSnapshot();
dateRange,
withDataDecimation: false const items = historicalData.filter(({ date }) => {
return !isBefore(date, startDate) && !isAfter(date, endDate);
}); });
let investments: InvestmentItem[]; let investments: InvestmentItem[];
@ -356,22 +339,19 @@ export class PortfolioService {
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
); );
const { activities } = await this.orderService.getOrders({ const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
filters, filters,
userCurrency, userCurrency,
userId, userId
withExcludedAccounts
}); });
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
activities, activities,
dateRange, filters,
userId, userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: userCurrency, currency: userCurrency
hasFilters: true, // disable cache
isExperimentalFeatures:
this.request.user?.Settings.settings.isExperimentalFeatures
}); });
const { currentValueInBaseCurrency, hasErrors, positions } = const { currentValueInBaseCurrency, hasErrors, positions } =
@ -425,10 +405,8 @@ export class PortfolioService {
}; };
}); });
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const symbolProfiles =
this.dataProviderService.getQuotes({ user, items: dataGatheringItems }), await this.symbolProfileService.getSymbolProfiles(dataGatheringItems);
this.symbolProfileService.getSymbolProfiles(dataGatheringItems)
]);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
for (const symbolProfile of symbolProfiles) { for (const symbolProfile of symbolProfiles) {
@ -452,8 +430,8 @@ export class PortfolioService {
marketPrice, marketPrice,
netPerformance, netPerformance,
netPerformancePercentage, netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect, netPerformancePercentageWithCurrencyEffectMap,
netPerformanceWithCurrencyEffect, netPerformanceWithCurrencyEffectMap,
quantity, quantity,
symbol, symbol,
tags, tags,
@ -473,7 +451,6 @@ export class PortfolioService {
} }
const assetProfile = symbolProfileMap[symbol]; const assetProfile = symbolProfileMap[symbol];
const dataProviderResponse = dataProviderResponses[symbol];
let markets: PortfolioPosition['markets']; let markets: PortfolioPosition['markets'];
let marketsAdvanced: PortfolioPosition['marketsAdvanced']; let marketsAdvanced: PortfolioPosition['marketsAdvanced'];
@ -508,15 +485,27 @@ export class PortfolioService {
grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0, grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
grossPerformanceWithCurrencyEffect: grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect?.toNumber() ?? 0, grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
holdings: assetProfile.holdings.map(
({ allocationInPercentage, name }) => {
return {
allocationInPercentage,
name,
valueInBaseCurrency: valueInBaseCurrency
.mul(allocationInPercentage)
.toNumber()
};
}
),
investment: investment.toNumber(), investment: investment.toNumber(),
marketState: dataProviderResponse?.marketState ?? 'delayed',
name: assetProfile.name, name: assetProfile.name,
netPerformance: netPerformance?.toNumber() ?? 0, netPerformance: netPerformance?.toNumber() ?? 0,
netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0, netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0,
netPerformancePercentWithCurrencyEffect: netPerformancePercentWithCurrencyEffect:
netPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0, netPerformancePercentageWithCurrencyEffectMap?.[
dateRange
]?.toNumber() ?? 0,
netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffect?.toNumber() ?? 0, netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0,
quantity: quantity.toNumber(), quantity: quantity.toNumber(),
sectors: assetProfile.sectors, sectors: assetProfile.sectors,
url: assetProfile.url, url: assetProfile.url,
@ -585,7 +574,6 @@ export class PortfolioService {
if (withSummary) { if (withSummary) {
summary = await this.getSummary({ summary = await this.getSummary({
filteredValueInBaseCurrency, filteredValueInBaseCurrency,
holdings,
impersonationId, impersonationId,
portfolioCalculator, portfolioCalculator,
userCurrency, userCurrency,
@ -690,10 +678,10 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const { activities } = await this.orderService.getOrders({ const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
userCurrency, userCurrency,
userId, userId
withExcludedAccounts: true
}); });
const orders = activities.filter(({ SymbolProfile }) => { const orders = activities.filter(({ SymbolProfile }) => {
@ -748,10 +736,7 @@ export class PortfolioService {
); );
}), }),
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: userCurrency, currency: userCurrency
hasFilters: true,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
}); });
const portfolioStart = portfolioCalculator.getStartDate(); const portfolioStart = portfolioCalculator.getStartDate();
@ -788,7 +773,7 @@ export class PortfolioService {
return Account; return Account;
}); });
const dividendYieldPercent = this.getAnnualizedPerformancePercent({ const dividendYieldPercent = getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercentage: timeWeightedInvestment.eq(0) netPerformancePercentage: timeWeightedInvestment.eq(0)
? new Big(0) ? new Big(0)
@ -796,7 +781,7 @@ export class PortfolioService {
}); });
const dividendYieldPercentWithCurrencyEffect = const dividendYieldPercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({ getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq( netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
0 0
@ -928,9 +913,11 @@ export class PortfolioService {
netPerformance: position.netPerformance?.toNumber(), netPerformance: position.netPerformance?.toNumber(),
netPerformancePercent: position.netPerformancePercentage?.toNumber(), netPerformancePercent: position.netPerformancePercentage?.toNumber(),
netPerformancePercentWithCurrencyEffect: netPerformancePercentWithCurrencyEffect:
position.netPerformancePercentageWithCurrencyEffect?.toNumber(), position.netPerformancePercentageWithCurrencyEffectMap?.[
'max'
]?.toNumber(),
netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffect:
position.netPerformanceWithCurrencyEffect?.toNumber(), position.netPerformanceWithCurrencyEffectMap?.['max']?.toNumber(),
quantity: quantity.toNumber(), quantity: quantity.toNumber(),
value: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice ?? 0).toNumber(), quantity.mul(marketPrice ?? 0).toNumber(),
@ -1033,10 +1020,8 @@ export class PortfolioService {
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const { endDate } = getInterval(dateRange); const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
const { activities } = await this.orderService.getOrders({
endDate,
filters, filters,
userId, userId,
userCurrency: this.getUserCurrency() userCurrency: this.getUserCurrency()
@ -1051,13 +1036,10 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
activities, activities,
dateRange, filters,
userId, userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency, currency: this.request.user.Settings.settings.baseCurrency
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
}); });
let { hasErrors, positions } = await portfolioCalculator.getSnapshot(); let { hasErrors, positions } = await portfolioCalculator.getSnapshot();
@ -1116,8 +1098,8 @@ export class PortfolioService {
investmentWithCurrencyEffect, investmentWithCurrencyEffect,
netPerformance, netPerformance,
netPerformancePercentage, netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect, netPerformancePercentageWithCurrencyEffectMap,
netPerformanceWithCurrencyEffect, netPerformanceWithCurrencyEffectMap,
quantity, quantity,
symbol, symbol,
timeWeightedInvestment, timeWeightedInvestment,
@ -1151,9 +1133,12 @@ export class PortfolioService {
netPerformancePercentage: netPerformancePercentage:
netPerformancePercentage?.toNumber() ?? null, netPerformancePercentage?.toNumber() ?? null,
netPerformancePercentageWithCurrencyEffect: netPerformancePercentageWithCurrencyEffect:
netPerformancePercentageWithCurrencyEffect?.toNumber() ?? null, netPerformancePercentageWithCurrencyEffectMap?.[
dateRange
]?.toNumber() ?? null,
netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffect?.toNumber() ?? null, netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ??
null,
quantity: quantity.toNumber(), quantity: quantity.toNumber(),
timeWeightedInvestment: timeWeightedInvestment?.toNumber(), timeWeightedInvestment: timeWeightedInvestment?.toNumber(),
timeWeightedInvestmentWithCurrencyEffect: timeWeightedInvestmentWithCurrencyEffect:
@ -1169,6 +1154,7 @@ export class PortfolioService {
dateRange = 'max', dateRange = 'max',
filters, filters,
impersonationId, impersonationId,
portfolioCalculator,
userId, userId,
withExcludedAccounts = false, withExcludedAccounts = false,
calculateTimeWeightedPerformance = false calculateTimeWeightedPerformance = false
@ -1176,6 +1162,7 @@ export class PortfolioService {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string; impersonationId: string;
portfolioCalculator?: PortfolioCalculator;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
calculateTimeWeightedPerformance?: boolean; calculateTimeWeightedPerformance?: boolean;
@ -1214,14 +1201,11 @@ export class PortfolioService {
) )
); );
const { endDate } = getInterval(dateRange); const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
const { activities } = await this.orderService.getOrders({
endDate,
filters, filters,
userCurrency, userCurrency,
userId, userId
withExcludedAccounts
}); });
if (accountBalanceItems?.length <= 0 && activities?.length <= 0) { if (accountBalanceItems?.length <= 0 && activities?.length <= 0) {
@ -1232,10 +1216,6 @@ export class PortfolioService {
performance: { performance: {
currentNetWorth: 0, currentNetWorth: 0,
currentValueInBaseCurrency: 0, currentValueInBaseCurrency: 0,
grossPerformance: 0,
grossPerformancePercentage: 0,
grossPerformancePercentageWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
netPerformance: 0, netPerformance: 0,
netPerformancePercentage: 0, netPerformancePercentage: 0,
netPerformancePercentageWithCurrencyEffect: 0, netPerformancePercentageWithCurrencyEffect: 0,
@ -1245,93 +1225,47 @@ export class PortfolioService {
}; };
} }
const portfolioCalculator = this.calculatorFactory.createCalculator({ const { endDate, startDate } = getIntervalFromDateRange(dateRange);
accountBalanceItems,
activities, const { chart } = await portfolioCalculator.getPerformance({
dateRange, end: endDate,
userId, start: startDate
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
}); });
const { const {
currentValueInBaseCurrency,
errors,
grossPerformance,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
grossPerformanceWithCurrencyEffect,
hasErrors,
netPerformance, netPerformance,
netPerformancePercentage, netPerformanceInPercentage,
netPerformancePercentageWithCurrencyEffect, netPerformanceInPercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect, netPerformanceWithCurrencyEffect,
totalInvestment netWorth,
} = await portfolioCalculator.getSnapshot(); totalInvestment,
valueWithCurrencyEffect
let currentNetPerformance = netPerformance; } =
chart?.length > 0
let currentNetPerformancePercentage = netPerformancePercentage; ? last(chart)
: {
let currentNetPerformancePercentageWithCurrencyEffect = netPerformance: 0,
netPerformancePercentageWithCurrencyEffect; netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
let currentNetPerformanceWithCurrencyEffect = netPerformanceWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect; netWorth: 0,
totalInvestment: 0,
let currentNetWorth = 0; valueWithCurrencyEffect: 0
};
let items = await portfolioCalculator.getChart({
dateRange,
withTimeWeightedReturn: calculateTimeWeightedPerformance
});
const itemOfToday = items.find(({ date }) => {
return date === format(new Date(), DATE_FORMAT);
});
if (itemOfToday) {
currentNetPerformance = new Big(itemOfToday.netPerformance);
currentNetPerformancePercentage = new Big(
itemOfToday.netPerformanceInPercentage
).div(100);
currentNetPerformancePercentageWithCurrencyEffect = new Big(
itemOfToday.netPerformanceInPercentageWithCurrencyEffect
).div(100);
currentNetPerformanceWithCurrencyEffect = new Big(
itemOfToday.netPerformanceWithCurrencyEffect
);
currentNetWorth = itemOfToday.netWorth;
}
return { return {
errors, chart,
hasErrors, hasErrors: false,
chart: items, firstOrderDate: parseDate(chart[0]?.date),
firstOrderDate: parseDate(items[0]?.date),
performance: { performance: {
currentNetWorth, netPerformance,
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(), netPerformanceWithCurrencyEffect,
grossPerformance: grossPerformance.toNumber(), totalInvestment,
grossPerformancePercentage: grossPerformancePercentage.toNumber(), currentNetWorth: netWorth,
grossPerformancePercentageWithCurrencyEffect: currentValueInBaseCurrency: valueWithCurrencyEffect,
grossPerformancePercentageWithCurrencyEffect.toNumber(), netPerformancePercentage: netPerformanceInPercentage,
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect.toNumber(),
netPerformance: currentNetPerformance.toNumber(),
netPerformancePercentage: currentNetPerformancePercentage.toNumber(),
netPerformancePercentageWithCurrencyEffect: netPerformancePercentageWithCurrencyEffect:
currentNetPerformancePercentageWithCurrencyEffect.toNumber(), netPerformanceInPercentageWithCurrencyEffect
netPerformanceWithCurrencyEffect:
currentNetPerformanceWithCurrencyEffect.toNumber(),
totalInvestment: totalInvestment.toNumber()
} }
}; };
} }
@ -1342,7 +1276,8 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const { activities } = await this.orderService.getOrders({ const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
userCurrency, userCurrency,
userId userId
}); });
@ -1351,10 +1286,7 @@ export class PortfolioService {
activities, activities,
userId, userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency, currency: this.request.user.Settings.settings.baseCurrency
hasFilters: false,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
}); });
let { totalFeesWithCurrencyEffect, positions, totalInvestment } = let { totalFeesWithCurrencyEffect, positions, totalInvestment } =
@ -1432,6 +1364,25 @@ export class PortfolioService {
}; };
} }
@LogPerformance
public async updateTags({
dataSource,
impersonationId,
symbol,
tags,
userId
}: {
dataSource: DataSource;
impersonationId: string;
symbol: string;
tags: Tag[];
userId: string;
}) {
userId = await this.getUserId(impersonationId, userId);
await this.orderService.assignTags({ dataSource, symbol, tags, userId });
}
@LogPerformance @LogPerformance
private async getCashPositions({ private async getCashPositions({
cashDetails, cashDetails,
@ -1589,9 +1540,9 @@ export class PortfolioService {
grossPerformancePercent: 0, grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0, grossPerformancePercentWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0, grossPerformanceWithCurrencyEffect: 0,
holdings: [],
investment: balance, investment: balance,
marketPrice: 0, marketPrice: 0,
marketState: 'open',
name: currency, name: currency,
netPerformance: 0, netPerformance: 0,
netPerformancePercent: 0, netPerformancePercent: 0,
@ -1714,7 +1665,6 @@ export class PortfolioService {
balanceInBaseCurrency, balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency, emergencyFundPositionsValueInBaseCurrency,
filteredValueInBaseCurrency, filteredValueInBaseCurrency,
holdings,
impersonationId, impersonationId,
portfolioCalculator, portfolioCalculator,
userCurrency, userCurrency,
@ -1723,7 +1673,6 @@ export class PortfolioService {
balanceInBaseCurrency: number; balanceInBaseCurrency: number;
emergencyFundPositionsValueInBaseCurrency: number; emergencyFundPositionsValueInBaseCurrency: number;
filteredValueInBaseCurrency: Big; filteredValueInBaseCurrency: Big;
holdings: PortfolioDetails['holdings'];
impersonationId: string; impersonationId: string;
portfolioCalculator: PortfolioCalculator; portfolioCalculator: PortfolioCalculator;
userCurrency: string; userCurrency: string;
@ -1748,18 +1697,20 @@ export class PortfolioService {
} }
} }
const { currentValueInBaseCurrency, totalInvestment } =
await portfolioCalculator.getSnapshot();
const { performance } = await this.getPerformance({
impersonationId,
userId
});
const { const {
currentValueInBaseCurrency,
grossPerformance,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
grossPerformanceWithCurrencyEffect,
netPerformance, netPerformance,
netPerformancePercentage, netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect, netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect, netPerformanceWithCurrencyEffect
totalInvestment } = performance;
} = await portfolioCalculator.getSnapshot();
const dividendInBaseCurrency = const dividendInBaseCurrency =
await portfolioCalculator.getDividendInBaseCurrency(); await portfolioCalculator.getDividendInBaseCurrency();
@ -1841,13 +1792,13 @@ export class PortfolioService {
const daysInMarket = differenceInDays(new Date(), firstOrderDate); const daysInMarket = differenceInDays(new Date(), firstOrderDate);
const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({ const annualizedPerformancePercent = getAnnualizedPerformancePercent({
daysInMarket, daysInMarket,
netPerformancePercentage: new Big(netPerformancePercentage) netPerformancePercentage: new Big(netPerformancePercentage)
})?.toNumber(); })?.toNumber();
const annualizedPerformancePercentWithCurrencyEffect = const annualizedPerformancePercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({ getAnnualizedPerformancePercent({
daysInMarket, daysInMarket,
netPerformancePercentage: new Big( netPerformancePercentage: new Big(
netPerformancePercentageWithCurrencyEffect netPerformancePercentageWithCurrencyEffect
@ -1860,6 +1811,10 @@ export class PortfolioService {
cash, cash,
excludedAccountsAndActivities, excludedAccountsAndActivities,
firstOrderDate, firstOrderDate,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
totalBuy, totalBuy,
totalSell, totalSell,
committedFunds: committedFunds.toNumber(), committedFunds: committedFunds.toNumber(),
@ -1880,21 +1835,15 @@ export class PortfolioService {
fireWealth: new Big(currentValueInBaseCurrency) fireWealth: new Big(currentValueInBaseCurrency)
.minus(emergencyFundPositionsValueInBaseCurrency) .minus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(), .toNumber(),
grossPerformance: grossPerformance.toNumber(), grossPerformance: new Big(netPerformance).plus(fees).toNumber(),
grossPerformancePercentage: grossPerformancePercentage.toNumber(), grossPerformanceWithCurrencyEffect: new Big(
grossPerformancePercentageWithCurrencyEffect: netPerformanceWithCurrencyEffect
grossPerformancePercentageWithCurrencyEffect.toNumber(), )
grossPerformanceWithCurrencyEffect: .plus(fees)
grossPerformanceWithCurrencyEffect.toNumber(), .toNumber(),
interest: interest.toNumber(), interest: interest.toNumber(),
items: valuables.toNumber(), items: valuables.toNumber(),
liabilities: liabilities.toNumber(), liabilities: liabilities.toNumber(),
netPerformance: netPerformance.toNumber(),
netPerformancePercentage: netPerformancePercentage.toNumber(),
netPerformancePercentageWithCurrencyEffect:
netPerformancePercentageWithCurrencyEffect.toNumber(),
netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffect.toNumber(),
ordersCount: activities.filter(({ type }) => { ordersCount: activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type); return ['BUY', 'SELL'].includes(type);
}).length, }).length,

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

@ -12,11 +12,8 @@ export class RulesService {
aRules: Rule<T>[], aRules: Rule<T>[],
aUserSettings: UserSettings aUserSettings: UserSettings
) { ) {
return aRules return aRules.map((rule) => {
.filter((rule) => { if (rule.getSettings(aUserSettings)?.isActive) {
return rule.getSettings(aUserSettings)?.isActive;
})
.map((rule) => {
const { evaluation, value } = rule.evaluate( const { evaluation, value } = rule.evaluate(
rule.getSettings(aUserSettings) rule.getSettings(aUserSettings)
); );
@ -24,9 +21,17 @@ export class RulesService {
return { return {
evaluation, evaluation,
value, value,
isActive: true,
key: rule.getKey(), key: rule.getKey(),
name: rule.getName() name: rule.getName()
}; };
} else {
return {
isActive: false,
key: rule.getKey(),
name: rule.getName()
};
}
}); });
} }
} }

7
apps/api/src/app/portfolio/update-holding-tags.dto.ts

@ -0,0 +1,7 @@
import { Tag } from '@prisma/client';
import { IsArray } from 'class-validator';
export class UpdateHoldingTagsDto {
@IsArray()
tags: Tag[];
}

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

@ -1,9 +1,10 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier, Filter } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { createHash } from 'crypto';
import type { RedisCache } from './interfaces/redis-cache.interface'; import type { RedisCache } from './interfaces/redis-cache.interface';
@ -24,11 +25,37 @@ export class RedisCacheService {
return this.cache.get(key); return this.cache.get(key);
} }
public getPortfolioSnapshotKey({ userId }: { userId: string }) { public async getKeys(aPrefix?: string): Promise<string[]> {
return `portfolio-snapshot-${userId}`; let prefix = aPrefix;
if (prefix) {
prefix = `${prefix}*`;
}
return this.cache.store.keys(prefix);
}
public getPortfolioSnapshotKey({
filters,
userId
}: {
filters?: Filter[];
userId: string;
}) {
let portfolioSnapshotKey = `portfolio-snapshot-${userId}`;
if (filters?.length > 0) {
const filtersHash = createHash('sha256')
.update(JSON.stringify(filters))
.digest('hex');
portfolioSnapshotKey = `${portfolioSnapshotKey}-${filtersHash}`;
} }
public getQuoteKey({ dataSource, symbol }: UniqueAsset) { return portfolioSnapshotKey;
}
public getQuoteKey({ dataSource, symbol }: AssetProfileIdentifier) {
return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`; return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
} }
@ -36,6 +63,20 @@ export class RedisCacheService {
return this.cache.del(key); return this.cache.del(key);
} }
public async removePortfolioSnapshotsByUserId({
userId
}: {
userId: string;
}) {
const keys = await this.getKeys(
`${this.getPortfolioSnapshotKey({ userId })}`
);
for (const key of keys) {
await this.remove(key);
}
}
public async reset() { public async reset() {
return this.cache.reset(); return this.cache.reset();
} }

50
apps/api/src/app/sitemap/sitemap.controller.ts

@ -1,8 +1,10 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DATE_FORMAT, DATE_FORMAT,
getYesterday, getYesterday,
interpolate interpolate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common'; import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns'; import { format } from 'date-fns';
@ -14,7 +16,9 @@ import * as path from 'path';
export class SitemapController { export class SitemapController {
public sitemapXml = ''; public sitemapXml = '';
public constructor() { public constructor(
private readonly configurationService: ConfigurationService
) {
try { try {
this.sitemapXml = fs.readFileSync( this.sitemapXml = fs.readFileSync(
path.join(__dirname, 'assets', 'sitemap.xml'), path.join(__dirname, 'assets', 'sitemap.xml'),
@ -25,11 +29,51 @@ export class SitemapController {
@Get() @Get()
@Version(VERSION_NEUTRAL) @Version(VERSION_NEUTRAL)
public async flushCache(@Res() response: Response): Promise<void> { public async getSitemapXml(@Res() response: Response): Promise<void> {
const currentDate = format(getYesterday(), DATE_FORMAT);
response.setHeader('content-type', 'application/xml'); response.setHeader('content-type', 'application/xml');
response.send( response.send(
interpolate(this.sitemapXml, { interpolate(this.sitemapXml, {
currentDate: format(getYesterday(), DATE_FORMAT) currentDate,
personalFinanceTools: this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? personalFinanceTools
.map(({ alias, key }) => {
return [
'<url>',
` <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/es/recursos/personal-finance-tools/alternativa-de-software-libre-a-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/fr/ressources/personal-finance-tools/alternative-open-source-a-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/pt/recursos/personal-finance-tools/alternativa-de-software-livre-ao-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>'
].join('\n');
})
.join('\n')
: ''
}) })
); );
} }

5
apps/api/src/app/sitemap/sitemap.module.ts

@ -1,8 +1,11 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { SitemapController } from './sitemap.controller'; import { SitemapController } from './sitemap.controller';
@Module({ @Module({
controllers: [SitemapController] controllers: [SitemapController],
imports: [ConfigurationModule]
}) })
export class SitemapModule {} export class SitemapModule {}

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

@ -22,7 +22,7 @@ export class SubscriptionService {
this.stripe = new Stripe( this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'), this.configurationService.get('STRIPE_SECRET_KEY'),
{ {
apiVersion: '2022-11-15' apiVersion: '2024-04-10'
} }
); );
} }

7
apps/api/src/app/symbol/interfaces/symbol-item.interface.ts

@ -1,6 +1,9 @@
import { HistoricalDataItem, UniqueAsset } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
export interface SymbolItem extends UniqueAsset { export interface SymbolItem extends AssetProfileIdentifier {
currency: string; currency: string;
historicalData: HistoricalDataItem[]; historicalData: HistoricalDataItem[];
marketPrice: number; marketPrice: number;

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

@ -40,13 +40,13 @@ export class SymbolService {
const days = includeHistoricalData; const days = includeHistoricalData;
const marketData = await this.marketDataService.getRange({ const marketData = await this.marketDataService.getRange({
dateQuery: { gte: subDays(new Date(), days) }, assetProfileIdentifiers: [
uniqueAssets: [
{ {
dataSource: dataGatheringItem.dataSource, dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol symbol: dataGatheringItem.symbol
} }
] ],
dateQuery: { gte: subDays(new Date(), days) }
}); });
historicalData = marketData.map(({ date, marketPrice: value }) => { historicalData = marketData.map(({ date, marketPrice: value }) => {

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

@ -1,13 +1,15 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import type { import type {
ColorScheme, ColorScheme,
DateRange, DateRange,
ViewMode HoldingsViewMode,
ViewMode,
XRayRulesSettings
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { import {
IsArray, IsArray,
IsBoolean, IsBoolean,
IsISO4217CurrencyCode,
IsISO8601, IsISO8601,
IsIn, IsIn,
IsNumber, IsNumber,
@ -21,7 +23,7 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
annualInterestRate?: number; annualInterestRate?: number;
@IsISO4217CurrencyCode() @IsCurrencyCode()
@IsOptional() @IsOptional()
baseCurrency?: string; baseCurrency?: string;
@ -69,6 +71,10 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
'filters.tags'?: string[]; 'filters.tags'?: string[];
@IsIn(<HoldingsViewMode[]>['CHART', 'TABLE'])
@IsOptional()
holdingsViewMode?: HoldingsViewMode;
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isExperimentalFeatures?: boolean; isExperimentalFeatures?: boolean;
@ -100,4 +106,7 @@ export class UpdateUserSettingDto {
@IsIn(<ViewMode[]>['DEFAULT', 'ZEN']) @IsIn(<ViewMode[]>['DEFAULT', 'ZEN'])
@IsOptional() @IsOptional()
viewMode?: ViewMode; viewMode?: ViewMode;
@IsOptional()
xRayRules?: XRayRulesSettings;
} }

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

@ -24,7 +24,7 @@ import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { User as UserModel } from '@prisma/client'; import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { size } from 'lodash'; import { merge, size } from 'lodash';
import { DeleteOwnUserDto } from './delete-own-user.dto'; import { DeleteOwnUserDto } from './delete-own-user.dto';
import { UserItem } from './interfaces/user-item.interface'; import { UserItem } from './interfaces/user-item.interface';
@ -144,10 +144,13 @@ export class UserController {
); );
} }
const userSettings: UserSettings = { const emitPortfolioChangedEvent = 'baseCurrency' in data;
...(<UserSettings>this.request.user.Settings.settings),
...data const userSettings: UserSettings = merge(
}; {},
<UserSettings>this.request.user.Settings.settings,
data
);
for (const key in userSettings) { for (const key in userSettings) {
if (userSettings[key] === false || userSettings[key] === null) { if (userSettings[key] === false || userSettings[key] === null) {
@ -156,6 +159,7 @@ export class UserController {
} }
return this.userService.updateUserSetting({ return this.userService.updateUserSetting({
emitPortfolioChangedEvent,
userSettings, userSettings,
userId: this.request.user.id userId: this.request.user.id
}); });

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

@ -1,3 +1,4 @@
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -19,6 +20,7 @@ import { UserService } from './user.service';
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}), }),
OrderModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
SubscriptionModule, SubscriptionModule,

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

@ -1,3 +1,4 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
@ -40,6 +41,7 @@ export class UserService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly orderService: OrderService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
@ -188,13 +190,25 @@ export class UserService {
(user.Settings.settings as UserSettings).dateRange = (user.Settings.settings as UserSettings).dateRange =
(user.Settings.settings as UserSettings).viewMode === 'ZEN' (user.Settings.settings as UserSettings).viewMode === 'ZEN'
? 'max' ? 'max'
: (user.Settings.settings as UserSettings)?.dateRange ?? 'max'; : ((user.Settings.settings as UserSettings)?.dateRange ?? 'max');
// Set default value for view mode // Set default value for view mode
if (!(user.Settings.settings as UserSettings).viewMode) { if (!(user.Settings.settings as UserSettings).viewMode) {
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT'; (user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
} }
// Set default values for X-ray rules
if (!(user.Settings.settings as UserSettings).xRayRules) {
(user.Settings.settings as UserSettings).xRayRules = {
AccountClusterRiskCurrentInvestment: { isActive: true },
AccountClusterRiskSingleAccount: { isActive: true },
CurrencyClusterRiskBaseCurrencyCurrentInvestment: { isActive: true },
CurrencyClusterRiskCurrentInvestment: { isActive: true },
EmergencyFundSetup: { isActive: true },
FeeRatioInitialInvestment: { isActive: true }
};
}
let currentPermissions = getPermissions(user.role); let currentPermissions = getPermissions(user.role);
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) { if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
@ -235,11 +249,15 @@ export class UserService {
currentPermissions = without( currentPermissions = without(
currentPermissions, currentPermissions,
permissions.accessHoldingsChart,
permissions.createAccess permissions.createAccess
); );
// Reset benchmark // Reset benchmark
user.Settings.settings.benchmark = undefined; user.Settings.settings.benchmark = undefined;
// Reset holdings view mode
user.Settings.settings.holdingsViewMode = undefined;
} else if (user.subscription?.type === 'Premium') { } else if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.reportDataGlitch); currentPermissions.push(permissions.reportDataGlitch);
@ -398,8 +416,8 @@ export class UserService {
} catch {} } catch {}
try { try {
await this.prismaService.order.deleteMany({ await this.orderService.deleteOrders({
where: { userId: where.id } userId: where.id
}); });
} catch {} } catch {}
@ -415,9 +433,11 @@ export class UserService {
} }
public async updateUserSetting({ public async updateUserSetting({
emitPortfolioChangedEvent,
userId, userId,
userSettings userSettings
}: { }: {
emitPortfolioChangedEvent: boolean;
userId: string; userId: string;
userSettings: UserSettings; userSettings: UserSettings;
}) { }) {
@ -438,12 +458,14 @@ export class UserService {
} }
}); });
if (emitPortfolioChangedEvent) {
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ new PortfolioChangedEvent({
userId userId
}) })
); );
}
return settings; return settings;
} }

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

@ -16,10 +16,8 @@ export class PortfolioChangedListener {
'PortfolioChangedListener' 'PortfolioChangedListener'
); );
this.redisCacheService.remove( this.redisCacheService.removePortfolioSnapshotsByUserId({
this.redisCacheService.getPortfolioSnapshotKey({
userId: event.getUserId() userId: event.getUserId()
}) });
);
} }
} }

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

@ -1,17 +1,4 @@
import { resetHours } from '@ghostfolio/common/helper';
import { DateRange } from '@ghostfolio/common/types';
import { Type as ActivityType } from '@prisma/client'; import { Type as ActivityType } from '@prisma/client';
import {
endOfDay,
max,
subDays,
startOfMonth,
startOfWeek,
startOfYear,
subYears,
endOfYear
} from 'date-fns';
export function getFactor(activityType: ActivityType) { export function getFactor(activityType: ActivityType) {
let factor: number; let factor: number;
@ -31,61 +18,3 @@ export function getFactor(activityType: ActivityType) {
return factor; 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 };
}

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

@ -55,10 +55,10 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0; const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0;
if (maxInvestmentRatio > ruleSettings.threshold) { if (maxInvestmentRatio > ruleSettings.thresholdMax) {
return { return {
evaluation: `Over ${ evaluation: `Over ${
ruleSettings.threshold * 100 ruleSettings.thresholdMax * 100
}% of your current investment is at ${maxItem.name} (${( }% of your current investment is at ${maxItem.name} (${(
maxInvestmentRatio * 100 maxInvestmentRatio * 100
).toPrecision(3)}%)`, ).toPrecision(3)}%)`,
@ -70,7 +70,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
evaluation: `The major part of your current investment is at ${ evaluation: `The major part of your current investment is at ${
maxItem.name maxItem.name
} (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${ } (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${
ruleSettings.threshold * 100 ruleSettings.thresholdMax * 100
}%`, }%`,
value: true value: true
}; };
@ -79,13 +79,13 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
public getSettings(aUserSettings: UserSettings): Settings { public getSettings(aUserSettings: UserSettings): Settings {
return { return {
baseCurrency: aUserSettings.baseCurrency, baseCurrency: aUserSettings.baseCurrency,
isActive: true, isActive: aUserSettings.xRayRules[this.getKey()].isActive,
threshold: 0.5 thresholdMax: 0.5
}; };
} }
} }
interface Settings extends RuleSettings { interface Settings extends RuleSettings {
baseCurrency: string; baseCurrency: string;
threshold: number; thresholdMax: number;
} }

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

@ -36,7 +36,7 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
public getSettings(aUserSettings: UserSettings): RuleSettings { public getSettings(aUserSettings: UserSettings): RuleSettings {
return { return {
isActive: true isActive: aUserSettings.xRayRules[this.getKey()].isActive
}; };
} }
} }

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

@ -65,7 +65,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
public getSettings(aUserSettings: UserSettings): Settings { public getSettings(aUserSettings: UserSettings): Settings {
return { return {
baseCurrency: aUserSettings.baseCurrency, baseCurrency: aUserSettings.baseCurrency,
isActive: true isActive: aUserSettings.xRayRules[this.getKey()].isActive
}; };
} }
} }

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

@ -41,10 +41,10 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
const maxValueRatio = maxItem?.value / totalValue || 0; const maxValueRatio = maxItem?.value / totalValue || 0;
if (maxValueRatio > ruleSettings.threshold) { if (maxValueRatio > ruleSettings.thresholdMax) {
return { return {
evaluation: `Over ${ evaluation: `Over ${
ruleSettings.threshold * 100 ruleSettings.thresholdMax * 100
}% of your current investment is in ${maxItem.groupKey} (${( }% of your current investment is in ${maxItem.groupKey} (${(
maxValueRatio * 100 maxValueRatio * 100
).toPrecision(3)}%)`, ).toPrecision(3)}%)`,
@ -56,7 +56,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
evaluation: `The major part of your current investment is in ${ evaluation: `The major part of your current investment is in ${
maxItem?.groupKey ?? ruleSettings.baseCurrency maxItem?.groupKey ?? ruleSettings.baseCurrency
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${ } (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${
ruleSettings.threshold * 100 ruleSettings.thresholdMax * 100
}%`, }%`,
value: true value: true
}; };
@ -65,13 +65,13 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
public getSettings(aUserSettings: UserSettings): Settings { public getSettings(aUserSettings: UserSettings): Settings {
return { return {
baseCurrency: aUserSettings.baseCurrency, baseCurrency: aUserSettings.baseCurrency,
isActive: true, isActive: aUserSettings.xRayRules[this.getKey()].isActive,
threshold: 0.5 thresholdMax: 0.5
}; };
} }
} }
interface Settings extends RuleSettings { interface Settings extends RuleSettings {
baseCurrency: string; baseCurrency: string;
threshold: number; thresholdMax: number;
} }

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

@ -19,29 +19,29 @@ export class EmergencyFundSetup extends Rule<Settings> {
} }
public evaluate(ruleSettings: Settings) { public evaluate(ruleSettings: Settings) {
if (this.emergencyFund > ruleSettings.threshold) { if (this.emergencyFund < ruleSettings.thresholdMin) {
return { return {
evaluation: 'An emergency fund has been set up', evaluation: 'No emergency fund has been set up',
value: true value: false
}; };
} }
return { return {
evaluation: 'No emergency fund has been set up', evaluation: 'An emergency fund has been set up',
value: false value: true
}; };
} }
public getSettings(aUserSettings: UserSettings): Settings { public getSettings(aUserSettings: UserSettings): Settings {
return { return {
baseCurrency: aUserSettings.baseCurrency, baseCurrency: aUserSettings.baseCurrency,
isActive: true, isActive: aUserSettings.xRayRules[this.getKey()].isActive,
threshold: 0 thresholdMin: 0
}; };
} }
} }
interface Settings extends RuleSettings { interface Settings extends RuleSettings {
baseCurrency: string; baseCurrency: string;
threshold: number; thresholdMin: number;
} }

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

@ -26,10 +26,10 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
? this.fees / this.totalInvestment ? this.fees / this.totalInvestment
: 0; : 0;
if (feeRatio > ruleSettings.threshold) { if (feeRatio > ruleSettings.thresholdMax) {
return { return {
evaluation: `The fees do exceed ${ evaluation: `The fees do exceed ${
ruleSettings.threshold * 100 ruleSettings.thresholdMax * 100
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`, }% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
value: false value: false
}; };
@ -37,7 +37,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
return { return {
evaluation: `The fees do not exceed ${ evaluation: `The fees do not exceed ${
ruleSettings.threshold * 100 ruleSettings.thresholdMax * 100
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`, }% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
value: true value: true
}; };
@ -46,13 +46,13 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
public getSettings(aUserSettings: UserSettings): Settings { public getSettings(aUserSettings: UserSettings): Settings {
return { return {
baseCurrency: aUserSettings.baseCurrency, baseCurrency: aUserSettings.baseCurrency,
isActive: true, isActive: aUserSettings.xRayRules[this.getKey()].isActive,
threshold: 0.01 thresholdMax: 0.01
}; };
} }
} }
interface Settings extends RuleSettings { interface Settings extends RuleSettings {
baseCurrency: string; baseCurrency: string;
threshold: number; thresholdMax: number;
} }

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

@ -4,6 +4,7 @@ import { DEFAULT_ROOT_URL } from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str, url } from 'envalid'; import { bool, cleanEnv, host, json, num, port, str, url } from 'envalid';
import ms from 'ms';
@Injectable() @Injectable()
export class ConfigurationService { export class ConfigurationService {
@ -20,7 +21,7 @@ export class ConfigurationService {
API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }), API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }),
API_KEY_OPEN_FIGI: str({ default: '' }), API_KEY_OPEN_FIGI: str({ default: '' }),
API_KEY_RAPID_API: str({ default: '' }), API_KEY_RAPID_API: str({ default: '' }),
CACHE_QUOTES_TTL: num({ default: 1 }), CACHE_QUOTES_TTL: num({ default: ms('1 minute') / 1000 }),
CACHE_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }),
LOG_LEVEL: str({ default: '' }), LOG_LEVEL: str({ default: '' }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
@ -42,6 +43,7 @@ export class ConfigurationService {
HOST: host({ default: '0.0.0.0' }), HOST: host({ default: '0.0.0.0' }),
JWT_SECRET_KEY: str({}), JWT_SECRET_KEY: str({}),
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_CHART_ITEMS: num({ default: 365 }),
MAX_ITEM_IN_CACHE: num({ default: 9999 }), MAX_ITEM_IN_CACHE: num({ default: 9999 }),
PORT: port({ default: 3333 }), PORT: port({ default: 3333 }),
REDIS_DB: num({ default: 0 }), REDIS_DB: num({ default: 0 }),

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

@ -45,10 +45,11 @@ export class CronService {
@Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME) @Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME)
public async runEverySundayAtTwelvePm() { public async runEverySundayAtTwelvePm() {
if (await this.isDataGatheringEnabled()) { if (await this.isDataGatheringEnabled()) {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return { return {
data: { data: {
dataSource, dataSource,

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

@ -7,7 +7,7 @@ import {
GATHER_HISTORICAL_MARKET_DATA_PROCESS GATHER_HISTORICAL_MARKET_DATA_PROCESS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Process, Processor } from '@nestjs/bull'; import { Process, Processor } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -35,7 +35,7 @@ export class DataGatheringProcessor {
) {} ) {}
@Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS }) @Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS })
public async gatherAssetProfile(job: Job<UniqueAsset>) { public async gatherAssetProfile(job: Job<AssetProfileIdentifier>) {
try { try {
Logger.log( Logger.log(
`Asset profile data gathering has been started for ${job.data.symbol} (${job.data.dataSource})`, `Asset profile data gathering has been started for ${job.data.symbol} (${job.data.dataSource})`,

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

@ -10,6 +10,7 @@ import {
DATA_GATHERING_QUEUE, DATA_GATHERING_QUEUE,
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW, DATA_GATHERING_QUEUE_PRIORITY_LOW,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_HISTORICAL_MARKET_DATA_PROCESS, GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS
@ -19,7 +20,10 @@ import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
resetHours resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { BenchmarkProperty, UniqueAsset } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
BenchmarkProperty
} from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
@ -65,9 +69,22 @@ export class DataGatheringService {
} }
public async gather7Days() { public async gather7Days() {
const dataGatheringItems = await this.getSymbols7D();
await this.gatherSymbols({ await this.gatherSymbols({
dataGatheringItems, dataGatheringItems: await this.getCurrencies7D(),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
await this.gatherSymbols({
dataGatheringItems: await this.getSymbols7D({
withUserSubscription: true
}),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
});
await this.gatherSymbols({
dataGatheringItems: await this.getSymbols7D({
withUserSubscription: false
}),
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
}); });
} }
@ -80,7 +97,7 @@ export class DataGatheringService {
}); });
} }
public async gatherSymbol({ dataSource, symbol }: UniqueAsset) { public async gatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
const dataGatheringItems = (await this.getSymbolsMax()).filter( const dataGatheringItems = (await this.getSymbolsMax()).filter(
@ -135,23 +152,29 @@ export class DataGatheringService {
} }
} }
public async gatherAssetProfiles(aUniqueAssets?: UniqueAsset[]) { public async gatherAssetProfiles(
let uniqueAssets = aUniqueAssets?.filter((dataGatheringItem) => { aAssetProfileIdentifiers?: AssetProfileIdentifier[]
) {
let assetProfileIdentifiers = aAssetProfileIdentifiers?.filter(
(dataGatheringItem) => {
return dataGatheringItem.dataSource !== 'MANUAL'; return dataGatheringItem.dataSource !== 'MANUAL';
}); }
);
if (!uniqueAssets) { if (!assetProfileIdentifiers) {
uniqueAssets = await this.getUniqueAssets(); assetProfileIdentifiers = await this.getAllAssetProfileIdentifiers();
} }
if (uniqueAssets.length <= 0) { if (assetProfileIdentifiers.length <= 0) {
return; return;
} }
const assetProfiles = const assetProfiles = await this.dataProviderService.getAssetProfiles(
await this.dataProviderService.getAssetProfiles(uniqueAssets); assetProfileIdentifiers
const symbolProfiles = );
await this.symbolProfileService.getSymbolProfiles(uniqueAssets); const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
assetProfileIdentifiers
);
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => { const symbolMapping = symbolProfiles.find((symbolProfile) => {
@ -184,6 +207,7 @@ export class DataGatheringService {
figi, figi,
figiComposite, figiComposite,
figiShareClass, figiShareClass,
holdings,
isin, isin,
name, name,
sectors, sectors,
@ -201,6 +225,7 @@ export class DataGatheringService {
figi, figi,
figiComposite, figiComposite,
figiShareClass, figiShareClass,
holdings,
isin, isin,
name, name,
sectors, sectors,
@ -215,6 +240,7 @@ export class DataGatheringService {
figi, figi,
figiComposite, figiComposite,
figiShareClass, figiShareClass,
holdings,
isin, isin,
name, name,
sectors, sectors,
@ -234,7 +260,7 @@ export class DataGatheringService {
'DataGatheringService' 'DataGatheringService'
); );
if (uniqueAssets.length === 1) { if (assetProfileIdentifiers.length === 1) {
throw error; throw error;
} }
} }
@ -270,7 +296,9 @@ export class DataGatheringService {
); );
} }
public async getUniqueAssets(): Promise<UniqueAsset[]> { public async getAllAssetProfileIdentifiers(): Promise<
AssetProfileIdentifier[]
> {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({ const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }] orderBy: [{ symbol: 'asc' }]
}); });
@ -290,73 +318,83 @@ export class DataGatheringService {
}); });
} }
private getEarliestDate(aStartDate: Date) { private async getAssetProfileIdentifiersWithCompleteMarketData(): Promise<
return min([aStartDate, subYears(new Date(), 10)]); AssetProfileIdentifier[]
} > {
return (
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7);
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
dataSource: true,
scraperConfiguration: true,
symbol: true
}
});
// Only consider symbols with incomplete market data for the last
// 7 days
const symbolsWithCompleteMarketData = (
await this.prismaService.marketData.groupBy({ await this.prismaService.marketData.groupBy({
_count: true, _count: true,
by: ['symbol'], by: ['dataSource', 'symbol'],
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
where: { where: {
date: { gt: startDate }, date: { gt: subDays(resetHours(new Date()), 7) },
state: 'CLOSE' state: 'CLOSE'
} }
}) })
) )
.filter((group) => { .filter(({ _count }) => {
return group._count >= 6; return _count >= 6;
})
.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
});
}
private async getCurrencies7D(): Promise<IDataGatheringItem[]> {
const assetProfileIdentifiersWithCompleteMarketData =
await this.getAssetProfileIdentifiersWithCompleteMarketData();
return this.exchangeRateDataService
.getCurrencyPairs()
.filter(({ dataSource, symbol }) => {
return !assetProfileIdentifiersWithCompleteMarketData.some((item) => {
return item.dataSource === dataSource && item.symbol === symbol;
});
}) })
.map((group) => { .map(({ dataSource, symbol }) => {
return group.symbol; return {
dataSource,
symbol,
date: subDays(resetHours(new Date()), 7)
};
});
}
private getEarliestDate(aStartDate: Date) {
return min([aStartDate, subYears(new Date(), 10)]);
}
private async getSymbols7D({
withUserSubscription = false
}: {
withUserSubscription?: boolean;
}): Promise<IDataGatheringItem[]> {
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesByUserSubscription({
withUserSubscription
}); });
const symbolProfilesToGather = symbolProfiles const assetProfileIdentifiersWithCompleteMarketData =
await this.getAssetProfileIdentifiersWithCompleteMarketData();
return symbolProfiles
.filter(({ dataSource, scraperConfiguration, symbol }) => { .filter(({ dataSource, scraperConfiguration, symbol }) => {
const manualDataSourceWithScraperConfiguration = const manualDataSourceWithScraperConfiguration =
dataSource === 'MANUAL' && !isEmpty(scraperConfiguration); dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
return ( return (
!symbolsWithCompleteMarketData.includes(symbol) && !assetProfileIdentifiersWithCompleteMarketData.some((item) => {
return item.dataSource === dataSource && item.symbol === symbol;
}) &&
(dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration) (dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration)
); );
}) })
.map((symbolProfile) => { .map((symbolProfile) => {
return { return {
...symbolProfile, ...symbolProfile,
date: startDate date: subDays(resetHours(new Date()), 7)
}; };
}); });
const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.filter(({ symbol }) => {
return !symbolsWithCompleteMarketData.includes(symbol);
})
.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
});
return [...currencyPairsToGather, ...symbolProfilesToGather];
} }
private async getSymbolsMax(): Promise<IDataGatheringItem[]> { private async getSymbolsMax(): Promise<IDataGatheringItem[]> {

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

@ -36,6 +36,7 @@ export class DataEnhancerService {
if ( if (
(assetProfile.countries as unknown as Prisma.JsonArray)?.length > 0 && (assetProfile.countries as unknown as Prisma.JsonArray)?.length > 0 &&
(assetProfile.holdings as unknown as Prisma.JsonArray)?.length > 0 &&
(assetProfile.sectors as unknown as Prisma.JsonArray)?.length > 0 (assetProfile.sectors as unknown as Prisma.JsonArray)?.length > 0
) { ) {
return true; return true;

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

@ -1,5 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { Holding } from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
@ -151,11 +152,30 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
} }
} }
if (
!response.holdings ||
(response.holdings as unknown as Holding[]).length === 0
) {
response.holdings = [];
for (const { label, weight } of holdings?.topHoldings ?? []) {
if (label?.toLowerCase() === 'other') {
continue;
}
response.holdings.push({
weight,
name: label
});
}
}
if ( if (
!response.sectors || !response.sectors ||
(response.sectors as unknown as Sector[]).length === 0 (response.sectors as unknown as Sector[]).length === 0
) { ) {
response.sectors = []; response.sectors = [];
for (const [name, value] of Object.entries<any>( for (const [name, value] of Object.entries<any>(
holdings?.sectors ?? {} holdings?.sectors ?? {}
)) { )) {

33
apps/api/src/services/data-provider/data-provider.service.ts

@ -15,8 +15,13 @@ import {
DERIVED_CURRENCIES, DERIVED_CURRENCIES,
PROPERTY_DATA_SOURCE_MAPPING PROPERTY_DATA_SOURCE_MAPPING
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper'; import {
import { UniqueAsset } from '@ghostfolio/common/interfaces'; DATE_FORMAT,
getCurrencyFromSymbol,
getStartOfUtcDate,
isDerivedCurrency
} from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
@ -71,7 +76,7 @@ export class DataProviderService {
return false; return false;
} }
public async getAssetProfiles(items: UniqueAsset[]): Promise<{ public async getAssetProfiles(items: AssetProfileIdentifier[]): Promise<{
[symbol: string]: Partial<SymbolProfile>; [symbol: string]: Partial<SymbolProfile>;
}> { }> {
const response: { const response: {
@ -169,7 +174,7 @@ export class DataProviderService {
} }
public async getHistorical( public async getHistorical(
aItems: UniqueAsset[], aItems: AssetProfileIdentifier[],
aGranularity: Granularity = 'month', aGranularity: Granularity = 'month',
from: Date, from: Date,
to: Date to: Date
@ -239,7 +244,7 @@ export class DataProviderService {
from, from,
to to
}: { }: {
dataGatheringItems: UniqueAsset[]; dataGatheringItems: AssetProfileIdentifier[];
from: Date; from: Date;
to: Date; to: Date;
}): Promise<{ }): Promise<{
@ -347,7 +352,7 @@ export class DataProviderService {
useCache = true, useCache = true,
user user
}: { }: {
items: UniqueAsset[]; items: AssetProfileIdentifier[];
requestTimeout?: number; requestTimeout?: number;
useCache?: boolean; useCache?: boolean;
user?: UserWithSettings; user?: UserWithSettings;
@ -373,7 +378,7 @@ export class DataProviderService {
} }
// Get items from cache // Get items from cache
const itemsToFetch: UniqueAsset[] = []; const itemsToFetch: AssetProfileIdentifier[] = [];
for (const { dataSource, symbol } of items) { for (const { dataSource, symbol } of items) {
if (useCache) { if (useCache) {
@ -425,13 +430,18 @@ export class DataProviderService {
continue; continue;
} }
const symbols = dataGatheringItems.map((dataGatheringItem) => { const symbols = dataGatheringItems
return dataGatheringItem.symbol; .filter(({ symbol }) => {
return !isDerivedCurrency(getCurrencyFromSymbol(symbol));
})
.map(({ symbol }) => {
return symbol;
}); });
const maximumNumberOfSymbolsPerRequest = const maximumNumberOfSymbolsPerRequest =
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ?? dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
Number.MAX_SAFE_INTEGER; Number.MAX_SAFE_INTEGER;
for ( for (
let i = 0; let i = 0;
i < symbols.length; i < symbols.length;
@ -520,7 +530,8 @@ export class DataProviderService {
.filter((symbol) => { .filter((symbol) => {
return ( return (
isNumber(response[symbol].marketPrice) && isNumber(response[symbol].marketPrice) &&
response[symbol].marketPrice > 0 response[symbol].marketPrice > 0 &&
response[symbol].marketState === 'open'
); );
}) })
.map((symbol) => { .map((symbol) => {
@ -645,7 +656,7 @@ export class DataProviderService {
dataGatheringItems dataGatheringItems
}: { }: {
currency: string; currency: string;
dataGatheringItems: UniqueAsset[]; dataGatheringItems: AssetProfileIdentifier[];
}) { }) {
return dataGatheringItems.some(({ dataSource, symbol }) => { return dataGatheringItems.some(({ dataSource, symbol }) => {
return ( return (

15
apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts

@ -246,7 +246,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
for (const { close, code, timestamp } of quotes) { for (const { close, code, timestamp } of quotes) {
let currency: string; let currency: string;
if (code.endsWith('.FOREX')) { if (this.isForex(code)) {
currency = this.convertFromEodSymbol(code)?.replace( currency = this.convertFromEodSymbol(code)?.replace(
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
'' ''
@ -272,7 +272,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
currency, currency,
dataSource: this.getName(), dataSource: this.getName(),
marketPrice: close, marketPrice: close,
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed' marketState:
this.isForex(code) || isToday(new Date(timestamp * 1000))
? 'open'
: 'closed'
}; };
} else { } else {
Logger.error( Logger.error(
@ -311,7 +314,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
items: searchResult items: searchResult
.filter(({ currency, symbol }) => { .filter(({ currency, symbol }) => {
// Remove 'NA' currency and exchange rates // Remove 'NA' currency and exchange rates
return currency?.length === 3 && !symbol.endsWith('.FOREX'); return currency?.length === 3 && !this.isForex(symbol);
}) })
.map( .map(
({ ({
@ -349,7 +352,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
private convertFromEodSymbol(aEodSymbol: string) { private convertFromEodSymbol(aEodSymbol: string) {
let symbol = aEodSymbol; let symbol = aEodSymbol;
if (symbol.endsWith('.FOREX')) { if (this.isForex(symbol)) {
symbol = symbol.replace('GBX', 'GBp'); symbol = symbol.replace('GBX', 'GBp');
symbol = symbol.replace('.FOREX', ''); symbol = symbol.replace('.FOREX', '');
} }
@ -451,6 +454,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
return searchResult; return searchResult;
} }
private isForex(aCode: string) {
return aCode?.endsWith('.FOREX') || false;
}
private parseAssetClass({ private parseAssetClass({
Exchange, Exchange,
Type Type

7
apps/api/src/services/data-provider/manual/manual.service.ts

@ -175,9 +175,10 @@ export class ManualService implements DataProviderInterface {
.then((_result) => _result.flat()); .then((_result) => _result.flat());
for (const { currency, symbol } of symbolProfiles) { for (const { currency, symbol } of symbolProfiles) {
let marketPrice = marketData.find((marketDataItem) => { let marketPrice =
marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbol; return marketDataItem.symbol === symbol;
})?.marketPrice; })?.marketPrice ?? 0;
response[symbol] = { response[symbol] = {
currency, currency,
@ -264,7 +265,7 @@ export class ManualService implements DataProviderInterface {
signal: abortController.signal signal: abortController.signal
}); });
if (headers['content-type'] === 'application/json') { if (headers['content-type'].includes('application/json')) {
const data = JSON.parse(body); const data = JSON.parse(body);
const value = String( const value = String(
jsonpath.query(data, scraperConfiguration.selector)[0] jsonpath.query(data, scraperConfiguration.selector)[0]

24
apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts

@ -361,13 +361,13 @@ export class ExchangeRateDataService {
const symbol = `${currencyFrom}${currencyTo}`; const symbol = `${currencyFrom}${currencyTo}`;
const marketData = await this.marketDataService.getRange({ const marketData = await this.marketDataService.getRange({
dateQuery: { gte: startDate, lt: endDate }, assetProfileIdentifiers: [
uniqueAssets: [
{ {
dataSource, dataSource,
symbol symbol
} }
] ],
dateQuery: { gte: startDate, lt: endDate }
}); });
if (marketData?.length > 0) { if (marketData?.length > 0) {
@ -392,13 +392,13 @@ export class ExchangeRateDataService {
} }
} else { } else {
const marketData = await this.marketDataService.getRange({ const marketData = await this.marketDataService.getRange({
dateQuery: { gte: startDate, lt: endDate }, assetProfileIdentifiers: [
uniqueAssets: [
{ {
dataSource, dataSource,
symbol: `${DEFAULT_CURRENCY}${currencyFrom}` symbol: `${DEFAULT_CURRENCY}${currencyFrom}`
} }
] ],
dateQuery: { gte: startDate, lt: endDate }
}); });
for (const { date, marketPrice } of marketData) { for (const { date, marketPrice } of marketData) {
@ -415,16 +415,16 @@ export class ExchangeRateDataService {
} }
} else { } else {
const marketData = await this.marketDataService.getRange({ const marketData = await this.marketDataService.getRange({
dateQuery: { assetProfileIdentifiers: [
gte: startDate,
lt: endDate
},
uniqueAssets: [
{ {
dataSource, dataSource,
symbol: `${DEFAULT_CURRENCY}${currencyTo}` symbol: `${DEFAULT_CURRENCY}${currencyTo}`
} }
] ],
dateQuery: {
gte: startDate,
lt: endDate
}
}); });
for (const { date, marketPrice } of marketData) { for (const { date, marketPrice } of marketData) {

1
apps/api/src/services/interfaces/environment.interface.ts

@ -28,6 +28,7 @@ export interface Environment extends CleanedEnvAccessors {
GOOGLE_SHEETS_PRIVATE_KEY: string; GOOGLE_SHEETS_PRIVATE_KEY: string;
JWT_SECRET_KEY: string; JWT_SECRET_KEY: string;
MAX_ACTIVITIES_TO_IMPORT: number; MAX_ACTIVITIES_TO_IMPORT: number;
MAX_CHART_ITEMS: number;
MAX_ITEM_IN_CACHE: number; MAX_ITEM_IN_CACHE: number;
PORT: number; PORT: number;
REDIS_DB: number; REDIS_DB: number;

7
apps/api/src/services/interfaces/interfaces.ts

@ -1,4 +1,7 @@
import { DataProviderInfo, UniqueAsset } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
DataProviderInfo
} from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types'; import { MarketState } from '@ghostfolio/common/types';
import { import {
@ -34,6 +37,6 @@ export interface IDataProviderResponse {
marketState: MarketState; marketState: MarketState;
} }
export interface IDataGatheringItem extends UniqueAsset { export interface IDataGatheringItem extends AssetProfileIdentifier {
date?: Date; date?: Date;
} }

16
apps/api/src/services/market-data/market-data.service.ts

@ -5,7 +5,7 @@ import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfac
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { BatchPrismaClient } from '@ghostfolio/common/chunkhelper'; import { BatchPrismaClient } from '@ghostfolio/common/chunkhelper';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import {
@ -24,7 +24,7 @@ export class MarketDataService {
private dateQueryHelper = new DateQueryHelper(); private dateQueryHelper = new DateQueryHelper();
public async deleteMany({ dataSource, symbol }: UniqueAsset) { public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.marketData.deleteMany({ return this.prismaService.marketData.deleteMany({
where: { where: {
dataSource, dataSource,
@ -47,7 +47,7 @@ export class MarketDataService {
}); });
} }
public async getMax({ dataSource, symbol }: UniqueAsset) { public async getMax({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.marketData.findFirst({ return this.prismaService.marketData.findFirst({
select: { select: {
date: true, date: true,
@ -66,11 +66,11 @@ export class MarketDataService {
} }
public async getRange({ public async getRange({
dateQuery, assetProfileIdentifiers,
uniqueAssets dateQuery
}: { }: {
assetProfileIdentifiers: AssetProfileIdentifier[];
dateQuery: DateQuery; dateQuery: DateQuery;
uniqueAssets: UniqueAsset[];
}): Promise<MarketData[]> { }): Promise<MarketData[]> {
return this.prismaService.marketData.findMany({ return this.prismaService.marketData.findMany({
orderBy: [ orderBy: [
@ -83,13 +83,13 @@ export class MarketDataService {
], ],
where: { where: {
dataSource: { dataSource: {
in: uniqueAssets.map(({ dataSource }) => { in: assetProfileIdentifiers.map(({ dataSource }) => {
return dataSource; return dataSource;
}) })
}, },
date: dateQuery, date: dateQuery,
symbol: { symbol: {
in: uniqueAssets.map(({ symbol }) => { in: assetProfileIdentifiers.map(({ symbol }) => {
return symbol; return symbol;
}) })
} }

72
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -2,9 +2,10 @@ import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { import {
AssetProfileIdentifier,
EnhancedSymbolProfile, EnhancedSymbolProfile,
ScraperConfiguration, Holding,
UniqueAsset ScraperConfiguration
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
@ -28,7 +29,7 @@ export class SymbolProfileService {
return this.prismaService.symbolProfile.create({ data: assetProfile }); return this.prismaService.symbolProfile.create({ data: assetProfile });
} }
public async delete({ dataSource, symbol }: UniqueAsset) { public async delete({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.symbolProfile.delete({ return this.prismaService.symbolProfile.delete({
where: { dataSource_symbol: { dataSource, symbol } } where: { dataSource_symbol: { dataSource, symbol } }
}); });
@ -42,7 +43,7 @@ export class SymbolProfileService {
@LogPerformance @LogPerformance
public async getSymbolProfiles( public async getSymbolProfiles(
aUniqueAssets: UniqueAsset[] aAssetProfileIdentifiers: AssetProfileIdentifier[]
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile return this.prismaService.symbolProfile
.findMany({ .findMany({
@ -61,7 +62,7 @@ export class SymbolProfileService {
SymbolProfileOverrides: true SymbolProfileOverrides: true
}, },
where: { where: {
OR: aUniqueAssets.map(({ dataSource, symbol }) => { OR: aAssetProfileIdentifiers.map(({ dataSource, symbol }) => {
return { return {
dataSource, dataSource,
symbol symbol
@ -99,6 +100,40 @@ export class SymbolProfileService {
}); });
} }
public async getSymbolProfilesByUserSubscription({
withUserSubscription = false
}: {
withUserSubscription?: boolean;
}) {
return this.prismaService.symbolProfile.findMany({
include: {
Order: {
include: {
User: true
}
}
},
orderBy: [{ symbol: 'asc' }],
where: {
Order: withUserSubscription
? {
some: {
User: {
Subscription: { some: { expiresAt: { gt: new Date() } } }
}
}
}
: {
every: {
User: {
Subscription: { none: { expiresAt: { gt: new Date() } } }
}
}
}
}
});
}
public updateSymbolProfile({ public updateSymbolProfile({
assetClass, assetClass,
assetSubClass, assetSubClass,
@ -106,6 +141,7 @@ export class SymbolProfileService {
countries, countries,
currency, currency,
dataSource, dataSource,
holdings,
name, name,
tags, tags,
scraperConfiguration, scraperConfiguration,
@ -114,7 +150,7 @@ export class SymbolProfileService {
symbolMapping, symbolMapping,
SymbolProfileOverrides, SymbolProfileOverrides,
url url
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) { }: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
return this.prismaService.symbolProfile.update({ return this.prismaService.symbolProfile.update({
data: { data: {
assetClass, assetClass,
@ -122,6 +158,7 @@ export class SymbolProfileService {
comment, comment,
countries, countries,
currency, currency,
holdings,
name, name,
tags, tags,
scraperConfiguration, scraperConfiguration,
@ -152,6 +189,7 @@ export class SymbolProfileService {
symbolProfile?.countries as unknown as Prisma.JsonArray symbolProfile?.countries as unknown as Prisma.JsonArray
), ),
dateOfFirstActivity: <Date>undefined, dateOfFirstActivity: <Date>undefined,
holdings: this.getHoldings(symbolProfile),
scraperConfiguration: this.getScraperConfiguration(symbolProfile), scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile), sectors: this.getSectors(symbolProfile),
symbolMapping: this.getSymbolMapping(symbolProfile), symbolMapping: this.getSymbolMapping(symbolProfile),
@ -180,6 +218,14 @@ export class SymbolProfileService {
); );
} }
if (
(item.SymbolProfileOverrides.holdings as unknown as Holding[])
?.length > 0
) {
item.holdings = item.SymbolProfileOverrides
.holdings as unknown as Holding[];
}
item.name = item.SymbolProfileOverrides?.name ?? item.name; item.name = item.SymbolProfileOverrides?.name ?? item.name;
if ( if (
@ -216,6 +262,20 @@ export class SymbolProfileService {
}); });
} }
private getHoldings(symbolProfile: SymbolProfile): Holding[] {
return ((symbolProfile?.holdings as Prisma.JsonArray) ?? []).map(
(holding) => {
const { name, weight } = holding as Prisma.JsonObject;
return {
allocationInPercentage: weight as number,
name: (name as string) ?? UNKNOWN_KEY,
valueInBaseCurrency: undefined
};
}
);
}
private getScraperConfiguration( private getScraperConfiguration(
symbolProfile: SymbolProfile symbolProfile: SymbolProfile
): ScraperConfiguration { ): ScraperConfiguration {

2
apps/api/src/services/twitter-bot/twitter-bot.service.ts

@ -70,7 +70,7 @@ export class TwitterBotService {
await this.twitterClient.v2.tweet(status); await this.twitterClient.v2.tweet(status);
Logger.log( Logger.log(
`Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}`, `Fear & Greed Index has been posted: https://x.com/ghostfolio_/status/${createdTweet.id}`,
'TwitterBotService' 'TwitterBotService'
); );
} }

44
apps/api/src/validators/is-currency-code.ts

@ -0,0 +1,44 @@
import { DERIVED_CURRENCIES } from '@ghostfolio/common/config';
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments
} from 'class-validator';
import { isISO4217CurrencyCode } from 'class-validator';
export function IsCurrencyCode(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
propertyName,
constraints: [],
options: validationOptions,
target: object.constructor,
validator: IsExtendedCurrencyConstraint
});
};
}
@ValidatorConstraint({ async: false })
export class IsExtendedCurrencyConstraint
implements ValidatorConstraintInterface
{
public defaultMessage(args: ValidationArguments) {
return '$value must be a valid ISO4217 currency code';
}
public validate(currency: any) {
// Return true if currency is a standard ISO 4217 code or a derived currency
return (
isISO4217CurrencyCode(currency) ||
[
...DERIVED_CURRENCIES.map((derivedCurrency) => {
return derivedCurrency.currency;
}),
'USX'
].includes(currency)
);
}
}

18
apps/client/localhost.cert

@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIIC5TCCAc2gAwIBAgIJAJAMHOFnJ6oyMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV
BAMMCWxvY2FsaG9zdDAeFw0yNDAyMjcxNTQ2MzFaFw0yNDAzMjgxNTQ2MzFaMBQx
EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
ggEBAJ0hRjViikEKVIyukXR4CfuYVvFEFzB6AwAQ9Jrz2MseqpLacLTXFFAS54mp
iDuqPBzs9ta40mlSrqSBuAWKikpW5kTNnmqUnDZ6iSJezbYWx9YyULGqqb1q3C4/
5pH9m6NHJ+2uaUNKlDxYNKbntqs3drQEdxH9yv672Z53nvogTcf9jz6zjutEQGSV
HgVkCTTQmzf3+3st+VJ5D8JeYFR+tpZ6yleqgXFaTMtPZRfKLvTkQ+KeyCJLnsUJ
BQvdCKI0PGsG6d6ygXFmSePolD9KW3VTKKDPCsndID89vAnRWDj9UhzvycxmKpcF
GrUPH5+Pis1PM1R7OnAvnFygnyECAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxo
b3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0B
AQsFAAOCAQEAOdzrY3RTAPnBVAd3/GKIEkielYyOgKPnvd+RcOB+je8cXZY+vaxX
uEFP0526G00+kRZ2Tx9t0SmjoSpwg3lHUPMko0dIxgRlqISDAohdrEptHpcVujsD
ak88LLnAurr60JNjWX2wbEoJ18KLtqGSnATdmCgKwDPIN5a7+ssp44BGyzH6VYCg
wV3VjJl0pp4C5M0Jfu0p1FrQjzIVhgqR7JFYmvqIogWrGwYAQK/3XRXq8t48J5X3
IsfWiLAA2ZdCoWAnZ6PAGBOoGivtkJm967pHjd/28qYY6mQo4sN2ksEOjx6/YslF
2mOJdLV/DzqoggUsTpPEG0dRhzQLTGHKDQ==
-----END CERTIFICATE-----

28
apps/client/localhost.pem

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCdIUY1YopBClSM
rpF0eAn7mFbxRBcwegMAEPSa89jLHqqS2nC01xRQEueJqYg7qjwc7PbWuNJpUq6k
gbgFiopKVuZEzZ5qlJw2eokiXs22FsfWMlCxqqm9atwuP+aR/ZujRyftrmlDSpQ8
WDSm57arN3a0BHcR/cr+u9med576IE3H/Y8+s47rREBklR4FZAk00Js39/t7LflS
eQ/CXmBUfraWespXqoFxWkzLT2UXyi705EPinsgiS57FCQUL3QiiNDxrBunesoFx
Zknj6JQ/Slt1UyigzwrJ3SA/PbwJ0Vg4/VIc78nMZiqXBRq1Dx+fj4rNTzNUezpw
L5xcoJ8hAgMBAAECggEAPU5YOEgELTA8oM8TjV+wdWuQsH2ilpVkSkhTR4nQkh+a
6cU0qDoqgLt/fySYNL9MyPRjso9V+SX7YdAC3paZMjwJh9q57lehQ1g33SMkG+Fz
gs0K0ucFZxQkaB8idN+ANAp1N7UO+ORGRe0cTeqmSNNRCxea5XgiFZVxaPS/IFOR
vVdXS1DulgwHh4H0ljKmkj7jc9zPBSc9ccW5Ml2q4a26Atu4IC/Mlp/DF4GNELbD
ebi9ZOZG33ip2bdhj3WX7NW9GJaaViKtVUpcpR6u+6BfvTXQ6xrqdoxXk9fnPzzf
sSoLPTt8yO4RavP1zQU22PhgIcWudhCiy/2Nv+uLqQKBgQDMPh1/xwdFl8yES8dy
f0xJyCDWPiB+xzGwcOAC90FrNZaqG0WWs3VHE4cilaWjCflvdR6aAEDEY68sZJhl
h+ChT/g61QLMOI+VIDQ1kJXKYgfS/B+BE1PZ0EkuymKFOvbNO8agHodB8CqnZaQh
bLuZaDnZ0JLK4im3KPt70+aKYwKBgQDE8s6xl0SLu+yJLo3VklCRD67Z8/jlvx2t
h3DF6NG8pB3QmvKdJWFKuLAWLGflJwbUW9Pt3hXkc0649KQrtiIYC0ZMh9KMaVCk
WmjS/n2eIUQZ7ZUlwFesi4p4iGynVBavIY8TJ6Y+K3TvsJgXP3IZ96r689PQJo8E
KbSeyYzFqwKBgGQTS4EAlJ+U8bEhMGj51veQCAbyChoUoFRD+n95h6RwbZKMKlzd
MenRt7VKfg6VJJNoX8Y1uYaBEaQ+5i1Zlsdz1718AhLu4+u+C9bzMXIo9ox63TTx
s3RWioVSxVNiwOtvDrQGQWAdvcioFPQLwyA34aDIgiTHDImimxbhjWThAoGAWOqW
Tq9QjxWk0Lpn5ohMP3GpK1VuhasnJvUDARb/uf8ORuPtrOz3Y9jGBvy9W0OnXbCn
mbiugZldbTtl8yYjdl+AuYSIlkPl2I3IzZl/9Shnqp0MvSJ9crT9KzXMeC8Knr6z
7Z30/BR6ksxTngtS5E5grzPt6Qe/gc2ich3kpEkCgYBfBHUhVIKVrDW/8a7U2679
Wj4i9us/M3iPMxcTv7/ZAg08TEvNBQYtvVQLu0NfDKXx8iGKJJ6evIYgNXVm2sPq
VzSgwGCALi1X0443amZU+mnX+/9RnBqyM+PJU8570mM83jqKlY/BEsi0aqxTioFG
an3xbjjN+Rq9iKLzmPxIMg==
-----END PRIVATE KEY-----

14
apps/client/project.json

@ -36,6 +36,10 @@
"ngswConfigPath": "apps/client/ngsw-config.json" "ngswConfigPath": "apps/client/ngsw-config.json"
}, },
"configurations": { "configurations": {
"development-ca": {
"baseHref": "/ca/",
"localize": ["ca"]
},
"development-de": { "development-de": {
"baseHref": "/de/", "baseHref": "/de/",
"localize": ["de"] "localize": ["de"]
@ -163,8 +167,11 @@
"serve": { "serve": {
"executor": "@nx/angular:webpack-dev-server", "executor": "@nx/angular:webpack-dev-server",
"options": { "options": {
"buildTarget": "client:build",
"proxyConfig": "apps/client/proxy.conf.json", "proxyConfig": "apps/client/proxy.conf.json",
"browserTarget": "client:build" "ssl": true,
"sslCert": "apps/client/localhost.cert",
"sslKey": "apps/client/localhost.pem"
}, },
"configurations": { "configurations": {
"development-de": { "development-de": {
@ -209,6 +216,7 @@
"includeContext": true, "includeContext": true,
"outputPath": "src/locales", "outputPath": "src/locales",
"targetFiles": [ "targetFiles": [
"messages.ca.xlf",
"messages.de.xlf", "messages.de.xlf",
"messages.es.xlf", "messages.es.xlf",
"messages.fr.xlf", "messages.fr.xlf",
@ -237,6 +245,10 @@
}, },
"i18n": { "i18n": {
"locales": { "locales": {
"ca": {
"baseHref": "/ca/",
"translation": "apps/client/src/locales/messages.ca.xlf"
},
"de": { "de": {
"baseHref": "/de/", "baseHref": "/de/",
"translation": "apps/client/src/locales/messages.de.xlf" "translation": "apps/client/src/locales/messages.de.xlf"

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

Loading…
Cancel
Save