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. 1110
      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"],
"extends": ["plugin:@nx/typescript"],
"rules": {}
"rules": {
"@typescript-eslint/no-extra-semi": "error",
"no-extra-semi": "off"
}
},
{
"files": ["*.js", "*.jsx"],
"extends": ["plugin:@nx/javascript"],
"rules": {}
"rules": {
"@typescript-eslint/no-extra-semi": "error",
"no-extra-semi": "off"
}
},
{
"files": ["*.ts"],

12
.github/workflows/build-code.yml

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

5
.gitignore

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

2
.nvmrc

@ -1 +1 @@
v18
v20

1
.prettierignore

@ -1,4 +1,5 @@
/.nx/cache
/.nx/workspace-data
/apps/client/src/polyfills.ts
/dist
/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/),
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
@ -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`)
- 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
### Added
@ -4656,7 +4959,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added the attribute `precision` in the value component
- Added the attribute `precision` to the value component
### Fixed

63
DEVELOPMENT.md

@ -1,5 +1,54 @@
# 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
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
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
## Git
@ -30,26 +79,26 @@ Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
#### Upgrade
1. Run `yarn nx migrate latest`
1. Make sure `package.json` changes make sense and then run `yarn 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 latest`
1. Make sure `package.json` changes make sense and then run `npm install`
1. Run `npx nx migrate --run-migrations`
### Prisma
#### Access database via GUI
Run `yarn database:gui`
Run `npm run database:gui`
https://www.prisma.io/studio
#### 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
#### 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

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
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
# layers when files (package.json etc.) have not changed
COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE
COPY ./package.json package.json
COPY ./yarn.lock yarn.lock
COPY ./.yarnrc .yarnrc
COPY ./package-lock.json package-lock.json
COPY ./prisma/schema.prisma prisma/schema.prisma
RUN apt update && apt install -y \
g++ \
git \
make \
openssl \
python3 \
&& rm -rf /var/lib/apt/lists/*
RUN yarn install
RUN npm install
# See https://github.com/nrwl/nx/issues/6586 for further details
COPY ./decorate-angular-cli.js decorate-angular-cli.js
@ -33,31 +33,35 @@ COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs
COPY ./apps apps
RUN yarn build:production
RUN npm run build:production
# Prepare the dist image with additional node_modules
WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original
yarn.lock needs to be used to ensure the same versions
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
package-lock.json needs to be used to ensure the same versions
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
RUN yarn
RUN npm install
COPY prisma /ghostfolio/dist/apps/api/prisma
# Overwrite the generated package.json with the original one to ensure having
# all the scripts
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
FROM node:18-slim
RUN apt update && apt install -y \
FROM node:20-slim
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 \
openssl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps
COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333}
USER node
CMD [ "/ghostfolio/entrypoint.sh" ]

108
README.md

@ -7,7 +7,7 @@
**Open Source Wealth Management Software**
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**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: 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
- ✅ 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
- ✅ Static analysis to identify potential risks in your portfolio
- ✅ Import and export transactions
@ -71,7 +71,7 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
### 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
@ -87,21 +87,21 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
| Name | Type | Default Value | Description |
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | string | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | string (`optional`) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API |
| `DATABASE_URL` | string | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | string (`optional`) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | string | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | number (`optional`) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | string | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | string | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | string | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | number (`optional`) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | string | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | string | | The password of _Redis_ |
| `REDIS_PORT` | number | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | number (`optional`) | `2000` | The timeout of network requests to data providers in milliseconds |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
### Run with Docker Compose
@ -146,53 +146,7 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
## Development
### Prerequisites
- [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`
For detailed information on the environment setup and development process, please refer to [DEVELOPMENT.md](./DEVELOPMENT.md).
## Public API
@ -234,17 +188,17 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
```
| Field | Type | Description |
| ---------- | ------------------- | ----------------------------------------------------------------------------- |
| accountId | string (`optional`) | Id of the account |
| comment | string (`optional`) | Comment of the activity |
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
| dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
| date | string | Date in the format `ISO-8601` |
| fee | number | Fee of the activity |
| quantity | number | Quantity of the activity |
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
| type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
| unitPrice | number | Price per unit of the activity |
| ------------ | ------------------- | ----------------------------------------------------------------------------- |
| `accountId` | `string` (optional) | Id of the account |
| `comment` | `string` (optional) | Comment of the activity |
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
| `date` | `string` | Date in the format `ISO-8601` |
| `fee` | `number` | Fee of the activity |
| `quantity` | `number` | Quantity of the activity |
| `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
| `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
| `unitPrice` | `number` | Price per unit of the activity |
#### 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.
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).

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

@ -176,8 +176,8 @@ export class AccountService {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
} = groupBy(filters, ({ type }) => {
return type;
});
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 {
IsBoolean,
IsISO4217CurrencyCode,
IsNumber,
IsOptional,
IsString,
@ -20,7 +21,7 @@ export class CreateAccountDto {
)
comment?: string;
@IsISO4217CurrencyCode()
@IsCurrencyCode()
currency: string;
@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 {
IsBoolean,
IsISO4217CurrencyCode,
IsNumber,
IsOptional,
IsString,
@ -20,7 +21,7 @@ export class UpdateAccountDto {
)
comment?: string;
@IsISO4217CurrencyCode()
@IsCurrencyCode()
currency: string;
@IsString()

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

@ -81,10 +81,11 @@ export class AdminController {
@Post('gather/max')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMax(): Promise<void> {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => {
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return {
data: {
dataSource,
@ -107,10 +108,11 @@ export class AdminController {
@Post('gather/profile-data')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileData(): Promise<void> {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => {
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return {
data: {
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 { 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';
@ -20,11 +22,13 @@ import { QueueModule } from './queue/queue.module';
@Module({
imports: [
ApiModule,
BenchmarkModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
OrderModule,
PrismaModule,
PropertyModule,
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 { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -14,21 +16,24 @@ import {
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config';
import { isCurrency, getCurrencyFromSymbol } from '@ghostfolio/common/helper';
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
Filter,
UniqueAsset
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter
} from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
Prisma,
PrismaClient,
Property,
SymbolProfile,
DataSource,
@ -41,10 +46,12 @@ import { groupBy } from 'lodash';
@Injectable()
export class AdminService {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService,
@ -56,7 +63,9 @@ export class AdminService {
currency,
dataSource,
symbol
}: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> {
}: AssetProfileIdentifier & { currency?: string }): Promise<
SymbolProfile | never
> {
try {
if (dataSource === 'MANUAL') {
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.symbolProfileService.delete({ dataSource, symbol });
}
@ -151,7 +163,16 @@ export class AdminService {
[{ symbol: 'asc' }];
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();
} else if (
presetId === 'ETF_WITHOUT_COUNTRIES' ||
@ -201,8 +222,11 @@ export class AdminService {
}
}
const extendedPrismaClient = this.getExtendedPrismaClient();
try {
let [assetProfiles, count] = await Promise.all([
this.prismaService.symbolProfile.findMany({
extendedPrismaClient.symbolProfile.findMany({
orderBy,
skip,
take,
@ -218,6 +242,7 @@ export class AdminService {
currency: true,
dataSource: true,
id: true,
isUsedByUsersWithSubscription: true,
name: true,
Order: {
orderBy: [{ date: 'asc' }],
@ -233,8 +258,9 @@ export class AdminService {
this.prismaService.symbolProfile.count({ where })
]);
let marketData: AdminMarketDataItem[] = assetProfiles.map(
({
let marketData: AdminMarketDataItem[] = await Promise.all(
assetProfiles.map(
async ({
_count,
assetClass,
assetSubClass,
@ -243,13 +269,16 @@ export class AdminService {
currency,
dataSource,
id,
isUsedByUsersWithSubscription,
name,
Order,
sectors,
symbol,
tags
}) => {
const countriesCount = countries ? Object.keys(countries).length : 0;
const countriesCount = countries
? Object.keys(countries).length
: 0;
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
@ -273,9 +302,12 @@ export class AdminService {
sectorsCount,
activitiesCount: _count.Order,
date: Order?.[0]?.date,
isUsedByUsersWithSubscription:
await isUsedByUsersWithSubscription,
tags
};
}
)
);
if (presetId) {
@ -296,12 +328,27 @@ export class AdminService {
count,
marketData
};
} finally {
await extendedPrismaClient.$disconnect();
Logger.debug('Disconnect extended prisma client', 'AdminService');
}
}
public async getMarketDataBySymbol({
dataSource,
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([
this.symbolProfileService.getSymbolProfiles([
{
@ -329,8 +376,11 @@ export class AdminService {
return {
marketData,
assetProfile: assetProfile ?? {
symbol,
currency: '-'
activitiesCount,
currency,
dataSource,
dateOfFirstActivity,
symbol
}
};
}
@ -342,6 +392,7 @@ export class AdminService {
countries,
currency,
dataSource,
holdings,
name,
tags,
scraperConfiguration,
@ -349,71 +400,38 @@ export class AdminService {
symbol,
symbolMapping,
url
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
if (dataSource === 'MANUAL') {
await this.symbolProfileService.updateSymbolProfile({
assetClass,
assetSubClass,
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
const symbolProfileOverrides = {
assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass,
name: name as string,
url: url as string
};
const updatedSymbolProfile: AssetProfileIdentifier &
Prisma.SymbolProfileUpdateInput = {
comment,
countries,
currency,
dataSource,
name,
tags,
scraperConfiguration,
symbol,
sectors,
symbolMapping
});
} else {
await this.symbolProfileService.updateSymbolProfile({
comment,
countries,
dataSource,
name,
tags,
holdings,
scraperConfiguration,
sectors,
symbol,
symbolMapping
});
let symbolProfileId =
await this.symbolProfileOverwriteService.GetSymbolProfileId(
symbol,
dataSource
);
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
});
symbolMapping,
...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url }
: {
SymbolProfileOverrides: {
upsert: {
create: symbolProfileOverrides,
update: symbolProfileOverrides
}
}
})
};
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{
@ -443,15 +461,72 @@ export class AdminService {
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> {
const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
const marketData: AdminMarketDataItem[] = this.exchangeRateDataService
const marketDataPromise: Promise<AdminMarketDataItem>[] =
this.exchangeRateDataService
.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 =
marketDataItems.find((marketDataItem) => {
return (
@ -461,6 +536,8 @@ export class AdminService {
})?._count ?? 0;
return {
activitiesCount,
date: dateOfFirstActivity,
dataSource,
marketDataItemCount,
symbol,
@ -474,6 +551,7 @@ export class AdminService {
};
});
const marketData = await Promise.all(marketDataPromise);
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 {
IsArray,
IsEnum,
IsISO4217CurrencyCode,
IsObject,
IsOptional,
IsString,
@ -26,7 +27,7 @@ export class UpdateAssetProfileDto {
@IsOptional()
countries?: Prisma.InputJsonArray;
@IsISO4217CurrencyCode()
@IsCurrencyCode()
@IsOptional()
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 { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller';
import { AssetModule } from './asset/asset.module';
import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
@ -51,6 +52,7 @@ import { UserModule } from './user/user.module';
AdminModule,
AccessModule,
AccountModule,
AssetModule,
AuthDeviceModule,
AuthModule,
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 { 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 { 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 {
AssetProfileIdentifier,
BenchmarkMarketDataDetails,
BenchmarkResponse,
UniqueAsset
BenchmarkResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
@ -41,7 +41,9 @@ export class BenchmarkController {
@HasPermission(permissions.accessAdminControl)
@Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
public async addBenchmark(
@Body() { dataSource, symbol }: AssetProfileIdentifier
) {
try {
const benchmark = await this.benchmarkService.addBenchmark({
dataSource,
@ -105,19 +107,19 @@ export class BenchmarkController {
@Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataBySymbol(
public async getBenchmarkMarketDataForUser(
@Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string,
@Param('symbol') symbol: string,
@Query('range') dateRange: DateRange = 'max'
): Promise<BenchmarkMarketDataDetails> {
const { endDate, startDate } = getInterval(
const { endDate, startDate } = getIntervalFromDateRange(
dateRange,
new Date(startDateString)
);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
return this.benchmarkService.getMarketDataBySymbol({
return this.benchmarkService.getMarketDataForUser({
dataSource,
endDate,
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 { 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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
@ -18,6 +19,7 @@ import { BenchmarkService } from './benchmark.service';
controllers: [BenchmarkController],
exports: [BenchmarkService],
imports: [
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,

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

@ -12,6 +12,7 @@ describe('BenchmarkService', () => {
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 { 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
MAX_CHART_ITEMS,
PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config';
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
import {
DATE_FORMAT,
calculateBenchmarkTrend,
@ -17,11 +15,11 @@ import {
resetHours
} from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
Benchmark,
BenchmarkMarketDataDetails,
BenchmarkProperty,
BenchmarkResponse,
UniqueAsset
BenchmarkResponse
} from '@ghostfolio/common/interfaces';
import { BenchmarkTrend } from '@ghostfolio/common/types';
@ -29,20 +27,25 @@ import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import {
addHours,
differenceInDays,
eachDayOfInterval,
format,
isAfter,
isSameDay,
subDays
} from 'date-fns';
import { isNumber, last, uniqBy } from 'lodash';
import ms from 'ms';
import { BenchmarkValue } from './interfaces/benchmark-value.interface';
@Injectable()
export class BenchmarkService {
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
@ -61,7 +64,10 @@ export class BenchmarkService {
return 0;
}
public async getBenchmarkTrends({ dataSource, symbol }: UniqueAsset) {
public async getBenchmarkTrends({
dataSource,
symbol
}: AssetProfileIdentifier) {
const historicalData = await this.marketDataService.marketDataItems({
orderBy: {
date: 'desc'
@ -89,94 +95,28 @@ export class BenchmarkService {
enableSharing = false,
useCache = true
} = {}): Promise<BenchmarkResponse['benchmarks']> {
let benchmarks: BenchmarkResponse['benchmarks'];
if (useCache) {
try {
benchmarks = JSON.parse(
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
const cachedBenchmarkValue = await this.redisCacheService.get(
this.CACHE_KEY_BENCHMARKS
);
if (benchmarks) {
return benchmarks;
}
} catch {}
}
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
enableSharing
});
const { benchmarks, expiration }: BenchmarkValue =
JSON.parse(cachedBenchmarkValue);
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
[];
const promisesBenchmarkTrends: Promise<{
trend50d: BenchmarkTrend;
trend200d: BenchmarkTrend;
}>[] = [];
Logger.debug('Fetched benchmarks from cache', 'BenchmarkService');
const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
}),
requestTimeout: ms('30 seconds'),
useCache: false
if (isAfter(new Date(), new Date(expiration))) {
this.calculateAndCacheBenchmarks({
enableSharing
});
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;
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;
} catch {}
}
return benchmarks;
return this.calculateAndCacheBenchmarks({ enableSharing });
}
public async getBenchmarkAssetProfiles({
@ -213,7 +153,7 @@ export class BenchmarkService {
.sort((a, b) => a.name.localeCompare(b.name));
}
public async getMarketDataBySymbol({
public async getMarketDataForUser({
dataSource,
endDate = new Date(),
startDate,
@ -223,7 +163,7 @@ export class BenchmarkService {
endDate?: Date;
startDate: Date;
userCurrency: string;
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
const marketData: { date: string; value: number }[] = [];
const days = differenceInDays(endDate, startDate) + 1;
@ -232,7 +172,12 @@ export class BenchmarkService {
start: startDate,
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) => {
return resetHours(date);
});
@ -343,7 +288,7 @@ export class BenchmarkService {
public async addBenchmark({
dataSource,
symbol
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: {
dataSource,
@ -380,7 +325,7 @@ export class BenchmarkService {
public async deleteBenchmark({
dataSource,
symbol
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: {
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(
aPerformanceInPercent: number
): Benchmark['marketCondition'] {
if (aPerformanceInPercent === 0) {
if (aPerformanceInPercent >= 0) {
return 'ALL_TIME_HIGH';
} else if (aPerformanceInPercent <= -0.2) {
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')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
} from '@ghostfolio/common/config';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import {
AccountWithPlatform,
OrderWithAccount,
@ -54,7 +51,7 @@ export class ImportService {
dataSource,
symbol,
userCurrency
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> {
}: AssetProfileIdentifier & { userCurrency: string }): Promise<Activity[]> {
try {
const { firstBuyDate, historicalData, orders } =
await this.portfolioService.getPosition(dataSource, undefined, symbol);
@ -75,13 +72,18 @@ export class ImportService {
})
]);
const accounts = orders.map((order) => {
return order.Account;
const accounts = orders
.filter(({ Account }) => {
return !!Account;
})
.map(({ Account }) => {
return Account;
});
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 =
historicalData.find((historicalDataItem) => {
return historicalDataItem.date === dateString;
@ -128,13 +130,16 @@ export class ImportService {
unitPrice: marketPrice,
updatedAt: undefined,
userId: Account?.userId,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
valueInBaseCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
value,
assetProfile.currency,
userCurrency
userCurrency,
date
)
};
});
})
);
} catch {
return [];
}
@ -295,6 +300,7 @@ export class ImportService {
figi,
figiComposite,
figiShareClass,
holdings,
id,
isin,
name,
@ -367,6 +373,7 @@ export class ImportService {
figi,
figiComposite,
figiShareClass,
holdings,
id,
isin,
name,
@ -429,17 +436,20 @@ export class ImportService {
...order,
error,
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
feeInBaseCurrency: await this.exchangeRateDataService.toCurrencyAtDate(
fee,
assetProfile.currency,
userCurrency
userCurrency,
date
),
// @ts-ignore
SymbolProfile: assetProfile,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
valueInBaseCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
value,
assetProfile.currency,
userCurrency
userCurrency,
date
)
});
}
@ -538,6 +548,7 @@ export class ImportService {
assetSubClass: undefined,
countries: undefined,
createdAt: undefined,
holdings: undefined,
id: undefined,
sectors: 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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.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 { 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 { DataSource } from '@prisma/client';
@ -17,7 +17,7 @@ export class LogoService {
public async getLogoByDataSourceAndSymbol({
dataSource,
symbol
}: UniqueAsset) {
}: AssetProfileIdentifier) {
if (!DataSource[dataSource]) {
throw new HttpException(
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 {
AssetClass,
AssetSubClass,
@ -10,12 +13,12 @@ import {
IsArray,
IsBoolean,
IsEnum,
IsISO4217CurrencyCode,
IsISO8601,
IsNumber,
IsOptional,
IsString,
Min
Min,
Validate
} from 'class-validator';
import { isString } from 'lodash';
@ -39,10 +42,10 @@ export class CreateOrderDto {
)
comment?: string;
@IsISO4217CurrencyCode()
@IsCurrencyCode()
currency: string;
@IsISO4217CurrencyCode()
@IsCurrencyCode()
@IsOptional()
customCurrency?: string;
@ -51,6 +54,7 @@ export class CreateOrderDto {
dataSource?: DataSource;
@IsISO8601()
@Validate(IsAfter1970Constraint)
date: string;
@IsNumber()

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

@ -1,12 +1,12 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
HEADER_KEY_IMPERSONATION
@ -36,7 +36,7 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
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 { UpdateOrderDto } from './update-order.dto';
@ -66,7 +66,6 @@ export class OrderController {
return this.orderService.deleteOrders({
filters,
userCurrency: this.request.user.Settings.settings.baseCurrency,
userId: this.request.user.id
});
}
@ -111,7 +110,7 @@ export class OrderController {
let startDate: Date;
if (dateRange) {
({ endDate, startDate } = getInterval(dateRange));
({ endDate, startDate } = getIntervalFromDateRange(dateRange));
}
const filters = this.apiService.buildFiltersFromQueryParams({
@ -141,6 +140,38 @@ export class OrderController {
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)
@Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)

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

@ -11,7 +11,11 @@ import {
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
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 { Injectable } from '@nestjs/common';
@ -43,6 +47,39 @@ export class OrderService {
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(
data: Prisma.OrderCreateInput & {
accountId?: string;
@ -181,7 +218,15 @@ export class OrderService {
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);
}
@ -197,18 +242,16 @@ export class OrderService {
public async deleteOrders({
filters,
userCurrency,
userId
}: {
filters?: Filter[];
userCurrency: string;
userId: string;
}): Promise<number> {
const { activities } = await this.getOrders({
filters,
userId,
userCurrency,
includeDrafts: true,
userCurrency: undefined,
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(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ userId })
@ -230,7 +286,7 @@ export class OrderService {
return count;
}
public async getLatestOrder({ dataSource, symbol }: UniqueAsset) {
public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.order.findFirst({
orderBy: {
date: 'desc'
@ -270,7 +326,8 @@ export class OrderService {
withExcludedAccounts?: boolean;
}): Promise<Activities> {
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
{ date: 'asc' }
{ date: 'asc' },
{ id: 'asc' }
];
const where: Prisma.OrderWhereInput = { userId };
@ -290,10 +347,14 @@ export class OrderService {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
} = groupBy(filters, ({ type }) => {
return type;
});
const searchQuery = filters?.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
if (filtersByAccount?.length > 0) {
where.accountId = {
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) {
where.AND = [
{
@ -367,7 +452,7 @@ export class OrderService {
}
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }];
orderBy = [{ [sortColumn]: sortDirection }, { id: sortDirection }];
}
if (types) {
@ -406,7 +491,7 @@ export class OrderService {
this.prismaService.order.count({ where })
]);
const uniqueAssets = uniqBy(
const assetProfileIdentifiers = uniqBy(
orders.map(({ SymbolProfile }) => {
return {
dataSource: SymbolProfile.dataSource,
@ -421,10 +506,12 @@ export class OrderService {
}
);
const assetProfiles =
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
const assetProfiles = await this.symbolProfileService.getSymbolProfiles(
assetProfileIdentifiers
);
const activities = orders.map((order) => {
const activities = await Promise.all(
orders.map(async (order) => {
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => {
return (
dataSource === order.SymbolProfile.dataSource &&
@ -437,25 +524,65 @@ export class OrderService {
return {
...order,
value,
// TODO: Use exchange rate of date
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
feeInBaseCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
order.fee,
order.SymbolProfile.currency,
userCurrency
userCurrency,
order.date
),
SymbolProfile: assetProfile,
// TODO: Use exchange rate of date
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
valueInBaseCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
value,
order.SymbolProfile.currency,
userCurrency
userCurrency,
order.date
)
};
});
})
);
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(
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
): 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 {
AssetClass,
AssetSubClass,
@ -9,12 +12,12 @@ import { Transform, TransformFnParams } from 'class-transformer';
import {
IsArray,
IsEnum,
IsISO4217CurrencyCode,
IsISO8601,
IsNumber,
IsOptional,
IsString,
Min
Min,
Validate
} from 'class-validator';
import { isString } from 'lodash';
@ -38,10 +41,10 @@ export class UpdateOrderDto {
)
comment?: string;
@IsISO4217CurrencyCode()
@IsCurrencyCode()
currency: string;
@IsISO4217CurrencyCode()
@IsCurrencyCode()
@IsOptional()
customCurrency?: string;
@ -49,6 +52,7 @@ export class UpdateOrderDto {
dataSource: DataSource;
@IsISO8601()
@Validate(IsAfter1970Constraint)
date: string;
@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 { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import {
getFactor,
getInterval
} from '@ghostfolio/api/helper/portfolio.helper';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
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 { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import { Inject, Logger } from '@nestjs/common';
@ -43,21 +40,19 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator {
configurationService,
currency,
currentRateService,
dateRange,
exchangeRateDataService,
redisCacheService,
useCache,
userId
userId,
filters
}: {
accountBalanceItems: HistoricalDataItem[];
activities: Activity[];
configurationService: ConfigurationService;
currency: string;
currentRateService: CurrentRateService;
dateRange: DateRange;
exchangeRateDataService: ExchangeRateDataService;
redisCacheService: RedisCacheService;
useCache: boolean;
filters: Filter[];
userId: string;
},
@Inject()
@ -68,11 +63,10 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator {
activities,
configurationService,
currency,
filters,
currentRateService,
dateRange,
exchangeRateDataService,
redisCacheService,
useCache,
userId
});
}
@ -87,23 +81,31 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator {
withDataDecimation?: boolean;
withTimeWeightedReturn?: boolean;
}): Promise<HistoricalDataItem[]> {
const { endDate, startDate } = getInterval(dateRange, this.getStartDate());
const { endDate, startDate } = getIntervalFromDateRange(
dateRange,
this.getStartDate()
);
const daysInMarket = differenceInDays(endDate, startDate) + 1;
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;
let item = super.getChartData({
step,
let item = await super.getPerformance({
end: endDate,
start: startDate
});
if (!withTimeWeightedReturn) {
return item;
return item.chart;
} else {
let itemResult = await item;
let itemResult = item.chart;
let dates = itemResult.map((item) => parseDate(item.date));
let timeWeighted = await this.getTimeWeightedChartData({
dates
@ -145,13 +147,14 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator {
const end = new Date(Date.now());
const holdings = await this.getHoldings(orders, parseDate(start), end);
const marketMap = await this.currentRateService.getToday(
this.mapToDataGatheringItems(orders)
);
const marketMap = await this.currentRateService.getValues({
dataGatheringItems: this.mapToDataGatheringItems(orders),
dateQuery: { in: [end] }
});
const endString = format(end, DATE_FORMAT);
let exchangeRates = await Promise.all(
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 exchangeRate = await this.exchangeRateDataService.toCurrencyAtDate(
1,
@ -175,7 +178,7 @@ export class CPRPortfolioCalculator extends TWRPortfolioCalculator {
if (!holdings[endString][holding].toNumber()) {
return sum;
}
let symbol = marketMap.find((m) => m.symbol === holding);
let symbol = marketMap.values.find((m) => m.symbol === holding);
if (symbol?.marketPrice === undefined) {
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 { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces';
import {
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
export class MWRPortfolioCalculator extends PortfolioCalculator {
@ -13,7 +16,6 @@ export class MWRPortfolioCalculator extends PortfolioCalculator {
dataSource,
end,
exchangeRates,
isChartMode = false,
marketSymbolMap,
start,
step = 1,
@ -21,13 +23,12 @@ export class MWRPortfolioCalculator extends PortfolioCalculator {
}: {
end: Date;
exchangeRates: { [dateString: string]: number };
isChartMode?: boolean;
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
step?: number;
} & UniqueAsset): SymbolMetrics {
} & AssetProfileIdentifier): SymbolMetrics {
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,
countries: [],
createdAt: undefined,
holdings: [],
id: undefined,
sectors: [],
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@ -37,30 +36,23 @@ export class PortfolioCalculatorFactory {
activities,
calculationType,
currency,
dateRange = 'max',
hasFilters,
isExperimentalFeatures = false,
filters = [],
userId
}: {
accountBalanceItems?: HistoricalDataItem[];
activities: Activity[];
calculationType: PerformanceCalculationType;
currency: string;
dateRange?: DateRange;
hasFilters: boolean;
isExperimentalFeatures?: boolean;
filters?: Filter[];
userId: string;
}): PortfolioCalculator {
const useCache = !hasFilters && isExperimentalFeatures;
switch (calculationType) {
case PerformanceCalculationType.MWR:
return new MWRPortfolioCalculator({
accountBalanceItems,
activities,
currency,
dateRange,
useCache,
filters,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
@ -74,12 +66,11 @@ export class PortfolioCalculatorFactory {
activities,
currency,
currentRateService: this.currentRateService,
dateRange,
useCache,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService
redisCacheService: this.redisCacheService,
filters
},
this.orderservice
);
@ -90,12 +81,11 @@ export class PortfolioCalculatorFactory {
activities,
currency,
currentRateService: this.currentRateService,
dateRange,
useCache,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService
redisCacheService: this.redisCacheService,
filters
},
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 { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -68,9 +69,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => {
it.only('with BALN.SW buy and sell in two activities', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
@ -124,43 +123,22 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-22')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-22')
);
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.04408677396780965649'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
hasErrors: false,
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.05528341497550734703'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.05528341497550734703'
),
netPerformanceWithCurrencyEffect: new Big('-15.8'),
positions: [
{
averagePrice: new Big('0'),
@ -169,6 +147,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'),
@ -178,12 +157,12 @@ describe('PortfolioCalculator', () => {
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.05528341497550734703'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.05528341497550734703'
),
netPerformanceWithCurrencyEffect: new Big('-15.8'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.0552834149755073478')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('-15.8')
},
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'),
@ -205,6 +184,16 @@ describe('PortfolioCalculator', () => {
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([
{ date: '2021-11-22', investment: new Big('285.8') },
{ 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 { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -68,9 +69,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
@ -109,43 +108,22 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-22')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-22')
);
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.0440867739678096571'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
hasErrors: false,
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.0552834149755073478'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.0552834149755073478'
),
netPerformanceWithCurrencyEffect: new Big('-15.8'),
positions: [
{
averagePrice: new Big('0'),
@ -154,6 +132,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'),
@ -165,10 +144,12 @@ describe('PortfolioCalculator', () => {
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.0552834149755073478'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.0552834149755073478'
),
netPerformanceWithCurrencyEffect: new Big('-15.8'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.0552834149755073478')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('-15.8')
},
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'),
@ -188,6 +169,16 @@ describe('PortfolioCalculator', () => {
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([
{ date: '2021-11-22', investment: new Big('285.8') },
{ 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 { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -68,9 +69,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => {
it.only('with BALN.SW buy', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
@ -94,43 +93,22 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-30')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-30')
);
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('297.8'),
errors: [],
grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.09004392386530014641'
),
grossPerformanceWithCurrencyEffect: new Big('24.6'),
hasErrors: false,
netPerformance: new Big('23.05'),
netPerformancePercentage: new Big('0.08437042459736456808'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.08437042459736456808'
),
netPerformanceWithCurrencyEffect: new Big('23.05'),
positions: [
{
averagePrice: new Big('136.6'),
@ -139,6 +117,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('1.55'),
feeInBaseCurrency: new Big('1.55'),
firstBuyDate: '2021-11-30',
grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'),
@ -150,10 +129,18 @@ describe('PortfolioCalculator', () => {
investmentWithCurrencyEffect: new Big('273.2'),
netPerformance: new Big('23.05'),
netPerformancePercentage: new Big('0.08437042459736456808'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.08437042459736456808'
),
netPerformanceWithCurrencyEffect: new Big('23.05'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.08437042459736456808')
},
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,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('2'),
@ -173,6 +160,16 @@ describe('PortfolioCalculator', () => {
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([
{ 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 { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
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 () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2018-01-01').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2018-01-01').getTime());
const activities: Activity[] = [
{
@ -122,43 +122,23 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2015-01-01')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2015-01-01')
);
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('13298.425356'),
errors: [],
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.41978276196153750666'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'41.6401219622042072686'
),
grossPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'),
hasErrors: false,
netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.41978276196153750666'),
netPerformancePercentageWithCurrencyEffect: new Big(
'41.6401219622042072686'
),
netPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'),
positions: [
{
averagePrice: new Big('320.43'),
@ -167,33 +147,34 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.41978276196153750666'),
grossPerformance: new Big('27172.74').mul(0.97373),
grossPerformancePercentage: new Big('0.4241983590271396608571'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'41.6401219622042072686'
'0.4164017412624815597008'
),
grossPerformanceWithCurrencyEffect: new Big(
'26516.208701400000064086'
),
investment: new Big('320.43'),
investment: new Big('320.43').mul(0.97373),
investmentWithCurrencyEffect: new Big('318.542667299999967957'),
marketPrice: 13657.2,
marketPriceInBaseCurrency: 13298.425356,
netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.41978276196153750666'),
netPerformancePercentageWithCurrencyEffect: new Big(
'41.6401219622042072686'
),
netPerformanceWithCurrencyEffect: new Big(
'26516.208701400000064086'
),
netPerformance: new Big('27172.74').mul(0.97373),
netPerformancePercentage: new Big('0.4241983590271396608571'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.417188277288666871633')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('26516.208701400000064086')
},
quantity: new Big('1'),
symbol: 'BTCUSD',
tags: [],
timeWeightedInvestment: new Big('640.56763686131386861314'),
timeWeightedInvestment: new Big('623.73914366102470265325'),
timeWeightedInvestmentWithCurrencyEffect: new Big(
'636.79469348020066587024'
'636.79389574611155533947'
),
transactionCount: 2,
valueInBaseCurrency: new Big('13298.425356')
@ -201,12 +182,22 @@ describe('PortfolioCalculator', () => {
],
totalFeesWithCurrencyEffect: 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'),
totalLiabilitiesWithCurrencyEffect: 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([
{ date: '2015-01-01', investment: new Big('640.86') },
{ 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 { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -68,9 +69,7 @@ describe('PortfolioCalculator', () => {
describe('compute portfolio snapshot', () => {
it.only('with fee activity', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
@ -94,28 +93,15 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-30')
);
spy.mockRestore();
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('0'),
grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('0'),
hasErrors: true,
netPerformance: new Big('0'),
netPerformancePercentage: new Big('0'),
netPerformancePercentageWithCurrencyEffect: new Big('0'),
netPerformanceWithCurrencyEffect: new Big('0'),
positions: [
{
averagePrice: new Big('0'),
@ -124,6 +110,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('49'),
feeInBaseCurrency: new Big('49'),
firstBuyDate: '2021-09-01',
grossPerformance: null,
grossPerformancePercentage: null,
@ -135,8 +122,8 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 0,
netPerformance: null,
netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffect: null,
netPerformanceWithCurrencyEffect: null,
netPerformancePercentageWithCurrencyEffectMap: null,
netPerformanceWithCurrencyEffectMap: null,
quantity: new Big('0'),
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141',
tags: [],
@ -153,6 +140,16 @@ describe('PortfolioCalculator', () => {
totalLiabilitiesWithCurrencyEffect: 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 { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -81,9 +82,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => {
it.only('with GOOGL buy', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2023-07-10').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime());
const activities: Activity[] = [
{
@ -107,43 +106,22 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2023-01-03')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2023-01-03')
);
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('103.10483'),
errors: [],
grossPerformance: new Big('27.33'),
grossPerformancePercentage: new Big('0.3066651705565529623'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.25235044599563974109'
),
grossPerformanceWithCurrencyEffect: new Big('20.775774'),
hasErrors: false,
netPerformance: new Big('26.33'),
netPerformancePercentage: new Big('0.29544434470377019749'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.24112962014285697628'
),
netPerformanceWithCurrencyEffect: new Big('19.851974'),
positions: [
{
averagePrice: new Big('89.12'),
@ -152,40 +130,53 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('1'),
feeInBaseCurrency: new Big('0.9238'),
firstBuyDate: '2023-01-03',
grossPerformance: new Big('27.33'),
grossPerformance: new Big('27.33').mul(0.8854),
grossPerformancePercentage: new Big('0.3066651705565529623'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.25235044599563974109'
),
grossPerformanceWithCurrencyEffect: new Big('20.775774'),
investment: new Big('89.12'),
investment: new Big('89.12').mul(0.8854),
investmentWithCurrencyEffect: new Big('82.329056'),
netPerformance: new Big('26.33'),
netPerformance: new Big('26.33').mul(0.8854),
netPerformancePercentage: new Big('0.29544434470377019749'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.24112962014285697628'
),
netPerformanceWithCurrencyEffect: new Big('19.851974'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.24112962014285697628')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('19.851974')
},
marketPrice: 116.45,
marketPriceInBaseCurrency: 103.10483,
quantity: new Big('1'),
symbol: 'GOOGL',
tags: [],
timeWeightedInvestment: new Big('89.12'),
timeWeightedInvestment: new Big('89.12').mul(0.8854),
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
transactionCount: 1,
valueInBaseCurrency: new Big('103.10483')
}
],
totalFeesWithCurrencyEffect: new Big('1'),
totalFeesWithCurrencyEffect: new Big('0.9238'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('89.12'),
totalInvestment: new Big('89.12').mul(0.8854),
totalInvestmentWithCurrencyEffect: new Big('82.329056'),
totalLiabilitiesWithCurrencyEffect: 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([
{ 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 { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -68,9 +69,7 @@ describe('PortfolioCalculator', () => {
describe('compute portfolio snapshot', () => {
it.only('with item activity', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-01-31').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2022-01-31').getTime());
const activities: Activity[] = [
{
@ -94,28 +93,15 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-01-01')
);
spy.mockRestore();
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('0'),
grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('0'),
hasErrors: true,
netPerformance: new Big('0'),
netPerformancePercentage: new Big('0'),
netPerformancePercentageWithCurrencyEffect: new Big('0'),
netPerformanceWithCurrencyEffect: new Big('0'),
positions: [
{
averagePrice: new Big('500000'),
@ -124,6 +110,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-01-01',
grossPerformance: null,
grossPerformancePercentage: null,
@ -135,8 +122,8 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 500000,
netPerformance: null,
netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffect: null,
netPerformanceWithCurrencyEffect: null,
netPerformancePercentageWithCurrencyEffectMap: null,
netPerformanceWithCurrencyEffectMap: null,
quantity: new Big('0'),
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde',
tags: [],
@ -153,6 +140,16 @@ describe('PortfolioCalculator', () => {
totalLiabilitiesWithCurrencyEffect: 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', () => {
it.only('with liability activity', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-01-31').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2022-01-31').getTime());
const activities: Activity[] = [
{
@ -94,12 +92,9 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
spy.mockRestore();
const liabilitiesInBaseCurrency =
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 { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -81,9 +82,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => {
it.only('with MSFT buy', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2023-07-10').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime());
const activities: Activity[] = [
{
@ -122,15 +121,10 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2023-07-10')
);
spy.mockRestore();
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
expect(portfolioSnapshot).toMatchObject({
errors: [],
@ -161,6 +155,12 @@ describe('PortfolioCalculator', () => {
totalLiabilitiesWithCurrencyEffect: 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 { subDays } from 'date-fns';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -64,45 +65,28 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => {
it('with no orders', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const portfolioCalculator = factory.createCalculator({
activities: [],
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const start = subDays(new Date(Date.now()), 10);
const chartData = await portfolioCalculator.getChartData({ start });
const portfolioSnapshot =
await portfolioCalculator.computeSnapshot(start);
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffect: new Big(0),
netPerformanceWithCurrencyEffect: new Big(0),
historicalData: [],
positions: [],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
@ -114,12 +98,7 @@ describe('PortfolioCalculator', () => {
expect(investments).toEqual([]);
expect(investmentsByMonth).toEqual([
{
date: '2021-12-01',
investment: 0
}
]);
expect(investmentsByMonth).toEqual([]);
});
});
});

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 { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -68,9 +69,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell partially', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = [
{
@ -109,43 +108,22 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2022-03-07')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-03-07')
);
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('87.8'),
errors: [],
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.15113417083448194384'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.15113417083448194384'
),
grossPerformanceWithCurrencyEffect: new Big('21.93'),
hasErrors: false,
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.12184460284330327256'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.12184460284330327256'
),
netPerformanceWithCurrencyEffect: new Big('17.68'),
positions: [
{
averagePrice: new Big('75.80'),
@ -154,6 +132,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4.25'),
feeInBaseCurrency: new Big('4.25'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.15113417083448194384'),
@ -165,10 +144,12 @@ describe('PortfolioCalculator', () => {
investmentWithCurrencyEffect: new Big('75.80'),
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.12184460284330327256'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.12184460284330327256'
),
netPerformanceWithCurrencyEffect: new Big('17.68'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.12348284960422163588')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('17.68')
},
marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8,
quantity: new Big('1'),
@ -190,6 +171,16 @@ describe('PortfolioCalculator', () => {
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([
{ date: '2022-03-07', investment: new Big('151.6') },
{ 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 { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -68,9 +69,7 @@ describe('PortfolioCalculator', () => {
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = [
{
@ -109,28 +108,34 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2022-03-07')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-03-07')
);
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
data: portfolioSnapshot.historicalData,
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',
investmentValueWithCurrencyEffect: 151.6,
netPerformance: 0,
@ -145,12 +150,16 @@ describe('PortfolioCalculator', () => {
valueWithCurrencyEffect: 151.6
});
expect(chartData[chartData.length - 1]).toEqual({
expect(
portfolioSnapshot.historicalData[
portfolioSnapshot.historicalData.length - 1
]
).toEqual({
date: '2022-04-11',
investmentValueWithCurrencyEffect: 0,
netPerformance: 19.86,
netPerformanceInPercentage: 13.100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744,
netPerformanceInPercentage: 0.13100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
netPerformanceWithCurrencyEffect: 19.86,
netWorth: 0,
totalAccountBalance: 0,
@ -160,22 +169,10 @@ describe('PortfolioCalculator', () => {
valueWithCurrencyEffect: 0
});
expect(portfolioSnapshot).toEqual({
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.13100263852242744063'
),
grossPerformanceWithCurrencyEffect: new Big('19.86'),
hasErrors: false,
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.13100263852242744063'
),
netPerformanceWithCurrencyEffect: new Big('19.86'),
positions: [
{
averagePrice: new Big('0'),
@ -184,6 +181,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
@ -195,10 +193,12 @@ describe('PortfolioCalculator', () => {
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.13100263852242744063'
),
netPerformanceWithCurrencyEffect: new Big('19.86'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.13100263852242744063')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('19.86')
},
marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8,
quantity: new Big('0'),
@ -218,6 +218,16 @@ describe('PortfolioCalculator', () => {
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([
{ date: '2022-03-07', investment: new Big('151.6') },
{ 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 { 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 { GetValuesObject } from './interfaces/get-values-object.interface';
@ -24,6 +30,10 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 139.9 };
} else if (isSameDay(parseDate('2021-11-30'), date)) {
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)) {
return { marketPrice: 148.9 };
}
@ -97,7 +107,10 @@ export const CurrentRateServiceMock = {
}
}
} else {
for (const date of dateQuery.in) {
for (const date of eachDayOfInterval({
end: dateQuery.lt,
start: dateQuery.gte
})) {
for (const dataGatheringItem of dataGatheringItems) {
values.push({
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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.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';
@ -24,32 +24,32 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
});
},
getRange: ({
assetProfileIdentifiers,
dateRangeEnd,
dateRangeStart,
uniqueAssets
dateRangeStart
}: {
assetProfileIdentifiers: AssetProfileIdentifier[];
dateRangeEnd: Date;
dateRangeStart: Date;
uniqueAssets: UniqueAsset[];
}) => {
return Promise.resolve<MarketData[]>([
{
createdAt: dateRangeStart,
dataSource: uniqueAssets[0].dataSource,
dataSource: assetProfileIdentifiers[0].dataSource,
date: dateRangeStart,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: 1841.823902,
state: 'CLOSE',
symbol: uniqueAssets[0].symbol
symbol: assetProfileIdentifiers[0].symbol
},
{
createdAt: dateRangeEnd,
dataSource: uniqueAssets[0].dataSource,
dataSource: assetProfileIdentifiers[0].dataSource,
date: dateRangeEnd,
id: '082d6893-df27-4c91-8a5d-092e84315b56',
marketPrice: 1847.839966,
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 { DateQueryHelper } from '@ghostfolio/api/helper/dateQueryHelper';
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 { resetHours } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
DataProviderInfo,
ResponseError,
UniqueAsset
ResponseError
} from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -22,8 +20,6 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
@Injectable()
export class CurrentRateService {
private dateQueryHelper = new DateQueryHelper();
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
@ -42,33 +38,58 @@ export class CurrentRateService {
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
(!dateQuery.in || this.containsToday(dateQuery.in));
let { query, dates } = this.dateQueryHelper.handleDateQueryIn(dateQuery);
const promises: Promise<GetValueObject[]>[] = [];
const quoteErrors: ResponseError['errors'] = [];
const today = resetHours(new Date());
if (includesToday) {
promises.push(
this.getTodayPrivate(
dataGatheringItems,
dataProviderInfos,
today,
quoteErrors
)
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
);
}
const uniqueAssets: UniqueAsset[] = dataGatheringItems.map(
({ dataSource, symbol }) => {
return { dataSource, symbol };
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;
})
);
}
const assetProfileIdentifiers: AssetProfileIdentifier[] =
dataGatheringItems.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
});
promises.push(
this.marketDataService
.getRange({
dateQuery: query,
uniqueAssets
assetProfileIdentifiers,
dateQuery
})
.then((data) => {
return data.map(({ dataSource, date, marketPrice, symbol }) => {
@ -89,12 +110,9 @@ export class CurrentRateService {
errors: quoteErrors.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
}),
values: uniqBy(values, ({ date, symbol }) => `${date}-${symbol}`).filter(
(v) =>
dates?.length === 0 ||
dates.some((d: Date) => d.getTime() === v.date.getTime())
)
values: uniqBy(values, ({ date, symbol }) => `${date}-${symbol}`)
};
if (!isEmpty(quoteErrors)) {
for (const { dataSource, symbol } of quoteErrors) {
try {
@ -144,61 +162,6 @@ export class CurrentRateService {
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 {
for (const date of dates) {
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;
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;
feeInBaseCurrencyWithCurrencyEffect?: Big;
itemType?: 'end' | 'start';
unitPriceFromMarketData?: Big;
unitPriceInBaseCurrency?: 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 { OrderService } from '@ghostfolio/api/app/order/order.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 {
hasNotDefinedValuesInObject,
nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.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';
@ -15,6 +15,7 @@ import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
DEFAULT_CURRENCY,
HEADER_KEY_IMPERSONATION
@ -30,7 +31,8 @@ import {
} from '@ghostfolio/common/interfaces';
import {
hasReadRestrictedAccessPermission,
isRestrictedView
isRestrictedView,
permissions
} from '@ghostfolio/common/permissions';
import type {
DateRange,
@ -39,12 +41,14 @@ import type {
} from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
Headers,
HttpException,
Inject,
Param,
Put,
Query,
UseGuards,
UseInterceptors,
@ -52,12 +56,13 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { AssetClass, AssetSubClass } from '@prisma/client';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { PortfolioService } from './portfolio.service';
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@Controller('portfolio')
export class PortfolioController {
@ -224,6 +229,7 @@ export class PortfolioController {
: undefined,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
holdings: hasDetails ? portfolioPosition.holdings : [],
markets: hasDetails ? portfolioPosition.markets : undefined,
marketsAdvanced: hasDetails
? portfolioPosition.marketsAdvanced
@ -261,7 +267,7 @@ export class PortfolioController {
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const { endDate, startDate } = getInterval(dateRange);
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const { activities } = await this.orderService.getOrders({
endDate,
@ -517,9 +523,6 @@ export class PortfolioController {
@Param('accessId') accessId
): Promise<PortfolioPublicDetails> {
const access = await this.accessService.access({ id: accessId });
const user = await this.userService.user({
id: access.userId
});
if (!access) {
throw new HttpException(
@ -529,6 +532,11 @@ export class PortfolioController {
}
let hasDetails = true;
const user = await this.userService.user({
id: access.userId
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = user.subscription.type === 'Premium';
}
@ -585,25 +593,25 @@ export class PortfolioController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPosition(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource,
@Param('symbol') symbol
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<PortfolioHoldingDetail> {
const position = await this.portfolioService.getPosition(
const holding = await this.portfolioService.getPosition(
dataSource,
impersonationId,
symbol
);
if (position) {
return position;
}
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return holding;
}
@Get('report')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getReport(
@ -624,4 +632,36 @@ export class PortfolioController {
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 { OrderService } from '@ghostfolio/api/app/order/order.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import {
getFactor,
getInterval
} from '@ghostfolio/api/helper/portfolio.helper';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
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 { 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 { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
getAnnualizedPerformancePercent,
getIntervalFromDateRange
} from '@ghostfolio/common/calculation-helper';
import {
DEFAULT_CURRENCY,
EMERGENCY_FUND_TAG_ID,
@ -60,19 +61,21 @@ import {
DataSource,
Order,
Platform,
Prisma
Prisma,
Tag
} from '@prisma/client';
import { Big } from 'big.js';
import {
differenceInDays,
format,
isAfter,
isBefore,
isSameMonth,
isSameYear,
parseISO,
set
} 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 { 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
public async getDividends({
activities,
@ -268,11 +252,12 @@ export class PortfolioService {
}): Promise<PortfolioInvestments> {
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,
userId,
includeDrafts: true,
types: ['BUY', 'SELL'],
userCurrency: this.getUserCurrency()
});
@ -285,18 +270,16 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
dateRange,
filters,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
currency: this.request.user.Settings.settings.baseCurrency
});
const items = await portfolioCalculator.getChart({
dateRange,
withDataDecimation: false
const { historicalData } = await portfolioCalculator.getSnapshot();
const items = historicalData.filter(({ date }) => {
return !isBefore(date, startDate) && !isAfter(date, endDate);
});
let investments: InvestmentItem[];
@ -356,22 +339,19 @@ export class PortfolioService {
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
);
const { activities } = await this.orderService.getOrders({
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
filters,
userCurrency,
userId,
withExcludedAccounts
userId
});
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
dateRange,
filters,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency,
hasFilters: true, // disable cache
isExperimentalFeatures:
this.request.user?.Settings.settings.isExperimentalFeatures
currency: userCurrency
});
const { currentValueInBaseCurrency, hasErrors, positions } =
@ -425,10 +405,8 @@ export class PortfolioService {
};
});
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes({ user, items: dataGatheringItems }),
this.symbolProfileService.getSymbolProfiles(dataGatheringItems)
]);
const symbolProfiles =
await this.symbolProfileService.getSymbolProfiles(dataGatheringItems);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
for (const symbolProfile of symbolProfiles) {
@ -452,8 +430,8 @@ export class PortfolioService {
marketPrice,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
netPerformancePercentageWithCurrencyEffectMap,
netPerformanceWithCurrencyEffectMap,
quantity,
symbol,
tags,
@ -473,7 +451,6 @@ export class PortfolioService {
}
const assetProfile = symbolProfileMap[symbol];
const dataProviderResponse = dataProviderResponses[symbol];
let markets: PortfolioPosition['markets'];
let marketsAdvanced: PortfolioPosition['marketsAdvanced'];
@ -508,15 +485,27 @@ export class PortfolioService {
grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
holdings: assetProfile.holdings.map(
({ allocationInPercentage, name }) => {
return {
allocationInPercentage,
name,
valueInBaseCurrency: valueInBaseCurrency
.mul(allocationInPercentage)
.toNumber()
};
}
),
investment: investment.toNumber(),
marketState: dataProviderResponse?.marketState ?? 'delayed',
name: assetProfile.name,
netPerformance: netPerformance?.toNumber() ?? 0,
netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0,
netPerformancePercentWithCurrencyEffect:
netPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
netPerformancePercentageWithCurrencyEffectMap?.[
dateRange
]?.toNumber() ?? 0,
netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffect?.toNumber() ?? 0,
netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0,
quantity: quantity.toNumber(),
sectors: assetProfile.sectors,
url: assetProfile.url,
@ -585,7 +574,6 @@ export class PortfolioService {
if (withSummary) {
summary = await this.getSummary({
filteredValueInBaseCurrency,
holdings,
impersonationId,
portfolioCalculator,
userCurrency,
@ -690,10 +678,10 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const { activities } = await this.orderService.getOrders({
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
userCurrency,
userId,
withExcludedAccounts: true
userId
});
const orders = activities.filter(({ SymbolProfile }) => {
@ -748,10 +736,7 @@ export class PortfolioService {
);
}),
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency,
hasFilters: true,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
currency: userCurrency
});
const portfolioStart = portfolioCalculator.getStartDate();
@ -788,7 +773,7 @@ export class PortfolioService {
return Account;
});
const dividendYieldPercent = this.getAnnualizedPerformancePercent({
const dividendYieldPercent = getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercentage: timeWeightedInvestment.eq(0)
? new Big(0)
@ -796,7 +781,7 @@ export class PortfolioService {
});
const dividendYieldPercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({
getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
0
@ -928,9 +913,11 @@ export class PortfolioService {
netPerformance: position.netPerformance?.toNumber(),
netPerformancePercent: position.netPerformancePercentage?.toNumber(),
netPerformancePercentWithCurrencyEffect:
position.netPerformancePercentageWithCurrencyEffect?.toNumber(),
position.netPerformancePercentageWithCurrencyEffectMap?.[
'max'
]?.toNumber(),
netPerformanceWithCurrencyEffect:
position.netPerformanceWithCurrencyEffect?.toNumber(),
position.netPerformanceWithCurrencyEffectMap?.['max']?.toNumber(),
quantity: quantity.toNumber(),
value: this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice ?? 0).toNumber(),
@ -1033,10 +1020,8 @@ export class PortfolioService {
const userId = await this.getUserId(impersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const { endDate } = getInterval(dateRange);
const { activities } = await this.orderService.getOrders({
endDate,
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
filters,
userId,
userCurrency: this.getUserCurrency()
@ -1051,13 +1036,10 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
dateRange,
filters,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
currency: this.request.user.Settings.settings.baseCurrency
});
let { hasErrors, positions } = await portfolioCalculator.getSnapshot();
@ -1116,8 +1098,8 @@ export class PortfolioService {
investmentWithCurrencyEffect,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
netPerformancePercentageWithCurrencyEffectMap,
netPerformanceWithCurrencyEffectMap,
quantity,
symbol,
timeWeightedInvestment,
@ -1151,9 +1133,12 @@ export class PortfolioService {
netPerformancePercentage:
netPerformancePercentage?.toNumber() ?? null,
netPerformancePercentageWithCurrencyEffect:
netPerformancePercentageWithCurrencyEffect?.toNumber() ?? null,
netPerformancePercentageWithCurrencyEffectMap?.[
dateRange
]?.toNumber() ?? null,
netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffect?.toNumber() ?? null,
netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ??
null,
quantity: quantity.toNumber(),
timeWeightedInvestment: timeWeightedInvestment?.toNumber(),
timeWeightedInvestmentWithCurrencyEffect:
@ -1169,6 +1154,7 @@ export class PortfolioService {
dateRange = 'max',
filters,
impersonationId,
portfolioCalculator,
userId,
withExcludedAccounts = false,
calculateTimeWeightedPerformance = false
@ -1176,6 +1162,7 @@ export class PortfolioService {
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
portfolioCalculator?: PortfolioCalculator;
userId: string;
withExcludedAccounts?: boolean;
calculateTimeWeightedPerformance?: boolean;
@ -1214,14 +1201,11 @@ export class PortfolioService {
)
);
const { endDate } = getInterval(dateRange);
const { activities } = await this.orderService.getOrders({
endDate,
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
filters,
userCurrency,
userId,
withExcludedAccounts
userId
});
if (accountBalanceItems?.length <= 0 && activities?.length <= 0) {
@ -1232,10 +1216,6 @@ export class PortfolioService {
performance: {
currentNetWorth: 0,
currentValueInBaseCurrency: 0,
grossPerformance: 0,
grossPerformancePercentage: 0,
grossPerformancePercentageWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
netPerformance: 0,
netPerformancePercentage: 0,
netPerformancePercentageWithCurrencyEffect: 0,
@ -1245,93 +1225,47 @@ export class PortfolioService {
};
}
const portfolioCalculator = this.calculatorFactory.createCalculator({
accountBalanceItems,
activities,
dateRange,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const { chart } = await portfolioCalculator.getPerformance({
end: endDate,
start: startDate
});
const {
currentValueInBaseCurrency,
errors,
grossPerformance,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
grossPerformanceWithCurrencyEffect,
hasErrors,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
totalInvestment
} = await portfolioCalculator.getSnapshot();
let currentNetPerformance = netPerformance;
let currentNetPerformancePercentage = netPerformancePercentage;
let currentNetPerformancePercentageWithCurrencyEffect =
netPerformancePercentageWithCurrencyEffect;
let currentNetPerformanceWithCurrencyEffect =
netPerformanceWithCurrencyEffect;
let currentNetWorth = 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;
}
netWorth,
totalInvestment,
valueWithCurrencyEffect
} =
chart?.length > 0
? last(chart)
: {
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalInvestment: 0,
valueWithCurrencyEffect: 0
};
return {
errors,
hasErrors,
chart: items,
firstOrderDate: parseDate(items[0]?.date),
chart,
hasErrors: false,
firstOrderDate: parseDate(chart[0]?.date),
performance: {
currentNetWorth,
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
grossPerformance: grossPerformance.toNumber(),
grossPerformancePercentage: grossPerformancePercentage.toNumber(),
grossPerformancePercentageWithCurrencyEffect:
grossPerformancePercentageWithCurrencyEffect.toNumber(),
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect.toNumber(),
netPerformance: currentNetPerformance.toNumber(),
netPerformancePercentage: currentNetPerformancePercentage.toNumber(),
netPerformance,
netPerformanceWithCurrencyEffect,
totalInvestment,
currentNetWorth: netWorth,
currentValueInBaseCurrency: valueWithCurrencyEffect,
netPerformancePercentage: netPerformanceInPercentage,
netPerformancePercentageWithCurrencyEffect:
currentNetPerformancePercentageWithCurrencyEffect.toNumber(),
netPerformanceWithCurrencyEffect:
currentNetPerformanceWithCurrencyEffect.toNumber(),
totalInvestment: totalInvestment.toNumber()
netPerformanceInPercentageWithCurrencyEffect
}
};
}
@ -1342,7 +1276,8 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const { activities } = await this.orderService.getOrders({
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
userCurrency,
userId
});
@ -1351,10 +1286,7 @@ export class PortfolioService {
activities,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency,
hasFilters: false,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
currency: this.request.user.Settings.settings.baseCurrency
});
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
private async getCashPositions({
cashDetails,
@ -1589,9 +1540,9 @@ export class PortfolioService {
grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
holdings: [],
investment: balance,
marketPrice: 0,
marketState: 'open',
name: currency,
netPerformance: 0,
netPerformancePercent: 0,
@ -1714,7 +1665,6 @@ export class PortfolioService {
balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency,
filteredValueInBaseCurrency,
holdings,
impersonationId,
portfolioCalculator,
userCurrency,
@ -1723,7 +1673,6 @@ export class PortfolioService {
balanceInBaseCurrency: number;
emergencyFundPositionsValueInBaseCurrency: number;
filteredValueInBaseCurrency: Big;
holdings: PortfolioDetails['holdings'];
impersonationId: string;
portfolioCalculator: PortfolioCalculator;
userCurrency: string;
@ -1748,18 +1697,20 @@ export class PortfolioService {
}
}
const { currentValueInBaseCurrency, totalInvestment } =
await portfolioCalculator.getSnapshot();
const { performance } = await this.getPerformance({
impersonationId,
userId
});
const {
currentValueInBaseCurrency,
grossPerformance,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
grossPerformanceWithCurrencyEffect,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
totalInvestment
} = await portfolioCalculator.getSnapshot();
netPerformanceWithCurrencyEffect
} = performance;
const dividendInBaseCurrency =
await portfolioCalculator.getDividendInBaseCurrency();
@ -1841,13 +1792,13 @@ export class PortfolioService {
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({
const annualizedPerformancePercent = getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercentage: new Big(netPerformancePercentage)
})?.toNumber();
const annualizedPerformancePercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({
getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercentage: new Big(
netPerformancePercentageWithCurrencyEffect
@ -1860,6 +1811,10 @@ export class PortfolioService {
cash,
excludedAccountsAndActivities,
firstOrderDate,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
totalBuy,
totalSell,
committedFunds: committedFunds.toNumber(),
@ -1880,21 +1835,15 @@ export class PortfolioService {
fireWealth: new Big(currentValueInBaseCurrency)
.minus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(),
grossPerformance: grossPerformance.toNumber(),
grossPerformancePercentage: grossPerformancePercentage.toNumber(),
grossPerformancePercentageWithCurrencyEffect:
grossPerformancePercentageWithCurrencyEffect.toNumber(),
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect.toNumber(),
grossPerformance: new Big(netPerformance).plus(fees).toNumber(),
grossPerformanceWithCurrencyEffect: new Big(
netPerformanceWithCurrencyEffect
)
.plus(fees)
.toNumber(),
interest: interest.toNumber(),
items: valuables.toNumber(),
liabilities: liabilities.toNumber(),
netPerformance: netPerformance.toNumber(),
netPerformancePercentage: netPerformancePercentage.toNumber(),
netPerformancePercentageWithCurrencyEffect:
netPerformancePercentageWithCurrencyEffect.toNumber(),
netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffect.toNumber(),
ordersCount: activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,

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

@ -12,11 +12,8 @@ export class RulesService {
aRules: Rule<T>[],
aUserSettings: UserSettings
) {
return aRules
.filter((rule) => {
return rule.getSettings(aUserSettings)?.isActive;
})
.map((rule) => {
return aRules.map((rule) => {
if (rule.getSettings(aUserSettings)?.isActive) {
const { evaluation, value } = rule.evaluate(
rule.getSettings(aUserSettings)
);
@ -24,9 +21,17 @@ export class RulesService {
return {
evaluation,
value,
isActive: true,
key: rule.getKey(),
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 { 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 { Inject, Injectable, Logger } from '@nestjs/common';
import { createHash } from 'crypto';
import type { RedisCache } from './interfaces/redis-cache.interface';
@ -24,11 +25,37 @@ export class RedisCacheService {
return this.cache.get(key);
}
public getPortfolioSnapshotKey({ userId }: { userId: string }) {
return `portfolio-snapshot-${userId}`;
public async getKeys(aPrefix?: string): Promise<string[]> {
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 })}`;
}
@ -36,6 +63,20 @@ export class RedisCacheService {
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() {
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 {
DATE_FORMAT,
getYesterday,
interpolate
} from '@ghostfolio/common/helper';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns';
@ -14,7 +16,9 @@ import * as path from 'path';
export class SitemapController {
public sitemapXml = '';
public constructor() {
public constructor(
private readonly configurationService: ConfigurationService
) {
try {
this.sitemapXml = fs.readFileSync(
path.join(__dirname, 'assets', 'sitemap.xml'),
@ -25,11 +29,51 @@ export class SitemapController {
@Get()
@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.send(
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 { SitemapController } from './sitemap.controller';
@Module({
controllers: [SitemapController]
controllers: [SitemapController],
imports: [ConfigurationModule]
})
export class SitemapModule {}

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

@ -22,7 +22,7 @@ export class SubscriptionService {
this.stripe = new Stripe(
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;
historicalData: HistoricalDataItem[];
marketPrice: number;

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

@ -40,13 +40,13 @@ export class SymbolService {
const days = includeHistoricalData;
const marketData = await this.marketDataService.getRange({
dateQuery: { gte: subDays(new Date(), days) },
uniqueAssets: [
assetProfileIdentifiers: [
{
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
}
]
],
dateQuery: { gte: subDays(new Date(), days) }
});
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 {
ColorScheme,
DateRange,
ViewMode
HoldingsViewMode,
ViewMode,
XRayRulesSettings
} from '@ghostfolio/common/types';
import {
IsArray,
IsBoolean,
IsISO4217CurrencyCode,
IsISO8601,
IsIn,
IsNumber,
@ -21,7 +23,7 @@ export class UpdateUserSettingDto {
@IsOptional()
annualInterestRate?: number;
@IsISO4217CurrencyCode()
@IsCurrencyCode()
@IsOptional()
baseCurrency?: string;
@ -69,6 +71,10 @@ export class UpdateUserSettingDto {
@IsOptional()
'filters.tags'?: string[];
@IsIn(<HoldingsViewMode[]>['CHART', 'TABLE'])
@IsOptional()
holdingsViewMode?: HoldingsViewMode;
@IsBoolean()
@IsOptional()
isExperimentalFeatures?: boolean;
@ -100,4 +106,7 @@ export class UpdateUserSettingDto {
@IsIn(<ViewMode[]>['DEFAULT', 'ZEN'])
@IsOptional()
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 { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { size } from 'lodash';
import { merge, size } from 'lodash';
import { DeleteOwnUserDto } from './delete-own-user.dto';
import { UserItem } from './interfaces/user-item.interface';
@ -144,10 +144,13 @@ export class UserController {
);
}
const userSettings: UserSettings = {
...(<UserSettings>this.request.user.Settings.settings),
...data
};
const emitPortfolioChangedEvent = 'baseCurrency' in data;
const userSettings: UserSettings = merge(
{},
<UserSettings>this.request.user.Settings.settings,
data
);
for (const key in userSettings) {
if (userSettings[key] === false || userSettings[key] === null) {
@ -156,6 +159,7 @@ export class UserController {
}
return this.userService.updateUserSetting({
emitPortfolioChangedEvent,
userSettings,
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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.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,
signOptions: { expiresIn: '30 days' }
}),
OrderModule,
PrismaModule,
PropertyModule,
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 { environment } from '@ghostfolio/api/environments/environment';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
@ -40,6 +41,7 @@ export class UserService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly eventEmitter: EventEmitter2,
private readonly orderService: OrderService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService,
@ -188,13 +190,25 @@ export class UserService {
(user.Settings.settings as UserSettings).dateRange =
(user.Settings.settings as UserSettings).viewMode === 'ZEN'
? 'max'
: (user.Settings.settings as UserSettings)?.dateRange ?? 'max';
: ((user.Settings.settings as UserSettings)?.dateRange ?? 'max');
// Set default value for view mode
if (!(user.Settings.settings as UserSettings).viewMode) {
(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);
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
@ -235,11 +249,15 @@ export class UserService {
currentPermissions = without(
currentPermissions,
permissions.accessHoldingsChart,
permissions.createAccess
);
// Reset benchmark
user.Settings.settings.benchmark = undefined;
// Reset holdings view mode
user.Settings.settings.holdingsViewMode = undefined;
} else if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.reportDataGlitch);
@ -398,8 +416,8 @@ export class UserService {
} catch {}
try {
await this.prismaService.order.deleteMany({
where: { userId: where.id }
await this.orderService.deleteOrders({
userId: where.id
});
} catch {}
@ -415,9 +433,11 @@ export class UserService {
}
public async updateUserSetting({
emitPortfolioChangedEvent,
userId,
userSettings
}: {
emitPortfolioChangedEvent: boolean;
userId: string;
userSettings: UserSettings;
}) {
@ -438,12 +458,14 @@ export class UserService {
}
});
if (emitPortfolioChangedEvent) {
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId
})
);
}
return settings;
}

1110
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'
);
this.redisCacheService.remove(
this.redisCacheService.getPortfolioSnapshotKey({
this.redisCacheService.removePortfolioSnapshotsByUserId({
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 {
endOfDay,
max,
subDays,
startOfMonth,
startOfWeek,
startOfYear,
subYears,
endOfYear
} from 'date-fns';
export function getFactor(activityType: ActivityType) {
let factor: number;
@ -31,61 +18,3 @@ export function getFactor(activityType: ActivityType) {
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;
if (maxInvestmentRatio > ruleSettings.threshold) {
if (maxInvestmentRatio > ruleSettings.thresholdMax) {
return {
evaluation: `Over ${
ruleSettings.threshold * 100
ruleSettings.thresholdMax * 100
}% of your current investment is at ${maxItem.name} (${(
maxInvestmentRatio * 100
).toPrecision(3)}%)`,
@ -70,7 +70,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
evaluation: `The major part of your current investment is at ${
maxItem.name
} (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${
ruleSettings.threshold * 100
ruleSettings.thresholdMax * 100
}%`,
value: true
};
@ -79,13 +79,13 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
isActive: aUserSettings.xRayRules[this.getKey()].isActive,
thresholdMax: 0.5
};
}
}
interface Settings extends RuleSettings {
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 {
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 {
return {
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;
if (maxValueRatio > ruleSettings.threshold) {
if (maxValueRatio > ruleSettings.thresholdMax) {
return {
evaluation: `Over ${
ruleSettings.threshold * 100
ruleSettings.thresholdMax * 100
}% of your current investment is in ${maxItem.groupKey} (${(
maxValueRatio * 100
).toPrecision(3)}%)`,
@ -56,7 +56,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
evaluation: `The major part of your current investment is in ${
maxItem?.groupKey ?? ruleSettings.baseCurrency
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${
ruleSettings.threshold * 100
ruleSettings.thresholdMax * 100
}%`,
value: true
};
@ -65,13 +65,13 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
isActive: aUserSettings.xRayRules[this.getKey()].isActive,
thresholdMax: 0.5
};
}
}
interface Settings extends RuleSettings {
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) {
if (this.emergencyFund > ruleSettings.threshold) {
if (this.emergencyFund < ruleSettings.thresholdMin) {
return {
evaluation: 'An emergency fund has been set up',
value: true
evaluation: 'No emergency fund has been set up',
value: false
};
}
return {
evaluation: 'No emergency fund has been set up',
value: false
evaluation: 'An emergency fund has been set up',
value: true
};
}
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0
isActive: aUserSettings.xRayRules[this.getKey()].isActive,
thresholdMin: 0
};
}
}
interface Settings extends RuleSettings {
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
: 0;
if (feeRatio > ruleSettings.threshold) {
if (feeRatio > ruleSettings.thresholdMax) {
return {
evaluation: `The fees do exceed ${
ruleSettings.threshold * 100
ruleSettings.thresholdMax * 100
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
value: false
};
@ -37,7 +37,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
return {
evaluation: `The fees do not exceed ${
ruleSettings.threshold * 100
ruleSettings.thresholdMax * 100
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
value: true
};
@ -46,13 +46,13 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.01
isActive: aUserSettings.xRayRules[this.getKey()].isActive,
thresholdMax: 0.01
};
}
}
interface Settings extends RuleSettings {
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 { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str, url } from 'envalid';
import ms from 'ms';
@Injectable()
export class ConfigurationService {
@ -20,7 +21,7 @@ export class ConfigurationService {
API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }),
API_KEY_OPEN_FIGI: 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 }),
LOG_LEVEL: str({ default: '' }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
@ -42,6 +43,7 @@ export class ConfigurationService {
HOST: host({ default: '0.0.0.0' }),
JWT_SECRET_KEY: str({}),
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_CHART_ITEMS: num({ default: 365 }),
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
PORT: port({ default: 3333 }),
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)
public async runEverySundayAtTwelvePm() {
if (await this.isDataGatheringEnabled()) {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => {
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return {
data: {
dataSource,

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

@ -7,7 +7,7 @@ import {
GATHER_HISTORICAL_MARKET_DATA_PROCESS
} from '@ghostfolio/common/config';
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 { Injectable, Logger } from '@nestjs/common';
@ -35,7 +35,7 @@ export class DataGatheringProcessor {
) {}
@Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS })
public async gatherAssetProfile(job: Job<UniqueAsset>) {
public async gatherAssetProfile(job: Job<AssetProfileIdentifier>) {
try {
Logger.log(
`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_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
PROPERTY_BENCHMARKS
@ -19,7 +20,10 @@ import {
getAssetProfileIdentifier,
resetHours
} from '@ghostfolio/common/helper';
import { BenchmarkProperty, UniqueAsset } from '@ghostfolio/common/interfaces';
import {
AssetProfileIdentifier,
BenchmarkProperty
} from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common';
@ -65,9 +69,22 @@ export class DataGatheringService {
}
public async gather7Days() {
const dataGatheringItems = await this.getSymbols7D();
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
});
}
@ -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 });
const dataGatheringItems = (await this.getSymbolsMax()).filter(
@ -135,23 +152,29 @@ export class DataGatheringService {
}
}
public async gatherAssetProfiles(aUniqueAssets?: UniqueAsset[]) {
let uniqueAssets = aUniqueAssets?.filter((dataGatheringItem) => {
public async gatherAssetProfiles(
aAssetProfileIdentifiers?: AssetProfileIdentifier[]
) {
let assetProfileIdentifiers = aAssetProfileIdentifiers?.filter(
(dataGatheringItem) => {
return dataGatheringItem.dataSource !== 'MANUAL';
});
}
);
if (!uniqueAssets) {
uniqueAssets = await this.getUniqueAssets();
if (!assetProfileIdentifiers) {
assetProfileIdentifiers = await this.getAllAssetProfileIdentifiers();
}
if (uniqueAssets.length <= 0) {
if (assetProfileIdentifiers.length <= 0) {
return;
}
const assetProfiles =
await this.dataProviderService.getAssetProfiles(uniqueAssets);
const symbolProfiles =
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
const assetProfiles = await this.dataProviderService.getAssetProfiles(
assetProfileIdentifiers
);
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
assetProfileIdentifiers
);
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => {
@ -184,6 +207,7 @@ export class DataGatheringService {
figi,
figiComposite,
figiShareClass,
holdings,
isin,
name,
sectors,
@ -201,6 +225,7 @@ export class DataGatheringService {
figi,
figiComposite,
figiShareClass,
holdings,
isin,
name,
sectors,
@ -215,6 +240,7 @@ export class DataGatheringService {
figi,
figiComposite,
figiShareClass,
holdings,
isin,
name,
sectors,
@ -234,7 +260,7 @@ export class DataGatheringService {
'DataGatheringService'
);
if (uniqueAssets.length === 1) {
if (assetProfileIdentifiers.length === 1) {
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({
orderBy: [{ symbol: 'asc' }]
});
@ -290,73 +318,83 @@ export class DataGatheringService {
});
}
private getEarliestDate(aStartDate: Date) {
return min([aStartDate, subYears(new Date(), 10)]);
}
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 = (
private async getAssetProfileIdentifiersWithCompleteMarketData(): Promise<
AssetProfileIdentifier[]
> {
return (
await this.prismaService.marketData.groupBy({
_count: true,
by: ['symbol'],
by: ['dataSource', 'symbol'],
orderBy: [{ symbol: 'asc' }],
where: {
date: { gt: startDate },
date: { gt: subDays(resetHours(new Date()), 7) },
state: 'CLOSE'
}
})
)
.filter((group) => {
return group._count >= 6;
.filter(({ _count }) => {
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) => {
return group.symbol;
.map(({ dataSource, 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 }) => {
const manualDataSourceWithScraperConfiguration =
dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
return (
!symbolsWithCompleteMarketData.includes(symbol) &&
!assetProfileIdentifiersWithCompleteMarketData.some((item) => {
return item.dataSource === dataSource && item.symbol === symbol;
}) &&
(dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration)
);
})
.map((symbolProfile) => {
return {
...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[]> {

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

@ -36,6 +36,7 @@ export class DataEnhancerService {
if (
(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
) {
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 { 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 { 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 (
!response.sectors ||
(response.sectors as unknown as Sector[]).length === 0
) {
response.sectors = [];
for (const [name, value] of Object.entries<any>(
holdings?.sectors ?? {}
)) {

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

@ -15,8 +15,13 @@ import {
DERIVED_CURRENCIES,
PROPERTY_DATA_SOURCE_MAPPING
} from '@ghostfolio/common/config';
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import {
DATE_FORMAT,
getCurrencyFromSymbol,
getStartOfUtcDate,
isDerivedCurrency
} from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common';
@ -71,7 +76,7 @@ export class DataProviderService {
return false;
}
public async getAssetProfiles(items: UniqueAsset[]): Promise<{
public async getAssetProfiles(items: AssetProfileIdentifier[]): Promise<{
[symbol: string]: Partial<SymbolProfile>;
}> {
const response: {
@ -169,7 +174,7 @@ export class DataProviderService {
}
public async getHistorical(
aItems: UniqueAsset[],
aItems: AssetProfileIdentifier[],
aGranularity: Granularity = 'month',
from: Date,
to: Date
@ -239,7 +244,7 @@ export class DataProviderService {
from,
to
}: {
dataGatheringItems: UniqueAsset[];
dataGatheringItems: AssetProfileIdentifier[];
from: Date;
to: Date;
}): Promise<{
@ -347,7 +352,7 @@ export class DataProviderService {
useCache = true,
user
}: {
items: UniqueAsset[];
items: AssetProfileIdentifier[];
requestTimeout?: number;
useCache?: boolean;
user?: UserWithSettings;
@ -373,7 +378,7 @@ export class DataProviderService {
}
// Get items from cache
const itemsToFetch: UniqueAsset[] = [];
const itemsToFetch: AssetProfileIdentifier[] = [];
for (const { dataSource, symbol } of items) {
if (useCache) {
@ -425,13 +430,18 @@ export class DataProviderService {
continue;
}
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
const symbols = dataGatheringItems
.filter(({ symbol }) => {
return !isDerivedCurrency(getCurrencyFromSymbol(symbol));
})
.map(({ symbol }) => {
return symbol;
});
const maximumNumberOfSymbolsPerRequest =
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
Number.MAX_SAFE_INTEGER;
for (
let i = 0;
i < symbols.length;
@ -520,7 +530,8 @@ export class DataProviderService {
.filter((symbol) => {
return (
isNumber(response[symbol].marketPrice) &&
response[symbol].marketPrice > 0
response[symbol].marketPrice > 0 &&
response[symbol].marketState === 'open'
);
})
.map((symbol) => {
@ -645,7 +656,7 @@ export class DataProviderService {
dataGatheringItems
}: {
currency: string;
dataGatheringItems: UniqueAsset[];
dataGatheringItems: AssetProfileIdentifier[];
}) {
return dataGatheringItems.some(({ dataSource, symbol }) => {
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) {
let currency: string;
if (code.endsWith('.FOREX')) {
if (this.isForex(code)) {
currency = this.convertFromEodSymbol(code)?.replace(
DEFAULT_CURRENCY,
''
@ -272,7 +272,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
currency,
dataSource: this.getName(),
marketPrice: close,
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
marketState:
this.isForex(code) || isToday(new Date(timestamp * 1000))
? 'open'
: 'closed'
};
} else {
Logger.error(
@ -311,7 +314,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
items: searchResult
.filter(({ currency, symbol }) => {
// Remove 'NA' currency and exchange rates
return currency?.length === 3 && !symbol.endsWith('.FOREX');
return currency?.length === 3 && !this.isForex(symbol);
})
.map(
({
@ -349,7 +352,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
private convertFromEodSymbol(aEodSymbol: string) {
let symbol = aEodSymbol;
if (symbol.endsWith('.FOREX')) {
if (this.isForex(symbol)) {
symbol = symbol.replace('GBX', 'GBp');
symbol = symbol.replace('.FOREX', '');
}
@ -451,6 +454,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
return searchResult;
}
private isForex(aCode: string) {
return aCode?.endsWith('.FOREX') || false;
}
private parseAssetClass({
Exchange,
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());
for (const { currency, symbol } of symbolProfiles) {
let marketPrice = marketData.find((marketDataItem) => {
let marketPrice =
marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbol;
})?.marketPrice;
})?.marketPrice ?? 0;
response[symbol] = {
currency,
@ -264,7 +265,7 @@ export class ManualService implements DataProviderInterface {
signal: abortController.signal
});
if (headers['content-type'] === 'application/json') {
if (headers['content-type'].includes('application/json')) {
const data = JSON.parse(body);
const value = String(
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 marketData = await this.marketDataService.getRange({
dateQuery: { gte: startDate, lt: endDate },
uniqueAssets: [
assetProfileIdentifiers: [
{
dataSource,
symbol
}
]
],
dateQuery: { gte: startDate, lt: endDate }
});
if (marketData?.length > 0) {
@ -392,13 +392,13 @@ export class ExchangeRateDataService {
}
} else {
const marketData = await this.marketDataService.getRange({
dateQuery: { gte: startDate, lt: endDate },
uniqueAssets: [
assetProfileIdentifiers: [
{
dataSource,
symbol: `${DEFAULT_CURRENCY}${currencyFrom}`
}
]
],
dateQuery: { gte: startDate, lt: endDate }
});
for (const { date, marketPrice } of marketData) {
@ -415,16 +415,16 @@ export class ExchangeRateDataService {
}
} else {
const marketData = await this.marketDataService.getRange({
dateQuery: {
gte: startDate,
lt: endDate
},
uniqueAssets: [
assetProfileIdentifiers: [
{
dataSource,
symbol: `${DEFAULT_CURRENCY}${currencyTo}`
}
]
],
dateQuery: {
gte: startDate,
lt: endDate
}
});
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;
JWT_SECRET_KEY: string;
MAX_ACTIVITIES_TO_IMPORT: number;
MAX_CHART_ITEMS: number;
MAX_ITEM_IN_CACHE: number;
PORT: 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 {
@ -34,6 +37,6 @@ export interface IDataProviderResponse {
marketState: MarketState;
}
export interface IDataGatheringItem extends UniqueAsset {
export interface IDataGatheringItem extends AssetProfileIdentifier {
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 { BatchPrismaClient } from '@ghostfolio/common/chunkhelper';
import { resetHours } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import {
@ -24,7 +24,7 @@ export class MarketDataService {
private dateQueryHelper = new DateQueryHelper();
public async deleteMany({ dataSource, symbol }: UniqueAsset) {
public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.marketData.deleteMany({
where: {
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({
select: {
date: true,
@ -66,11 +66,11 @@ export class MarketDataService {
}
public async getRange({
dateQuery,
uniqueAssets
assetProfileIdentifiers,
dateQuery
}: {
assetProfileIdentifiers: AssetProfileIdentifier[];
dateQuery: DateQuery;
uniqueAssets: UniqueAsset[];
}): Promise<MarketData[]> {
return this.prismaService.marketData.findMany({
orderBy: [
@ -83,13 +83,13 @@ export class MarketDataService {
],
where: {
dataSource: {
in: uniqueAssets.map(({ dataSource }) => {
in: assetProfileIdentifiers.map(({ dataSource }) => {
return dataSource;
})
},
date: dateQuery,
symbol: {
in: uniqueAssets.map(({ symbol }) => {
in: assetProfileIdentifiers.map(({ 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 { UNKNOWN_KEY } from '@ghostfolio/common/config';
import {
AssetProfileIdentifier,
EnhancedSymbolProfile,
ScraperConfiguration,
UniqueAsset
Holding,
ScraperConfiguration
} from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
@ -28,7 +29,7 @@ export class SymbolProfileService {
return this.prismaService.symbolProfile.create({ data: assetProfile });
}
public async delete({ dataSource, symbol }: UniqueAsset) {
public async delete({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.symbolProfile.delete({
where: { dataSource_symbol: { dataSource, symbol } }
});
@ -42,7 +43,7 @@ export class SymbolProfileService {
@LogPerformance
public async getSymbolProfiles(
aUniqueAssets: UniqueAsset[]
aAssetProfileIdentifiers: AssetProfileIdentifier[]
): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile
.findMany({
@ -61,7 +62,7 @@ export class SymbolProfileService {
SymbolProfileOverrides: true
},
where: {
OR: aUniqueAssets.map(({ dataSource, symbol }) => {
OR: aAssetProfileIdentifiers.map(({ dataSource, symbol }) => {
return {
dataSource,
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({
assetClass,
assetSubClass,
@ -106,6 +141,7 @@ export class SymbolProfileService {
countries,
currency,
dataSource,
holdings,
name,
tags,
scraperConfiguration,
@ -114,7 +150,7 @@ export class SymbolProfileService {
symbolMapping,
SymbolProfileOverrides,
url
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
return this.prismaService.symbolProfile.update({
data: {
assetClass,
@ -122,6 +158,7 @@ export class SymbolProfileService {
comment,
countries,
currency,
holdings,
name,
tags,
scraperConfiguration,
@ -152,6 +189,7 @@ export class SymbolProfileService {
symbolProfile?.countries as unknown as Prisma.JsonArray
),
dateOfFirstActivity: <Date>undefined,
holdings: this.getHoldings(symbolProfile),
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(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;
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(
symbolProfile: SymbolProfile
): 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);
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'
);
}

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

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

Loading…
Cancel
Save