Browse Source

Merge branch 'main' into feature/migrate-from-angular-material-design-2-to-3

pull/5311/head
Thomas Kaul 3 weeks ago
parent
commit
99adf9eca8
  1. 1
      .env.dev
  2. 4
      .env.example
  3. 151
      .eslintrc.json
  4. 4
      .github/ISSUE_TEMPLATE/bug_report.md
  5. 2
      .github/workflows/build-code.yml
  6. 2
      .github/workflows/docker-image.yml
  7. 40
      .github/workflows/extract-locales.yml
  8. 2
      .gitignore
  9. 2
      .nvmrc
  10. 1061
      CHANGELOG.md
  11. 25
      DEVELOPMENT.md
  12. 19
      Dockerfile
  13. 35
      README.md
  14. 13
      SECURITY.md
  15. 22
      apps/api/.eslintrc.json
  16. 31
      apps/api/eslint.config.cjs
  17. 12
      apps/api/src/app/access/access.controller.ts
  18. 2
      apps/api/src/app/access/access.service.ts
  19. 12
      apps/api/src/app/account-balance/account-balance.service.ts
  20. 30
      apps/api/src/app/account/account.controller.ts
  21. 37
      apps/api/src/app/account/account.service.ts
  22. 122
      apps/api/src/app/admin/admin.controller.ts
  23. 6
      apps/api/src/app/admin/admin.module.ts
  24. 305
      apps/api/src/app/admin/admin.service.ts
  25. 92
      apps/api/src/app/admin/create-asset-profile.dto.ts
  26. 19
      apps/api/src/app/admin/update-asset-profile.dto.ts
  27. 41
      apps/api/src/app/app.module.ts
  28. 18
      apps/api/src/app/auth/api-key.strategy.ts
  29. 14
      apps/api/src/app/auth/auth.controller.ts
  30. 8
      apps/api/src/app/auth/auth.service.ts
  31. 16
      apps/api/src/app/auth/jwt.strategy.ts
  32. 88
      apps/api/src/app/auth/web-auth.service.ts
  33. 59
      apps/api/src/app/endpoints/ai/ai.controller.ts
  34. 59
      apps/api/src/app/endpoints/ai/ai.module.ts
  35. 100
      apps/api/src/app/endpoints/ai/ai.service.ts
  36. 46
      apps/api/src/app/endpoints/assets/assets.controller.ts
  37. 11
      apps/api/src/app/endpoints/assets/assets.module.ts
  38. 40
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  39. 65
      apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
  40. 163
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  41. 160
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  42. 57
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  43. 65
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  44. 8
      apps/api/src/app/endpoints/market-data/market-data.module.ts
  45. 5
      apps/api/src/app/endpoints/public/public.controller.ts
  46. 4
      apps/api/src/app/endpoints/public/public.module.ts
  47. 51
      apps/api/src/app/endpoints/sitemap/sitemap.controller.ts
  48. 5
      apps/api/src/app/endpoints/sitemap/sitemap.module.ts
  49. 116
      apps/api/src/app/endpoints/sitemap/sitemap.service.ts
  50. 10
      apps/api/src/app/endpoints/tags/create-tag.dto.ts
  51. 87
      apps/api/src/app/endpoints/tags/tags.controller.ts
  52. 12
      apps/api/src/app/endpoints/tags/tags.module.ts
  53. 13
      apps/api/src/app/endpoints/tags/update-tag.dto.ts
  54. 10
      apps/api/src/app/endpoints/watchlist/create-watchlist-item.dto.ts
  55. 100
      apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
  56. 27
      apps/api/src/app/endpoints/watchlist/watchlist.module.ts
  57. 150
      apps/api/src/app/endpoints/watchlist/watchlist.service.ts
  58. 20
      apps/api/src/app/export/export.controller.ts
  59. 12
      apps/api/src/app/export/export.module.ts
  60. 198
      apps/api/src/app/export/export.service.ts
  61. 60
      apps/api/src/app/health/health.controller.ts
  62. 4
      apps/api/src/app/health/health.module.ts
  63. 27
      apps/api/src/app/health/health.service.ts
  64. 10
      apps/api/src/app/import/create-account-with-balances.dto.ts
  65. 17
      apps/api/src/app/import/create-asset-profile-with-market-data.dto.ts
  66. 16
      apps/api/src/app/import/import-data.dto.ts
  67. 7
      apps/api/src/app/import/import.controller.ts
  68. 2
      apps/api/src/app/import/import.module.ts
  69. 248
      apps/api/src/app/import/import.service.ts
  70. 4
      apps/api/src/app/info/info.module.ts
  71. 91
      apps/api/src/app/info/info.service.ts
  72. 9
      apps/api/src/app/logo/logo.controller.ts
  73. 19
      apps/api/src/app/logo/logo.service.ts
  74. 6
      apps/api/src/app/order/create-order.dto.ts
  75. 4
      apps/api/src/app/order/interfaces/activities.interface.ts
  76. 35
      apps/api/src/app/order/order.controller.ts
  77. 137
      apps/api/src/app/order/order.service.ts
  78. 4
      apps/api/src/app/platform/platform.service.ts
  79. 7
      apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts
  80. 5
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  81. 48
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  82. 104
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  83. 26
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  84. 22
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  85. 96
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  86. 240
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  87. 22
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  88. 240
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  89. 50
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  90. 18
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  91. 12
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  92. 35
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  93. 11
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts
  94. 20
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  95. 38
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  96. 64
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  97. 0
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts
  98. 985
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  99. 29
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts
  100. 956
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts

1
.env.dev

@ -22,4 +22,3 @@ JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
# For more info, see: https://nx.dev/concepts/inferred-tasks # For more info, see: https://nx.dev/concepts/inferred-tasks
NX_ADD_PLUGINS=false NX_ADD_PLUGINS=false
NX_NATIVE_COMMAND_RUNNER=false

4
.env.example

@ -1,7 +1,7 @@
COMPOSE_PROJECT_NAME=ghostfolio COMPOSE_PROJECT_NAME=ghostfolio
# CACHE # CACHE
REDIS_HOST=localhost REDIS_HOST=redis
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD> REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
@ -12,5 +12,5 @@ POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
# VARIOUS # VARIOUS
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING> ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING> JWT_SECRET_KEY=<INSERT_RANDOM_STRING>

151
.eslintrc.json

@ -1,151 +0,0 @@
{
"root": true,
"ignorePatterns": ["**/*"],
"plugins": ["@nx"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {
"@nx/enforce-module-boundaries": [
"warn",
{
"enforceBuildableLibDependency": true,
"allow": [],
"depConstraints": [
{
"sourceTag": "*",
"onlyDependOnLibsWithTags": ["*"]
}
]
}
],
"@typescript-eslint/no-extra-semi": "error",
"no-extra-semi": "off"
}
},
{
"files": ["*.ts", "*.tsx"],
"extends": ["plugin:@nx/typescript"]
},
{
"files": ["*.js", "*.jsx"],
"extends": ["plugin:@nx/javascript"]
},
{
"files": ["*.ts"],
"plugins": ["eslint-plugin-import", "@typescript-eslint"],
"extends": [
"plugin:@typescript-eslint/recommended-type-checked",
"plugin:@typescript-eslint/stylistic-type-checked"
],
"rules": {
"@typescript-eslint/consistent-indexed-object-style": "off",
"@typescript-eslint/dot-notation": "off",
"@typescript-eslint/explicit-member-accessibility": [
"off",
{
"accessibility": "explicit"
}
],
"@typescript-eslint/member-ordering": "warn",
"@typescript-eslint/naming-convention": [
"off",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow",
"trailingUnderscore": "allow"
},
{
"selector": ["variable", "classProperty", "typeProperty"],
"format": ["camelCase", "UPPER_CASE"],
"leadingUnderscore": "allow",
"trailingUnderscore": "allow"
},
{
"selector": "objectLiteralProperty",
"format": null
},
{
"selector": "enumMember",
"format": ["camelCase", "UPPER_CASE", "PascalCase"]
},
{
"selector": "typeLike",
"format": ["PascalCase"]
}
],
"@typescript-eslint/no-empty-interface": "warn",
"@typescript-eslint/no-inferrable-types": [
"warn",
{
"ignoreParameters": true
}
],
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-shadow": [
"warn",
{
"hoist": "all"
}
],
"@typescript-eslint/unified-signatures": "error",
"@typescript-eslint/no-loss-of-precision": "warn",
"@typescript-eslint/no-var-requires": "warn",
"@typescript-eslint/ban-types": "warn",
"arrow-body-style": "off",
"constructor-super": "error",
"eqeqeq": ["error", "smart"],
"guard-for-in": "warn",
"id-blacklist": "off",
"id-match": "off",
"import/no-deprecated": "warn",
"no-bitwise": "error",
"no-caller": "error",
"no-debugger": "error",
"no-empty": "off",
"no-eval": "error",
"no-fallthrough": "error",
"no-new-wrappers": "error",
"no-restricted-imports": ["error", "rxjs/Rx"],
"no-undef-init": "error",
"no-underscore-dangle": "off",
"no-var": "error",
"radix": "error",
"no-unsafe-optional-chaining": "warn",
"no-extra-boolean-cast": "warn",
"no-empty-pattern": "warn",
"no-useless-catch": "warn",
"no-unsafe-finally": "warn",
"no-prototype-builtins": "warn",
"no-async-promise-executor": "warn",
"no-constant-condition": "warn",
// The following rules are part of @typescript-eslint/recommended-type-checked
// and can be remove once solved
"@typescript-eslint/await-thenable": "warn",
"@typescript-eslint/ban-ts-comment": "warn",
"@typescript-eslint/no-base-to-string": "warn",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-floating-promises": "warn",
"@typescript-eslint/no-misused-promises": "warn",
"@typescript-eslint/no-redundant-type-constituents": "warn",
"@typescript-eslint/no-unnecessary-type-assertion": "warn",
"@typescript-eslint/no-unsafe-argument": "warn",
"@typescript-eslint/no-unsafe-assignment": "warn",
"@typescript-eslint/no-unsafe-enum-comparison": "warn",
"@typescript-eslint/no-unsafe-member-access": "warn",
"@typescript-eslint/no-unsafe-return": "warn",
"@typescript-eslint/no-unsafe-call": "warn",
"@typescript-eslint/require-await": "warn",
"@typescript-eslint/restrict-template-expressions": "warn",
"@typescript-eslint/unbound-method": "warn",
// The following rules are part of @typescript-eslint/stylistic-type-checked
// and can be remove once solved
"@typescript-eslint/prefer-nullish-coalescing": "warn" // TODO: Requires strictNullChecks: true
}
}
],
"extends": ["plugin:storybook/recommended"]
}

4
.github/ISSUE_TEMPLATE/bug_report.md

@ -26,7 +26,7 @@ Thank you for your understanding and cooperation!
2. 2.
3. 3.
**Expected behavior** **Expected Behavior**
<!-- A clear and concise description of what you expected to happen. --> <!-- A clear and concise description of what you expected to happen. -->
@ -48,6 +48,6 @@ Thank you for your understanding and cooperation!
- Browser - Browser
- OS - OS
**Additional context** **Additional Context**
<!-- Add any other context about the problem here. --> <!-- Add any other context about the problem here. -->

2
.github/workflows/build-code.yml

@ -13,7 +13,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node_version: node_version:
- 20 - 22
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4

2
.github/workflows/docker-image.yml

@ -19,7 +19,7 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
images: ghostfolio/ghostfolio images: ${{ vars.DOCKER_REPOSITORY || 'ghostfolio/ghostfolio' }}
tags: | tags: |
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=semver,pattern={{version}} type=semver,pattern={{version}}

40
.github/workflows/extract-locales.yml

@ -0,0 +1,40 @@
name: Extract locales
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
extract_locales:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
run: npm ci
- name: Extract locales
run: npm run extract-locales
- name: Check changes
id: verify-changed-files
uses: tj-actions/verify-changed-files@v20
- name: Create pull request
if: steps.verify-changed-files.outputs.files_changed == 'true'
uses: peter-evans/create-pull-request@v7
with:
author: 'github-actions[bot] <github-actions[bot]@users.noreply.github.com>'
branch: 'feature/update-locales'
commit-message: 'Update locales'
delete-branch: true
title: 'Feature/update locales'
token: ${{ secrets.GITHUB_TOKEN }}

2
.gitignore

@ -25,8 +25,10 @@ npm-debug.log
# misc # misc
/.angular/cache /.angular/cache
.cursor/rules/nx-rules.mdc
.env .env
.env.prod .env.prod
.github/instructions/nx.instructions.md
.nx/cache .nx/cache
.nx/workspace-data .nx/workspace-data
/.sass-cache /.sass-cache

2
.nvmrc

@ -1 +1 @@
v20 v22

1061
CHANGELOG.md

File diff suppressed because it is too large

25
DEVELOPMENT.md

@ -5,14 +5,14 @@
### Prerequisites ### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop) - [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 20+) - [Node.js](https://nodejs.org/en/download) (version 22+)
- Create a local copy of this Git repository (clone) - 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`) - Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
### Setup ### Setup
1. Run `npm install` 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 `docker compose -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 `npm run database:setup` to initialize the database schema
1. Start the [server](#start-server) and the [client](#start-client) 1. Start the [server](#start-server) and the [client](#start-client)
1. Open https://localhost:4200/en in your browser 1. Open https://localhost:4200/en in your browser
@ -30,7 +30,13 @@ Run `npm run start:server`
### Start Client ### Start Client
Run `npm run start:client` and open https://localhost:4200/en in your browser #### English (Default)
Run `npm run start:client` and open https://localhost:4200/en in your browser.
#### Other Languages
To start the client in a different language, such as German (`de`), adapt the `start:client` script in the `package.json` file by changing `--configuration=development-en` to `--configuration=development-de`. Then, run `npm run start:client` and open https://localhost:4200/de in your browser.
### Start _Storybook_ ### Start _Storybook_
@ -60,6 +66,10 @@ Remove permission in `UserService` using `without()`
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
## Component Library (_Storybook_)
https://ghostfol.io/development/storybook
## Git ## Git
### Rebase ### Rebase
@ -101,3 +111,12 @@ https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
Run `npm run prisma migrate dev --name added_job_title` Run `npm run prisma migrate dev --name added_job_title`
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
## SSL
Generate `localhost.cert` and `localhost.pem` files.
```
openssl req -x509 -newkey rsa:2048 -nodes -keyout apps/client/localhost.pem -out apps/client/localhost.cert -days 365 \
-subj "/C=CH/ST=State/L=City/O=Organization/OU=Unit/CN=localhost"
```

19
Dockerfile

@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:20-slim AS builder FROM --platform=$BUILDPLATFORM node:22-slim AS builder
# Build application and add additional files # Build application and add additional files
WORKDIR /ghostfolio WORKDIR /ghostfolio
@ -25,32 +25,33 @@ RUN npm install
COPY ./decorate-angular-cli.js decorate-angular-cli.js COPY ./decorate-angular-cli.js decorate-angular-cli.js
RUN node decorate-angular-cli.js RUN node decorate-angular-cli.js
COPY ./nx.json nx.json COPY ./apps apps
COPY ./replace.build.js replace.build.js COPY ./libs libs
COPY ./jest.preset.js jest.preset.js
COPY ./jest.config.ts jest.config.ts COPY ./jest.config.ts jest.config.ts
COPY ./jest.preset.js jest.preset.js
COPY ./nx.json nx.json
COPY ./replace.build.mjs replace.build.mjs
COPY ./tsconfig.base.json tsconfig.base.json COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs
COPY ./apps apps
ENV NX_DAEMON=false
RUN npm run build:production RUN npm run build:production
# Prepare the dist image with additional node_modules # Prepare the dist image with additional node_modules
WORKDIR /ghostfolio/dist/apps/api WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original # package.json was generated by the build process, however the original
# package-lock.json needs to be used to ensure the same versions # package-lock.json needs to be used to ensure the same versions
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
RUN npm install RUN npm install
COPY prisma /ghostfolio/dist/apps/api/prisma COPY prisma /ghostfolio/dist/apps/api/prisma
# Overwrite the generated package.json with the original one to ensure having # Overwrite the generated package.json with the original one to ensure having
# all the scripts # all the scripts
COPY package.json /ghostfolio/dist/apps/api COPY package.json /ghostfolio/dist/apps/api
RUN npm run database:generate-typings RUN npm run database:generate-typings
# Image to run, copy everything needed from builder # Image to run, copy everything needed from builder
FROM node:20-slim FROM node:22-slim
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio" LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
ENV NODE_ENV=production ENV NODE_ENV=production

35
README.md

@ -7,7 +7,7 @@
**Open Source Wealth Management Software** **Open Source Wealth Management Software**
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) | [**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_) [**Blog**](https://ghostfol.io/en/blog) | [**LinkedIn**](https://www.linkedin.com/company/ghostfolio) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_)
[![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio) [![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio)
[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-limegreen.svg)](#contributing) [![Shield: Docker Pulls](https://img.shields.io/docker/pulls/ghostfolio/ghostfolio?label=Docker%20Pulls)](https://hub.docker.com/r/ghostfolio/ghostfolio) [![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-limegreen.svg)](#contributing) [![Shield: Docker Pulls](https://img.shields.io/docker/pulls/ghostfolio/ghostfolio?label=Docker%20Pulls)](https://hub.docker.com/r/ghostfolio/ghostfolio)
@ -25,7 +25,7 @@
## Ghostfolio Premium ## Ghostfolio Premium
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover the costs of the hosting infrastructure and to fund ongoing development. Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover operational costs for the hosting infrastructure and professional data providers, and to fund ongoing development.
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section. If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
@ -47,7 +47,7 @@ Ghostfolio is for you if you are...
- ✅ Create, update and delete transactions - ✅ Create, update and delete transactions
- ✅ Multi account management - ✅ Multi account management
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max` - ✅ Portfolio performance: Return on Average Investment (ROAI) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Various charts - ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio - ✅ Static analysis to identify potential risks in your portfolio
- ✅ Import and export transactions - ✅ Import and export transactions
@ -118,7 +118,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio): Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
```bash ```bash
docker compose --env-file ./.env -f docker/docker-compose.yml up -d docker compose -f docker/docker-compose.yml up -d
``` ```
#### b. Build and run environment #### b. Build and run environment
@ -126,8 +126,8 @@ docker compose --env-file ./.env -f docker/docker-compose.yml up -d
Run the following commands to build and start the Docker images: Run the following commands to build and start the Docker images:
```bash ```bash
docker compose --env-file ./.env -f docker/docker-compose.build.yml build docker compose -f docker/docker-compose.build.yml build
docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d docker compose -f docker/docker-compose.build.yml up -d
``` ```
#### Setup #### Setup
@ -137,9 +137,18 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
#### Upgrade Version #### Upgrade Version
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml` 1. Update the _Ghostfolio_ Docker image
1. Run the following command to start the new Docker image: `docker compose --env-file ./.env -f docker/docker-compose.yml up -d` - Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
At each start, the container will automatically apply the database schema migrations if needed. - Run the following command if `ghostfolio:latest` is set:
```bash
docker compose -f docker/docker-compose.yml pull
```
1. Run the following command to start the new Docker image:
```bash
docker compose -f docker/docker-compose.yml up -d
```
The container will automatically apply any required database schema migrations during startup.
### Home Server Systems (Community) ### Home Server Systems (Community)
@ -213,16 +222,16 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
``` ```
| Field | Type | Description | | Field | Type | Description |
| ------------ | ------------------- | ----------------------------------------------------------------------------- | | ------------ | ------------------- | ------------------------------------------------------------------- |
| `accountId` | `string` (optional) | Id of the account | | `accountId` | `string` (optional) | Id of the account |
| `comment` | `string` (optional) | Comment of the activity | | `comment` | `string` (optional) | Comment of the activity |
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. | | `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` | | `dataSource` | `string` | `COINGECKO` \| `MANUAL` \| `YAHOO` |
| `date` | `string` | Date in the format `ISO-8601` | | `date` | `string` | Date in the format `ISO-8601` |
| `fee` | `number` | Fee of the activity | | `fee` | `number` | Fee of the activity |
| `quantity` | `number` | Quantity of the activity | | `quantity` | `number` | Quantity of the activity |
| `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) | | `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
| `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` | | `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `LIABILITY` \| `SELL` |
| `unitPrice` | `number` | Price per unit of the activity | | `unitPrice` | `number` | Price per unit of the activity |
#### Response #### Response
@ -296,6 +305,6 @@ If you like to support this project, get [**Ghostfolio Premium**](https://ghostf
## License ## License
© 2021 - 2024 [Ghostfolio](https://ghostfol.io) © 2021 - 2025 [Ghostfolio](https://ghostfol.io)
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html). Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).

13
SECURITY.md

@ -0,0 +1,13 @@
# Security Policy
## Reporting Security Issues
If you discover a security vulnerability in this repository, please report it to security[at]ghostfol.io. We will acknowledge your report and provide guidance on the next steps.
To help us resolve the issue, please include the following details:
- A description of the vulnerability
- Steps to reproduce the vulnerability
- Affected versions of the software
We appreciate your responsible disclosure and will work to address the issue promptly.

22
apps/api/.eslintrc.json

@ -1,22 +0,0 @@
{
"extends": "../../.eslintrc.json",
"ignorePatterns": ["!**/*"],
"rules": {},
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"parserOptions": {
"project": ["apps/api/tsconfig.*?.json"]
},
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

31
apps/api/eslint.config.cjs

@ -0,0 +1,31 @@
const baseConfig = require('../../eslint.config.cjs');
module.exports = [
{
ignores: ['**/dist']
},
...baseConfig,
{
rules: {}
},
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
// Override or add rules here
rules: {},
languageOptions: {
parserOptions: {
project: ['apps/api/tsconfig.*?.json']
}
}
},
{
files: ['**/*.ts', '**/*.tsx'],
// Override or add rules here
rules: {}
},
{
files: ['**/*.js', '**/*.jsx'],
// Override or add rules here
rules: {}
}
];

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

@ -37,20 +37,20 @@ export class AccessController {
public async getAllAccesses(): Promise<Access[]> { public async getAllAccesses(): Promise<Access[]> {
const accessesWithGranteeUser = await this.accessService.accesses({ const accessesWithGranteeUser = await this.accessService.accesses({
include: { include: {
GranteeUser: true granteeUser: true
}, },
orderBy: { granteeUserId: 'asc' }, orderBy: { granteeUserId: 'asc' },
where: { userId: this.request.user.id } where: { userId: this.request.user.id }
}); });
return accessesWithGranteeUser.map( return accessesWithGranteeUser.map(
({ alias, GranteeUser, id, permissions }) => { ({ alias, granteeUser, id, permissions }) => {
if (GranteeUser) { if (granteeUser) {
return { return {
alias, alias,
id, id,
permissions, permissions,
grantee: GranteeUser?.id, grantee: granteeUser?.id,
type: 'PRIVATE' type: 'PRIVATE'
}; };
} }
@ -85,11 +85,11 @@ export class AccessController {
try { try {
return this.accessService.createAccess({ return this.accessService.createAccess({
alias: data.alias || undefined, alias: data.alias || undefined,
GranteeUser: data.granteeUserId granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: undefined, : undefined,
permissions: data.permissions, permissions: data.permissions,
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}); });
} catch { } catch {
throw new HttpException( throw new HttpException(

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

@ -13,7 +13,7 @@ export class AccessService {
): Promise<AccessWithGranteeUser | null> { ): Promise<AccessWithGranteeUser | null> {
return this.prismaService.access.findFirst({ return this.prismaService.access.findFirst({
include: { include: {
GranteeUser: true granteeUser: true
}, },
where: accessWhereInput where: accessWhereInput
}); });

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

@ -30,7 +30,7 @@ export class AccountBalanceService {
): Promise<AccountBalance | null> { ): Promise<AccountBalance | null> {
return this.prismaService.accountBalance.findFirst({ return this.prismaService.accountBalance.findFirst({
include: { include: {
Account: true account: true
}, },
where: accountBalanceWhereInput where: accountBalanceWhereInput
}); });
@ -46,7 +46,7 @@ export class AccountBalanceService {
}): Promise<AccountBalance> { }): Promise<AccountBalance> {
const accountBalance = await this.prismaService.accountBalance.upsert({ const accountBalance = await this.prismaService.accountBalance.upsert({
create: { create: {
Account: { account: {
connect: { connect: {
id_userId: { id_userId: {
userId, userId,
@ -154,7 +154,7 @@ export class AccountBalanceService {
} }
if (withExcludedAccounts === false) { if (withExcludedAccounts === false) {
where.Account = { isExcluded: false }; where.account = { isExcluded: false };
} }
const balances = await this.prismaService.accountBalance.findMany({ const balances = await this.prismaService.accountBalance.findMany({
@ -163,7 +163,7 @@ export class AccountBalanceService {
date: 'asc' date: 'asc'
}, },
select: { select: {
Account: true, account: true,
date: true, date: true,
id: true, id: true,
value: true value: true
@ -174,10 +174,10 @@ export class AccountBalanceService {
balances: balances.map((balance) => { balances: balances.map((balance) => {
return { return {
...balance, ...balance,
accountId: balance.Account.id, accountId: balance.account.id,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
balance.value, balance.value,
balance.Account.currency, balance.account.currency,
userCurrency userCurrency
) )
}; };

30
apps/api/src/app/account/account.controller.ts

@ -9,7 +9,7 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation/imp
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { import {
AccountBalancesResponse, AccountBalancesResponse,
Accounts AccountsResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { import type {
@ -57,17 +57,17 @@ export class AccountController {
@HasPermission(permissions.deleteAccount) @HasPermission(permissions.deleteAccount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> { public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
const account = await this.accountService.accountWithOrders( const account = await this.accountService.accountWithActivities(
{ {
id_userId: { id_userId: {
id, id,
userId: this.request.user.id userId: this.request.user.id
} }
}, },
{ Order: true } { activities: true }
); );
if (!account || account?.Order.length > 0) { if (!account || account?.activities.length > 0) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -87,10 +87,10 @@ export class AccountController {
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getAllAccounts( public async getAllAccounts(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string @Query('symbol') filterBySymbol?: string
): Promise<Accounts> { ): Promise<AccountsResponse> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
@ -110,7 +110,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById( public async getAccountById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string @Param('id') id: string
): Promise<AccountWithValue> { ): Promise<AccountWithValue> {
const impersonationUserId = const impersonationUserId =
@ -134,7 +134,7 @@ export class AccountController {
): Promise<AccountBalancesResponse> { ): Promise<AccountBalancesResponse> {
return this.accountBalanceService.getAccountBalances({ return this.accountBalanceService.getAccountBalances({
filters: [{ id, type: 'ACCOUNT' }], filters: [{ id, type: 'ACCOUNT' }],
userCurrency: this.request.user.Settings.settings.baseCurrency, userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id userId: this.request.user.id
}); });
} }
@ -152,8 +152,8 @@ export class AccountController {
return this.accountService.createAccount( return this.accountService.createAccount(
{ {
...data, ...data,
Platform: { connect: { id: platformId } }, platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id this.request.user.id
); );
@ -163,7 +163,7 @@ export class AccountController {
return this.accountService.createAccount( return this.accountService.createAccount(
{ {
...data, ...data,
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id this.request.user.id
); );
@ -250,8 +250,8 @@ export class AccountController {
{ {
data: { data: {
...data, ...data,
Platform: { connect: { id: platformId } }, platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
where: { where: {
id_userId: { id_userId: {
@ -270,10 +270,10 @@ export class AccountController {
{ {
data: { data: {
...data, ...data,
Platform: originalAccount.platformId platform: originalAccount.platformId
? { disconnect: true } ? { disconnect: true }
: undefined, : undefined,
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
where: { where: {
id_userId: { id_userId: {

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

@ -7,7 +7,13 @@ import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Account, Order, Platform, Prisma } from '@prisma/client'; import {
Account,
AccountBalance,
Order,
Platform,
Prisma
} from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
@ -33,12 +39,12 @@ export class AccountService {
return account; return account;
} }
public async accountWithOrders( public async accountWithActivities(
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput, accountWhereUniqueInput: Prisma.AccountWhereUniqueInput,
accountInclude: Prisma.AccountInclude accountInclude: Prisma.AccountInclude
): Promise< ): Promise<
Account & { Account & {
Order?: Order[]; activities?: Order[];
} }
> { > {
return this.prismaService.account.findUnique({ return this.prismaService.account.findUnique({
@ -56,13 +62,19 @@ export class AccountService {
orderBy?: Prisma.AccountOrderByWithRelationInput; orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise< }): Promise<
(Account & { (Account & {
Order?: Order[]; activities?: Order[];
Platform?: Platform; balances?: AccountBalance[];
platform?: Platform;
})[] })[]
> { > {
const { include = {}, skip, take, cursor, where, orderBy } = params; const { include = {}, skip, take, cursor, where, orderBy } = params;
include.balances = { orderBy: { date: 'desc' }, take: 1 }; const isBalancesIncluded = !!include.balances;
include.balances = {
orderBy: { date: 'desc' },
...(isBalancesIncluded ? {} : { take: 1 })
};
const accounts = await this.prismaService.account.findMany({ const accounts = await this.prismaService.account.findMany({
cursor, cursor,
@ -76,7 +88,9 @@ export class AccountService {
return accounts.map((account) => { return accounts.map((account) => {
account = { ...account, balance: account.balances[0]?.value ?? 0 }; account = { ...account, balance: account.balances[0]?.value ?? 0 };
if (!isBalancesIncluded) {
delete account.balances; delete account.balances;
}
return account; return account;
}); });
@ -126,7 +140,10 @@ export class AccountService {
public async getAccounts(aUserId: string): Promise<Account[]> { public async getAccounts(aUserId: string): Promise<Account[]> {
const accounts = await this.accounts({ const accounts = await this.accounts({
include: { Order: true, Platform: true }, include: {
activities: true,
platform: true
},
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
where: { userId: aUserId } where: { userId: aUserId }
}); });
@ -134,15 +151,15 @@ export class AccountService {
return accounts.map((account) => { return accounts.map((account) => {
let transactionCount = 0; let transactionCount = 0;
for (const order of account.Order) { for (const { isDraft } of account.activities) {
if (!order.isDraft) { if (!isDraft) {
transactionCount += 1; transactionCount += 1;
} }
} }
const result = { ...account, transactionCount }; const result = { ...account, transactionCount };
delete result.Order; delete result.activities;
return result; return result;
}); });

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

@ -3,22 +3,22 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { DemoService } from '@ghostfolio/api/services/demo/demo.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails,
AdminUsers, AdminUsers,
EnhancedSymbolProfile EnhancedSymbolProfile,
ScraperConfiguration
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { import type {
@ -50,8 +50,6 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto'; import { UpdateAssetProfileDto } from './update-asset-profile.dto';
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
@ -59,8 +57,8 @@ export class AdminController {
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly demoService: DemoService,
private readonly manualService: ManualService, private readonly manualService: ManualService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -68,7 +66,14 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAdminData(): Promise<AdminData> { public async getAdminData(): Promise<AdminData> {
return this.adminService.get(); return this.adminService.get({ user: this.request.user });
}
@Get('demo-user/sync')
@HasPermission(permissions.syncDemoUserAccount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async syncDemoUserAccount(): Promise<Prisma.BatchPayload> {
return this.demoService.syncDemoUserAccount();
} }
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@ -83,7 +88,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMax(): Promise<void> { public async gatherMax(): Promise<void> {
const assetProfileIdentifiers = const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers(); await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -92,9 +97,9 @@ export class AdminController {
dataSource, dataSource,
symbol symbol
}, },
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }), jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
} }
@ -110,7 +115,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileData(): Promise<void> { public async gatherProfileData(): Promise<void> {
const assetProfileIdentifiers = const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers(); await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -119,9 +124,9 @@ export class AdminController {
dataSource, dataSource,
symbol symbol
}, },
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }), jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
} }
@ -142,9 +147,9 @@ export class AdminController {
dataSource, dataSource,
symbol symbol
}, },
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }), jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
} }
@ -214,30 +219,16 @@ export class AdminController {
}); });
} }
/**
* @deprecated
*/
@Get('market-data/:dataSource/:symbol')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> {
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol/test') @Post('market-data/:dataSource/:symbol/test')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async testMarketData( public async testMarketData(
@Body() data: { scraperConfiguration: string }, @Body() data: { scraperConfiguration: ScraperConfiguration },
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<{ price: number }> { ): Promise<{ price: number }> {
try { try {
const scraperConfiguration = JSON.parse(data.scraperConfiguration); const price = await this.manualService.test(data.scraperConfiguration);
const price = await this.manualService.test(scraperConfiguration);
if (price) { if (price) {
return { price }; return { price };
@ -253,58 +244,6 @@ export class AdminController {
} }
} }
/**
* @deprecated
*/
@HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateMarketData(
@Body() data: UpdateBulkMarketDataDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
({ date, marketPrice }) => ({
dataSource,
marketPrice,
symbol,
date: parseISO(date),
state: 'CLOSE'
})
);
return this.marketDataService.updateMany({
data: dataBulkUpdate
});
}
/**
* @deprecated
*/
@HasPermission(permissions.accessAdminControl)
@Put('market-data/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async update(
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string,
@Body() data: UpdateMarketDataDto
) {
const date = parseISO(dateString);
return this.marketDataService.updateMarketData({
data: { marketPrice: data.marketPrice, state: 'CLOSE' },
where: {
dataSource_date_symbol: {
dataSource,
date,
symbol
}
}
});
}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post('profile-data/:dataSource/:symbol') @Post('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -316,7 +255,7 @@ export class AdminController {
return this.adminService.addAssetProfile({ return this.adminService.addAssetProfile({
dataSource, dataSource,
symbol, symbol,
currency: this.request.user.Settings.settings.baseCurrency currency: this.request.user.settings.settings.baseCurrency
}); });
} }
@ -334,15 +273,14 @@ export class AdminController {
@Patch('profile-data/:dataSource/:symbol') @Patch('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async patchAssetProfileData( public async patchAssetProfileData(
@Body() assetProfileData: UpdateAssetProfileDto, @Body() assetProfile: UpdateAssetProfileDto,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<EnhancedSymbolProfile> { ): Promise<EnhancedSymbolProfile> {
return this.adminService.patchAssetProfileData({ return this.adminService.patchAssetProfileData(
...assetProfileData, { dataSource, symbol },
dataSource, assetProfile
symbol );
});
} }
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)

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

@ -1,10 +1,10 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.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 { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { DemoModule } from '@ghostfolio/api/services/demo/demo.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -25,13 +25,13 @@ import { QueueModule } from './queue/queue.module';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
DemoModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
QueueModule, QueueModule,
SubscriptionModule,
SymbolProfileModule, SymbolProfileModule,
TransformDataSourceInRequestModule TransformDataSourceInRequestModule
], ],

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

@ -1,7 +1,6 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { OrderService } from '@ghostfolio/api/app/order/order.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 { environment } from '@ghostfolio/api/environments/environment';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -10,7 +9,6 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DEFAULT_CURRENCY,
PROPERTY_CURRENCIES, PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
@ -30,9 +28,15 @@ import {
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { MarketDataPreset, UserWithSettings } from '@ghostfolio/common/types';
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import {
BadRequestException,
HttpException,
Injectable,
Logger
} from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -43,6 +47,7 @@ import {
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
@Injectable() @Injectable()
@ -56,7 +61,6 @@ export class AdminService {
private readonly orderService: OrderService, private readonly orderService: OrderService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -108,33 +112,31 @@ export class AdminService {
symbol symbol
}: AssetProfileIdentifier) { }: AssetProfileIdentifier) {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
await this.symbolProfileService.delete({ dataSource, symbol });
const currency = getCurrencyFromSymbol(symbol);
const customCurrencies =
await this.propertyService.getByKey<string[]>(PROPERTY_CURRENCIES);
if (customCurrencies.includes(currency)) {
const updatedCustomCurrencies = customCurrencies.filter(
(customCurrency) => {
return customCurrency !== currency;
} }
);
public async get(): Promise<AdminData> { await this.putSetting(
const exchangeRates = this.exchangeRateDataService PROPERTY_CURRENCIES,
.getCurrencies() JSON.stringify(updatedCustomCurrencies)
.filter((currency) => { );
return currency !== DEFAULT_CURRENCY; } else {
}) await this.symbolProfileService.delete({ dataSource, symbol });
.map((currency) => { }
const label1 = DEFAULT_CURRENCY; }
const label2 = currency;
return { public async get({ user }: { user: UserWithSettings }): Promise<AdminData> {
label1, const dataSources = await this.dataProviderService.getDataSources({
label2, user,
dataSource: includeGhostfolio: true
DataSource[
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
],
symbol: `${label1}${label2}`,
value: this.exchangeRateDataService.toCurrency(
1,
DEFAULT_CURRENCY,
currency
)
};
}); });
const [settings, transactionCount, userCount] = await Promise.all([ const [settings, transactionCount, userCount] = await Promise.all([
@ -143,8 +145,27 @@ export class AdminService {
this.countUsersWithAnalytics() this.countUsersWithAnalytics()
]); ]);
const dataProviders = await Promise.all(
dataSources.map(async (dataSource) => {
const dataProviderInfo = this.dataProviderService
.getDataProvider(dataSource)
.getDataProviderInfo();
const assetProfileCount = await this.prismaService.symbolProfile.count({
where: {
dataSource
}
});
return { return {
exchangeRates, ...dataProviderInfo,
assetProfileCount
};
})
);
return {
dataProviders,
settings, settings,
transactionCount, transactionCount,
userCount, userCount,
@ -223,7 +244,7 @@ export class AdminService {
if (sortColumn === 'activitiesCount') { if (sortColumn === 'activitiesCount') {
orderBy = { orderBy = {
Order: { activities: {
_count: sortDirection _count: sortDirection
} }
}; };
@ -241,7 +262,15 @@ export class AdminService {
where, where,
select: { select: {
_count: { _count: {
select: { Order: true } select: {
activities: true,
watchedBy: true
}
},
activities: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
}, },
assetClass: true, assetClass: true,
assetSubClass: true, assetSubClass: true,
@ -250,16 +279,13 @@ export class AdminService {
currency: true, currency: true,
dataSource: true, dataSource: true,
id: true, id: true,
isActive: true,
isUsedByUsersWithSubscription: true, isUsedByUsersWithSubscription: true,
name: true, name: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true, scraperConfiguration: true,
sectors: true, sectors: true,
symbol: true symbol: true,
SymbolProfileOverrides: true
} }
}), }),
this.prismaService.symbolProfile.count({ where }) this.prismaService.symbolProfile.count({ where })
@ -302,6 +328,7 @@ export class AdminService {
assetProfiles.map( assetProfiles.map(
async ({ async ({
_count, _count,
activities,
assetClass, assetClass,
assetSubClass, assetSubClass,
comment, comment,
@ -309,15 +336,14 @@ export class AdminService {
currency, currency,
dataSource, dataSource,
id, id,
isActive,
isUsedByUsersWithSubscription, isUsedByUsersWithSubscription,
name, name,
Order,
sectors, sectors,
symbol symbol,
SymbolProfileOverrides
}) => { }) => {
const countriesCount = countries let countriesCount = countries ? Object.keys(countries).length : 0;
? Object.keys(countries).length
: 0;
const lastMarketPrice = lastMarketPriceMap.get( const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol }) getAssetProfileIdentifier({ dataSource, symbol })
@ -331,7 +357,34 @@ export class AdminService {
); );
})?._count ?? 0; })?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0; let sectorsCount = sectors ? Object.keys(sectors).length : 0;
if (SymbolProfileOverrides) {
assetClass = SymbolProfileOverrides.assetClass ?? assetClass;
assetSubClass =
SymbolProfileOverrides.assetSubClass ?? assetSubClass;
if (
(
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
)?.length > 0
) {
countriesCount = (
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
).length;
}
name = SymbolProfileOverrides.name ?? name;
if (
(SymbolProfileOverrides.sectors as unknown as Sector[])
?.length > 0
) {
sectorsCount = (
SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray
).length;
}
}
return { return {
assetClass, assetClass,
@ -341,14 +394,17 @@ export class AdminService {
countriesCount, countriesCount,
dataSource, dataSource,
id, id,
isActive,
lastMarketPrice, lastMarketPrice,
name, name,
symbol, symbol,
marketDataItemCount, marketDataItemCount,
sectorsCount, sectorsCount,
activitiesCount: _count.Order, activitiesCount: _count.activities,
date: Order?.[0]?.date, date: activities?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription isUsedByUsersWithSubscription:
await isUsedByUsersWithSubscription,
watchedByCount: _count.watchedBy
}; };
} }
) )
@ -424,7 +480,8 @@ export class AdminService {
currency, currency,
dataSource, dataSource,
dateOfFirstActivity, dateOfFirstActivity,
symbol symbol,
isActive: true
} }
}; };
} }
@ -444,21 +501,81 @@ export class AdminService {
return { count, users }; return { count, users };
} }
public async patchAssetProfileData({ public async patchAssetProfileData(
{ dataSource, symbol }: AssetProfileIdentifier,
{
assetClass, assetClass,
assetSubClass, assetSubClass,
comment, comment,
countries, countries,
currency, currency,
dataSource, dataSource: newDataSource,
holdings, holdings,
isActive,
name, name,
scraperConfiguration, scraperConfiguration,
sectors, sectors,
symbol, symbol: newSymbol,
symbolMapping, symbolMapping,
url url
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) { }: Prisma.SymbolProfileUpdateInput
) {
if (
newSymbol &&
newDataSource &&
(newSymbol !== symbol || newDataSource !== dataSource)
) {
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
]);
if (assetProfile) {
throw new HttpException(
getReasonPhrase(StatusCodes.CONFLICT),
StatusCodes.CONFLICT
);
}
try {
Promise.all([
await this.symbolProfileService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
),
await this.marketDataService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
)
]);
return this.symbolProfileService.getSymbolProfiles([
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
])?.[0];
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
} else {
const symbolProfileOverrides = { const symbolProfileOverrides = {
assetClass: assetClass as AssetClass, assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass, assetSubClass: assetSubClass as AssetSubClass,
@ -466,13 +583,13 @@ export class AdminService {
url: url as string url: url as string
}; };
const updatedSymbolProfile: AssetProfileIdentifier & const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = {
Prisma.SymbolProfileUpdateInput = {
comment, comment,
countries, countries,
currency, currency,
dataSource, dataSource,
holdings, holdings,
isActive,
scraperConfiguration, scraperConfiguration,
sectors, sectors,
symbol, symbol,
@ -489,16 +606,21 @@ export class AdminService {
}) })
}; };
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile); await this.symbolProfileService.updateSymbolProfile(
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{ {
dataSource, dataSource,
symbol symbol
} },
]); updatedSymbolProfile
);
return symbolProfile; return this.symbolProfileService.getSymbolProfiles([
{
dataSource: dataSource as DataSource,
symbol: symbol as string
}
])?.[0];
}
} }
public async putSetting(key: string, value: string) { public async putSetting(key: string, value: string) {
@ -525,7 +647,7 @@ export class AdminService {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
where = { where = {
NOT: { NOT: {
Analytics: null analytics: null
} }
}; };
} }
@ -549,10 +671,10 @@ export class AdminService {
select: { select: {
_count: { _count: {
select: { select: {
Order: { activities: {
where: { where: {
User: { user: {
Subscription: { subscriptions: {
some: { some: {
expiresAt: { expiresAt: {
gt: new Date() gt: new Date()
@ -570,7 +692,7 @@ export class AdminService {
} }
}); });
return _count.Order > 0; return _count.activities > 0;
} }
} }
} }
@ -657,8 +779,10 @@ export class AdminService {
countriesCount: 0, countriesCount: 0,
date: dateOfFirstActivity, date: dateOfFirstActivity,
id: undefined, id: undefined,
isActive: true,
name: symbol, name: symbol,
sectorsCount: 0 sectorsCount: 0,
watchedByCount: 0
}; };
} }
); );
@ -681,13 +805,13 @@ export class AdminService {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
orderBy = { orderBy = {
Analytics: { analytics: {
lastRequestAt: 'desc' lastRequestAt: 'desc'
} }
}; };
where = { where = {
NOT: { NOT: {
Analytics: null analytics: null
} }
}; };
} }
@ -699,9 +823,9 @@ export class AdminService {
where, where,
select: { select: {
_count: { _count: {
select: { Account: true, Order: true } select: { accounts: true, activities: true }
}, },
Analytics: { analytics: {
select: { select: {
activityCount: true, activityCount: true,
country: true, country: true,
@ -712,25 +836,32 @@ export class AdminService {
createdAt: true, createdAt: true,
id: true, id: true,
role: true, role: true,
Subscription: true subscriptions: {
orderBy: {
expiresAt: 'desc'
},
take: 1,
where: {
expiresAt: {
gt: new Date()
}
}
}
} }
}); });
return usersWithAnalytics.map( return usersWithAnalytics.map(
({ _count, Analytics, createdAt, id, role, Subscription }) => { ({ _count, analytics, createdAt, id, role, subscriptions }) => {
const daysSinceRegistration = const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1; differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics const engagement = analytics
? Analytics.activityCount / daysSinceRegistration ? analytics.activityCount / daysSinceRegistration
: undefined; : undefined;
const subscription = this.configurationService.get( const subscription =
'ENABLE_FEATURE_SUBSCRIPTION' this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
) subscriptions?.length > 0
? this.subscriptionService.getSubscription({ ? subscriptions[0]
createdAt,
subscriptions: Subscription
})
: undefined; : undefined;
return { return {
@ -739,11 +870,11 @@ export class AdminService {
id, id,
role, role,
subscription, subscription,
accountCount: _count.Account || 0, accountCount: _count.accounts || 0,
country: Analytics?.country, activityCount: _count.activities || 0,
dailyApiRequests: Analytics?.dataProviderGhostfolioDailyRequests || 0, country: analytics?.country,
lastActivity: Analytics?.updatedAt, dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0,
transactionCount: _count.Order || 0 lastActivity: analytics?.updatedAt
}; };
} }
); );

92
apps/api/src/app/admin/create-asset-profile.dto.ts

@ -0,0 +1,92 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
import {
IsArray,
IsBoolean,
IsEnum,
IsObject,
IsOptional,
IsString,
IsUrl
} from 'class-validator';
export class CreateAssetProfileDto {
@IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass;
@IsOptional()
@IsString()
comment?: string;
@IsArray()
@IsOptional()
countries?: Prisma.InputJsonArray;
@IsCurrencyCode()
currency: string;
@IsOptional()
@IsString()
cusip?: string;
@IsEnum(DataSource)
dataSource: DataSource;
@IsOptional()
@IsString()
figi?: string;
@IsOptional()
@IsString()
figiComposite?: string;
@IsOptional()
@IsString()
figiShareClass?: string;
@IsArray()
@IsOptional()
holdings?: Prisma.InputJsonArray;
@IsBoolean()
@IsOptional()
isActive?: boolean;
@IsOptional()
@IsString()
isin?: string;
@IsOptional()
@IsString()
name?: string;
@IsObject()
@IsOptional()
scraperConfiguration?: Prisma.InputJsonObject;
@IsArray()
@IsOptional()
sectors?: Prisma.InputJsonArray;
@IsString()
symbol: string;
@IsObject()
@IsOptional()
symbolMapping?: {
[dataProvider: string]: string;
};
@IsOptional()
@IsUrl({
protocols: ['https'],
require_protocol: true
})
url?: string;
}

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

@ -1,8 +1,9 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
import { import {
IsArray, IsArray,
IsBoolean,
IsEnum, IsEnum,
IsObject, IsObject,
IsOptional, IsOptional,
@ -19,8 +20,8 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
@IsString()
@IsOptional() @IsOptional()
@IsString()
comment?: string; comment?: string;
@IsArray() @IsArray()
@ -31,8 +32,16 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
currency?: string; currency?: string;
@IsString() @IsEnum(DataSource)
@IsOptional() @IsOptional()
dataSource?: DataSource;
@IsBoolean()
@IsOptional()
isActive?: boolean;
@IsOptional()
@IsString()
name?: string; name?: string;
@IsObject() @IsObject()
@ -43,6 +52,10 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
sectors?: Prisma.InputJsonArray; sectors?: Prisma.InputJsonArray;
@IsOptional()
@IsString()
symbol?: string;
@IsObject() @IsObject()
@IsOptional() @IsOptional()
symbolMapping?: { symbolMapping?: {

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

@ -1,20 +1,21 @@
import { EventsModule } from '@ghostfolio/api/events/events.module'; import { EventsModule } from '@ghostfolio/api/events/events.module';
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CronService } from '@ghostfolio/api/services/cron.service'; import { CronModule } from '@ghostfolio/api/services/cron/cron.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { import {
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
@ -29,12 +30,17 @@ import { AppController } from './app.controller';
import { AssetModule } from './asset/asset.module'; import { AssetModule } from './asset/asset.module';
import { AuthDeviceModule } from './auth-device/auth-device.module'; import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { AiModule } from './endpoints/ai/ai.module';
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
import { AssetsModule } from './endpoints/assets/assets.module';
import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { MarketDataModule } from './endpoints/market-data/market-data.module'; import { MarketDataModule } from './endpoints/market-data/market-data.module';
import { PublicModule } from './endpoints/public/public.module'; import { PublicModule } from './endpoints/public/public.module';
import { SitemapModule } from './endpoints/sitemap/sitemap.module';
import { TagsModule } from './endpoints/tags/tags.module';
import { WatchlistModule } from './endpoints/watchlist/watchlist.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
@ -45,10 +51,8 @@ import { OrderModule } from './order/order.module';
import { PlatformModule } from './platform/platform.module'; import { PlatformModule } from './platform/platform.module';
import { PortfolioModule } from './portfolio/portfolio.module'; import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module'; import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SitemapModule } from './sitemap/sitemap.module';
import { SubscriptionModule } from './subscription/subscription.module'; import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module'; import { SymbolModule } from './symbol/symbol.module';
import { TagModule } from './tag/tag.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@Module({ @Module({
@ -57,11 +61,13 @@ import { UserModule } from './user/user.module';
AdminModule, AdminModule,
AccessModule, AccessModule,
AccountModule, AccountModule,
AiModule,
ApiKeysModule, ApiKeysModule,
AssetModule, AssetModule,
AssetsModule,
AuthDeviceModule, AuthDeviceModule,
AuthModule, AuthModule,
BenchmarkModule, BenchmarksModule,
BullModule.forRoot({ BullModule.forRoot({
redis: { redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10), db: parseInt(process.env.REDIS_DB ?? '0', 10),
@ -73,6 +79,7 @@ import { UserModule } from './user/user.module';
CacheModule, CacheModule,
ConfigModule.forRoot(), ConfigModule.forRoot(),
ConfigurationModule, ConfigurationModule,
CronModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
@ -96,7 +103,7 @@ import { UserModule } from './user/user.module';
RedisCacheModule, RedisCacheModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
exclude: ['/api*', '/sitemap.xml'], exclude: ['/.well-known/*wildcard', '/api/*wildcard', '/sitemap.xml'],
rootPath: join(__dirname, '..', 'client'), rootPath: join(__dirname, '..', 'client'),
serveStaticOptions: { serveStaticOptions: {
setHeaders: (res) => { setHeaders: (res) => {
@ -119,13 +126,21 @@ import { UserModule } from './user/user.module';
} }
} }
}), }),
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'client', '.well-known'),
serveRoot: '/.well-known'
}),
SitemapModule, SitemapModule,
SubscriptionModule, SubscriptionModule,
SymbolModule, SymbolModule,
TagModule, TagsModule,
TwitterBotModule, UserModule,
UserModule WatchlistModule
], ],
providers: [CronService] providers: [I18nService]
}) })
export class AppModule {} export class AppModule implements NestModule {
public configure(consumer: MiddlewareConsumer) {
consumer.apply(HtmlTemplateMiddleware).forRoutes('*wildcard');
}
}

18
apps/api/src/app/auth/api-key.strategy.ts

@ -21,11 +21,10 @@ export class ApiKeyStrategy extends PassportStrategy(
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly userService: UserService private readonly userService: UserService
) { ) {
super( super({ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, false);
{ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, }
true,
async (apiKey: string, done: (error: any, user?: any) => void) => { public async validate(apiKey: string) {
try {
const user = await this.validateApiKey(apiKey); const user = await this.validateApiKey(apiKey);
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
@ -37,7 +36,7 @@ export class ApiKeyStrategy extends PassportStrategy(
} }
await this.prismaService.analytics.upsert({ await this.prismaService.analytics.upsert({
create: { User: { connect: { id: user.id } } }, create: { user: { connect: { id: user.id } } },
update: { update: {
activityCount: { increment: 1 }, activityCount: { increment: 1 },
lastRequestAt: new Date() lastRequestAt: new Date()
@ -46,12 +45,7 @@ export class ApiKeyStrategy extends PassportStrategy(
}); });
} }
done(null, user); return user;
} catch (error) {
done(error, null);
}
}
);
} }
private async validateApiKey(apiKey: string) { private async validateApiKey(apiKey: string) {

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

@ -133,17 +133,19 @@ export class AuthController {
return this.webAuthService.verifyAttestation(body.credential); return this.webAuthService.verifyAttestation(body.credential);
} }
@Post('webauthn/generate-assertion-options') @Post('webauthn/generate-authentication-options')
public async generateAssertionOptions(@Body() body: { deviceId: string }) { public async generateAuthenticationOptions(
return this.webAuthService.generateAssertionOptions(body.deviceId); @Body() body: { deviceId: string }
) {
return this.webAuthService.generateAuthenticationOptions(body.deviceId);
} }
@Post('webauthn/verify-assertion') @Post('webauthn/verify-authentication')
public async verifyAssertion( public async verifyAuthentication(
@Body() body: { deviceId: string; credential: AssertionCredentialJSON } @Body() body: { deviceId: string; credential: AssertionCredentialJSON }
) { ) {
try { try {
const authToken = await this.webAuthService.verifyAssertion( const authToken = await this.webAuthService.verifyAuthentication(
body.deviceId, body.deviceId,
body.credential body.credential
); );

8
apps/api/src/app/auth/auth.service.ts

@ -20,10 +20,10 @@ export class AuthService {
public async validateAnonymousLogin(accessToken: string): Promise<string> { public async validateAnonymousLogin(accessToken: string): Promise<string> {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
try { try {
const hashedAccessToken = this.userService.createAccessToken( const hashedAccessToken = this.userService.createAccessToken({
accessToken, password: accessToken,
this.configurationService.get('ACCESS_TOKEN_SALT') salt: this.configurationService.get('ACCESS_TOKEN_SALT')
); });
const [user] = await this.userService.users({ const [user] = await this.userService.users({
where: { accessToken: hashedAccessToken } where: { accessToken: hashedAccessToken }

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

@ -1,7 +1,11 @@
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config'; import {
DEFAULT_CURRENCY,
DEFAULT_LANGUAGE_CODE,
HEADER_KEY_TIMEZONE
} from '@ghostfolio/common/config';
import { hasRole } from '@ghostfolio/common/permissions'; import { hasRole } from '@ghostfolio/common/permissions';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
@ -42,7 +46,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
countriesAndTimezones.getCountryForTimezone(timezone)?.id; countriesAndTimezones.getCountryForTimezone(timezone)?.id;
await this.prismaService.analytics.upsert({ await this.prismaService.analytics.upsert({
create: { country, User: { connect: { id: user.id } } }, create: { country, user: { connect: { id: user.id } } },
update: { update: {
country, country,
activityCount: { increment: 1 }, activityCount: { increment: 1 },
@ -52,6 +56,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
}); });
} }
if (!user.settings.settings.baseCurrency) {
user.settings.settings.baseCurrency = DEFAULT_CURRENCY;
}
if (!user.settings.settings.language) {
user.settings.settings.language = DEFAULT_LANGUAGE_CODE;
}
return user; return user;
} else { } else {
throw new HttpException( throw new HttpException(

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

@ -24,6 +24,8 @@ import {
verifyRegistrationResponse, verifyRegistrationResponse,
VerifyRegistrationResponseOpts VerifyRegistrationResponseOpts
} from '@simplewebauthn/server'; } from '@simplewebauthn/server';
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
import ms from 'ms';
import { import {
AssertionCredentialJSON, AssertionCredentialJSON,
@ -40,43 +42,42 @@ export class WebAuthService {
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
get rpID() { private get expectedOrigin() {
return new URL(this.configurationService.get('ROOT_URL')).hostname; return this.configurationService.get('ROOT_URL');
} }
get expectedOrigin() { private get rpID() {
return this.configurationService.get('ROOT_URL'); return new URL(this.configurationService.get('ROOT_URL')).hostname;
} }
public async generateRegistrationOptions() { public async generateRegistrationOptions() {
const user = this.request.user; const user = this.request.user;
const opts: GenerateRegistrationOptionsOpts = { const opts: GenerateRegistrationOptionsOpts = {
rpName: 'Ghostfolio',
rpID: this.rpID,
userID: user.id,
userName: '',
timeout: 60000,
attestationType: 'indirect',
authenticatorSelection: { authenticatorSelection: {
authenticatorAttachment: 'platform', authenticatorAttachment: 'platform',
requireResidentKey: false, residentKey: 'required',
userVerification: 'required' userVerification: 'preferred'
} },
rpID: this.rpID,
rpName: 'Ghostfolio',
timeout: ms('60 seconds'),
userID: isoUint8Array.fromUTF8String(user.id),
userName: ''
}; };
const options = await generateRegistrationOptions(opts); const registrationOptions = await generateRegistrationOptions(opts);
await this.userService.updateUser({ await this.userService.updateUser({
data: { data: {
authChallenge: options.challenge authChallenge: registrationOptions.challenge
}, },
where: { where: {
id: user.id id: user.id
} }
}); });
return options; return registrationOptions;
} }
public async verifyAttestation( public async verifyAttestation(
@ -84,13 +85,14 @@ export class WebAuthService {
): Promise<AuthDeviceDto> { ): Promise<AuthDeviceDto> {
const user = this.request.user; const user = this.request.user;
const expectedChallenge = user.authChallenge; const expectedChallenge = user.authChallenge;
let verification: VerifiedRegistrationResponse; let verification: VerifiedRegistrationResponse;
try { try {
const opts: VerifyRegistrationResponseOpts = { const opts: VerifyRegistrationResponseOpts = {
expectedChallenge, expectedChallenge,
expectedOrigin: this.expectedOrigin, expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID, expectedRPID: this.rpID,
requireUserVerification: false,
response: { response: {
clientExtensionResults: credential.clientExtensionResults, clientExtensionResults: credential.clientExtensionResults,
id: credential.id, id: credential.id,
@ -99,6 +101,7 @@ export class WebAuthService {
type: 'public-key' type: 'public-key'
} }
}; };
verification = await verifyRegistrationResponse(opts); verification = await verifyRegistrationResponse(opts);
} catch (error) { } catch (error) {
Logger.error(error, 'WebAuthService'); Logger.error(error, 'WebAuthService');
@ -111,11 +114,17 @@ export class WebAuthService {
where: { userId: user.id } where: { userId: user.id }
}); });
if (registrationInfo && verified) { if (registrationInfo && verified) {
const { counter, credentialID, credentialPublicKey } = registrationInfo; const {
credential: {
counter,
id: credentialId,
publicKey: credentialPublicKey
}
} = registrationInfo;
let existingDevice = devices.find( let existingDevice = devices.find((device) => {
(device) => device.credentialId === credentialID return isoBase64URL.fromBuffer(device.credentialId) === credentialId;
); });
if (!existingDevice) { if (!existingDevice) {
/** /**
@ -123,9 +132,9 @@ export class WebAuthService {
*/ */
existingDevice = await this.deviceService.createAuthDevice({ existingDevice = await this.deviceService.createAuthDevice({
counter, counter,
credentialId: Buffer.from(credentialID), credentialId: Buffer.from(credentialId),
credentialPublicKey: Buffer.from(credentialPublicKey), credentialPublicKey: Buffer.from(credentialPublicKey),
User: { connect: { id: user.id } } user: { connect: { id: user.id } }
}); });
} }
@ -138,7 +147,7 @@ export class WebAuthService {
throw new InternalServerErrorException('An unknown error occurred'); throw new InternalServerErrorException('An unknown error occurred');
} }
public async generateAssertionOptions(deviceId: string) { public async generateAuthenticationOptions(deviceId: string) {
const device = await this.deviceService.authDevice({ id: deviceId }); const device = await this.deviceService.authDevice({ id: deviceId });
if (!device) { if (!device) {
@ -146,33 +155,27 @@ export class WebAuthService {
} }
const opts: GenerateAuthenticationOptionsOpts = { const opts: GenerateAuthenticationOptionsOpts = {
allowCredentials: [ allowCredentials: [],
{
id: device.credentialId,
transports: ['internal'],
type: 'public-key'
}
],
rpID: this.rpID, rpID: this.rpID,
timeout: 60000, timeout: ms('60 seconds'),
userVerification: 'preferred' userVerification: 'preferred'
}; };
const options = await generateAuthenticationOptions(opts); const authenticationOptions = await generateAuthenticationOptions(opts);
await this.userService.updateUser({ await this.userService.updateUser({
data: { data: {
authChallenge: options.challenge authChallenge: authenticationOptions.challenge
}, },
where: { where: {
id: device.userId id: device.userId
} }
}); });
return options; return authenticationOptions;
} }
public async verifyAssertion( public async verifyAuthentication(
deviceId: string, deviceId: string,
credential: AssertionCredentialJSON credential: AssertionCredentialJSON
) { ) {
@ -185,16 +188,18 @@ export class WebAuthService {
const user = await this.userService.user({ id: device.userId }); const user = await this.userService.user({ id: device.userId });
let verification: VerifiedAuthenticationResponse; let verification: VerifiedAuthenticationResponse;
try { try {
const opts: VerifyAuthenticationResponseOpts = { const opts: VerifyAuthenticationResponseOpts = {
authenticator: { credential: {
credentialID: device.credentialId, counter: device.counter,
credentialPublicKey: device.credentialPublicKey, id: isoBase64URL.fromBuffer(device.credentialId),
counter: device.counter publicKey: device.credentialPublicKey
}, },
expectedChallenge: `${user.authChallenge}`, expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin, expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID, expectedRPID: this.rpID,
requireUserVerification: false,
response: { response: {
clientExtensionResults: credential.clientExtensionResults, clientExtensionResults: credential.clientExtensionResults,
id: credential.id, id: credential.id,
@ -203,13 +208,14 @@ export class WebAuthService {
type: 'public-key' type: 'public-key'
} }
}; };
verification = await verifyAuthenticationResponse(opts); verification = await verifyAuthenticationResponse(opts);
} catch (error) { } catch (error) {
Logger.error(error, 'WebAuthService'); Logger.error(error, 'WebAuthService');
throw new InternalServerErrorException({ error: error.message }); throw new InternalServerErrorException({ error: error.message });
} }
const { verified, authenticationInfo } = verification; const { authenticationInfo, verified } = verification;
if (verified) { if (verified) {
device.counter = authenticationInfo.newCounter; device.counter = authenticationInfo.newCounter;

59
apps/api/src/app/endpoints/ai/ai.controller.ts

@ -0,0 +1,59 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { AiPromptResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
Inject,
Param,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { AiService } from './ai.service';
@Controller('ai')
export class AiController {
public constructor(
private readonly aiService: AiService,
private readonly apiService: ApiService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get('prompt/:mode')
@HasPermission(permissions.readAiPrompt)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPrompt(
@Param('mode') mode: AiPromptMode,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<AiPromptResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});
const prompt = await this.aiService.getPrompt({
filters,
mode,
impersonationId: undefined,
languageCode: this.request.user.settings.settings.language,
userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id
});
return { prompt };
}
}

59
apps/api/src/app/endpoints/ai/ai.module.ts

@ -0,0 +1,59 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
@Module({
controllers: [AiController],
imports: [
ApiModule,
BenchmarkModule,
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
I18nModule,
ImpersonationModule,
MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule,
UserModule
],
providers: [
AccountBalanceService,
AccountService,
AiService,
CurrentRateService,
MarketDataService,
PortfolioCalculatorFactory,
PortfolioService,
RulesService
]
})
export class AiModule {}

100
apps/api/src/app/endpoints/ai/ai.service.ts

@ -0,0 +1,100 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_API_KEY_OPENROUTER,
PROPERTY_OPENROUTER_MODEL
} from '@ghostfolio/common/config';
import { Filter } from '@ghostfolio/common/interfaces';
import type { AiPromptMode } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { generateText } from 'ai';
@Injectable()
export class AiService {
public constructor(
private readonly portfolioService: PortfolioService,
private readonly propertyService: PropertyService
) {}
public async generateText({ prompt }: { prompt: string }) {
const openRouterApiKey = await this.propertyService.getByKey<string>(
PROPERTY_API_KEY_OPENROUTER
);
const openRouterModel = await this.propertyService.getByKey<string>(
PROPERTY_OPENROUTER_MODEL
);
const openRouterService = createOpenRouter({
apiKey: openRouterApiKey
});
return generateText({
prompt,
model: openRouterService.chat(openRouterModel)
});
}
public async getPrompt({
filters,
impersonationId,
languageCode,
mode,
userCurrency,
userId
}: {
filters?: Filter[];
impersonationId: string;
languageCode: string;
mode: AiPromptMode;
userCurrency: string;
userId: string;
}) {
const { holdings } = await this.portfolioService.getDetails({
filters,
impersonationId,
userId
});
const holdingsTable = [
'| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |',
'| --- | --- | --- | --- | --- | --- |',
...Object.values(holdings)
.sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage;
})
.map(
({
allocationInPercentage,
assetClass,
assetSubClass,
currency,
name,
symbol
}) => {
return `| ${name} | ${symbol} | ${currency} | ${assetClass} | ${assetSubClass} | ${(allocationInPercentage * 100).toFixed(3)}% |`;
}
)
];
if (mode === 'portfolio') {
return holdingsTable.join('\n');
}
return [
`You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`,
...holdingsTable,
'Structure your answer with these sections:',
'Overview: Briefly summarize the portfolio’s composition and allocation rationale.',
'Risk Assessment: Identify potential risks, including market volatility, concentration, and sectoral imbalances.',
'Advantages: Highlight strengths, focusing on growth potential, diversification, or other benefits.',
'Disadvantages: Point out weaknesses, such as overexposure or lack of defensive assets.',
'Target Group: Discuss who this portfolio might suit (e.g., risk tolerance, investment goals, life stages, and experience levels).',
'Optimization Ideas: Offer ideas to complement the portfolio, ensuring they are constructive and neutral in tone.',
'Conclusion: Provide a concise summary highlighting key insights.',
`Provide your answer in the following language: ${languageCode}.`
].join('\n');
}
}

46
apps/api/src/app/endpoints/assets/assets.controller.ts

@ -0,0 +1,46 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { interpolate } from '@ghostfolio/common/helper';
import {
Controller,
Get,
Param,
Res,
Version,
VERSION_NEUTRAL
} from '@nestjs/common';
import { Response } from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';
@Controller('assets')
export class AssetsController {
private webManifest = '';
public constructor(
public readonly configurationService: ConfigurationService
) {
try {
this.webManifest = readFileSync(
join(__dirname, 'assets', 'site.webmanifest'),
'utf8'
);
} catch {}
}
@Get('/:languageCode/site.webmanifest')
@Version(VERSION_NEUTRAL)
public getWebManifest(
@Param('languageCode') languageCode: string,
@Res() response: Response
): void {
const rootUrl = this.configurationService.get('ROOT_URL');
const webManifest = interpolate(this.webManifest, {
languageCode,
rootUrl
});
response.setHeader('Content-Type', 'application/json');
response.send(webManifest);
}
}

11
apps/api/src/app/endpoints/assets/assets.module.ts

@ -0,0 +1,11 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Module } from '@nestjs/common';
import { AssetsController } from './assets.controller';
@Module({
controllers: [AssetsController],
providers: [ConfigurationService]
})
export class AssetsModule {}

40
apps/api/src/app/benchmark/benchmark.controller.ts → apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts

@ -2,7 +2,10 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import type { import type {
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
@ -16,6 +19,7 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -29,12 +33,14 @@ import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { BenchmarkService } from './benchmark.service'; import { BenchmarksService } from './benchmarks.service';
@Controller('benchmark') @Controller('benchmarks')
export class BenchmarkController { export class BenchmarksController {
public constructor( public constructor(
private readonly apiService: ApiService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly benchmarksService: BenchmarksService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -108,23 +114,43 @@ export class BenchmarkController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataForUser( public async getBenchmarkMarketDataForUser(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string, @Param('startDateString') startDateString: string,
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Query('range') dateRange: DateRange = 'max' @Query('range') dateRange: DateRange = 'max',
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
): Promise<BenchmarkMarketDataDetails> { ): Promise<BenchmarkMarketDataDetails> {
const { endDate, startDate } = getIntervalFromDateRange( const { endDate, startDate } = getIntervalFromDateRange(
dateRange, dateRange,
new Date(startDateString) new Date(startDateString)
); );
const userCurrency = this.request.user.Settings.settings.baseCurrency;
return this.benchmarkService.getMarketDataForUser({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});
const withExcludedAccounts = withExcludedAccountsParam === 'true';
return this.benchmarksService.getMarketDataForUser({
dataSource, dataSource,
dateRange,
endDate, endDate,
filters,
impersonationId,
startDate, startDate,
symbol, symbol,
userCurrency withExcludedAccounts,
user: this.request.user
}); });
} }
} }

65
apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts

@ -0,0 +1,65 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { BenchmarksController } from './benchmarks.controller';
import { BenchmarksService } from './benchmarks.service';
@Module({
controllers: [BenchmarksController],
imports: [
ApiModule,
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
I18nModule,
ImpersonationModule,
MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule,
UserModule
],
providers: [
AccountBalanceService,
AccountService,
BenchmarkService,
BenchmarksService,
CurrentRateService,
MarketDataService,
PortfolioCalculatorFactory,
PortfolioService,
RulesService
]
})
export class BenchmarksModule {}

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

@ -0,0 +1,163 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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 { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
BenchmarkMarketDataDetails,
Filter
} from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { format, isSameDay } from 'date-fns';
import { isNumber } from 'lodash';
@Injectable()
export class BenchmarksService {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly portfolioService: PortfolioService,
private readonly symbolService: SymbolService
) {}
public async getMarketDataForUser({
dataSource,
dateRange,
endDate = new Date(),
filters,
impersonationId,
startDate,
symbol,
user,
withExcludedAccounts
}: {
dateRange: DateRange;
endDate?: Date;
filters?: Filter[];
impersonationId: string;
startDate: Date;
user: UserWithSettings;
withExcludedAccounts?: boolean;
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
const marketData: { date: string; value: number }[] = [];
const userCurrency = user.settings.settings.baseCurrency;
const userId = user.id;
const { chart } = await this.portfolioService.getPerformance({
dateRange,
filters,
impersonationId,
userId,
withExcludedAccounts
});
const [currentSymbolItem, marketDataItems] = await Promise.all([
this.symbolService.get({
dataGatheringItem: {
dataSource,
symbol
}
}),
this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
dataSource,
symbol,
date: {
in: chart.map(({ date }) => {
return resetHours(parseDate(date));
})
}
}
})
]);
const exchangeRates =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
startDate,
currencies: [currentSymbolItem.currency],
targetCurrency: userCurrency
});
const exchangeRateAtStartDate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(startDate, DATE_FORMAT)
];
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
return isSameDay(date, startDate);
})?.marketPrice;
if (!marketPriceAtStartDate) {
Logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
startDate,
DATE_FORMAT
)}`,
'BenchmarkService'
);
return { marketData };
}
for (const marketDataItem of marketDataItems) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(marketDataItem.date, DATE_FORMAT)
];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(marketDataItem.date, DATE_FORMAT),
value:
marketPriceAtStartDate === 0
? 0
: this.benchmarkService.calculateChangeInPercentage(
marketPriceAtStartDate,
marketDataItem.marketPrice * exchangeRateFactor
) * 100
});
}
const includesEndDate = isSameDay(
parseDate(marketData.at(-1).date),
endDate
);
if (currentSymbolItem?.marketPrice && !includesEndDate) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(endDate, DATE_FORMAT)
];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(endDate, DATE_FORMAT),
value:
this.benchmarkService.calculateChangeInPercentage(
marketPriceAtStartDate,
currentSymbolItem.marketPrice * exchangeRateFactor
) * 100
});
}
return {
marketData
};
}
}

160
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -2,6 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderGhostfolioStatusResponse, DataProviderGhostfolioStatusResponse,
DividendsResponse, DividendsResponse,
HistoricalResponse, HistoricalResponse,
@ -23,6 +24,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { isISIN } from 'class-validator';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { GetDividendsDto } from './get-dividends.dto'; import { GetDividendsDto } from './get-dividends.dto';
@ -37,16 +39,12 @@ export class GhostfolioController {
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
/** @Get('asset-profile/:symbol')
* @deprecated
*/
@Get('dividends/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)
public async getDividendsV1( public async getAssetProfile(
@Param('symbol') symbol: string, @Param('symbol') symbol: string
@Query() query: GetDividendsDto ): Promise<DataProviderGhostfolioAssetProfileResponse> {
): Promise<DividendsResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
@ -59,18 +57,15 @@ export class GhostfolioController {
} }
try { try {
const dividends = await this.ghostfolioService.getDividends({ const assetProfile = await this.ghostfolioService.getAssetProfile({
symbol, symbol
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
}); });
await this.ghostfolioService.incrementDailyRequests({ await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id userId: this.request.user.id
}); });
return dividends; return assetProfile;
} catch { } catch {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
@ -119,48 +114,6 @@ export class GhostfolioController {
} }
} }
/**
* @deprecated
*/
@Get('historical/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getHistoricalV1(
@Param('symbol') symbol: string,
@Query() query: GetHistoricalDto
): Promise<HistoricalResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const historicalData = await this.ghostfolioService.getHistorical({
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return historicalData;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('historical/:symbol') @Get('historical/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@ -201,47 +154,6 @@ export class GhostfolioController {
} }
} }
/**
* @deprecated
*/
@Get('lookup')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async lookupSymbolV1(
@Query('includeIndices') includeIndicesParam = 'false',
@Query('query') query = ''
): Promise<LookupResponse> {
const includeIndices = includeIndicesParam === 'true';
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const result = await this.ghostfolioService.lookup({
includeIndices,
query: query.toLowerCase()
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return result;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('lookup') @Get('lookup')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@ -265,7 +177,9 @@ export class GhostfolioController {
try { try {
const result = await this.ghostfolioService.lookup({ const result = await this.ghostfolioService.lookup({
includeIndices, includeIndices,
query: query.toLowerCase() query: isISIN(query.toUpperCase())
? query.toUpperCase()
: query.toLowerCase()
}); });
await this.ghostfolioService.incrementDailyRequests({ await this.ghostfolioService.incrementDailyRequests({
@ -281,44 +195,6 @@ export class GhostfolioController {
} }
} }
/**
* @deprecated
*/
@Get('quotes')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getQuotesV1(
@Query() query: GetQuotesDto
): Promise<QuotesResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const quotes = await this.ghostfolioService.getQuotes({
symbols: query.symbols
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return quotes;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('quotes') @Get('quotes')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@ -355,16 +231,6 @@ export class GhostfolioController {
} }
} }
/**
* @deprecated
*/
@Get('status')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getStatusV1(): Promise<DataProviderGhostfolioStatusResponse> {
return this.ghostfolioService.getStatus({ user: this.request.user });
}
@Get('status') @Get('status')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)

57
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -1,6 +1,8 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service';
import { import {
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -15,6 +17,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config'; import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import { import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderInfo, DataProviderInfo,
DividendsResponse, DividendsResponse,
HistoricalResponse, HistoricalResponse,
@ -25,7 +28,7 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
@Injectable() @Injectable()
@ -37,6 +40,44 @@ export class GhostfolioService {
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
public async getAssetProfile({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol
}: GetAssetProfileParams) {
let result: DataProviderGhostfolioAssetProfileResponse = {};
try {
const promises: Promise<Partial<SymbolProfile>>[] = [];
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
dataProviderService
.getAssetProfile({
requestTimeout,
symbol
})
.then((assetProfile) => {
result = {
...result,
...assetProfile,
dataSource: DataSource.GHOSTFOLIO
};
return assetProfile;
})
);
}
await Promise.all(promises);
return result;
} catch (error) {
Logger.error(error, 'GhostfolioService');
throw error;
}
}
public async getDividends({ public async getDividends({
from, from,
granularity, granularity,
@ -123,9 +164,9 @@ export class GhostfolioService {
public async getMaxDailyRequests() { public async getMaxDailyRequests() {
return parseInt( return parseInt(
((await this.propertyService.getByKey( (await this.propertyService.getByKey<string>(
PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS
)) as string) || '0', )) || '0',
10 10
); );
} }
@ -277,6 +318,7 @@ export class GhostfolioService {
}); });
results.items = filteredItems; results.items = filteredItems;
return results; return results;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); Logger.error(error, 'GhostfolioService');
@ -286,10 +328,15 @@ export class GhostfolioService {
} }
private getDataProviderInfo(): DataProviderInfo { private getDataProviderInfo(): DataProviderInfo {
const ghostfolioDataProviderService = new GhostfolioDataProviderService(
this.configurationService,
this.propertyService
);
return { return {
...ghostfolioDataProviderService.getDataProviderInfo(),
isPremium: false, isPremium: false,
name: 'Ghostfolio Premium', name: 'Ghostfolio Premium'
url: 'https://ghostfol.io'
}; };
} }

65
apps/api/src/app/endpoints/market-data/market-data.controller.ts

@ -1,7 +1,20 @@
import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { MarketDataDetailsResponse } from '@ghostfolio/common/interfaces'; import {
ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
ghostfolioFearAndGreedIndexDataSourceStocks,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks
} from '@ghostfolio/common/config';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import {
MarketDataDetailsResponse,
MarketDataOfMarketsResponse
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
@ -13,6 +26,7 @@ import {
Inject, Inject,
Param, Param,
Post, Post,
Query,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
@ -29,9 +43,48 @@ export class MarketDataController {
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService,
private readonly symbolService: SymbolService
) {} ) {}
@Get('markets')
@HasPermission(permissions.readMarketDataOfMarkets)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketDataOfMarkets(
@Query('includeHistoricalData') includeHistoricalData = 0
): Promise<MarketDataOfMarketsResponse> {
const [
marketDataFearAndGreedIndexCryptocurrencies,
marketDataFearAndGreedIndexStocks
] = await Promise.all([
this.symbolService.get({
includeHistoricalData,
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies
}
}),
this.symbolService.get({
includeHistoricalData,
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceStocks,
symbol: ghostfolioFearAndGreedIndexSymbolStocks
}
})
]);
return {
fearAndGreedIndex: {
CRYPTOCURRENCIES: {
...marketDataFearAndGreedIndexCryptocurrencies
},
STOCKS: {
...marketDataFearAndGreedIndexStocks
}
}
};
}
@Get(':dataSource/:symbol') @Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getMarketDataBySymbol( public async getMarketDataBySymbol(
@ -42,7 +95,7 @@ export class MarketDataController {
{ dataSource, symbol } { dataSource, symbol }
]); ]);
if (!assetProfile) { if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND StatusCodes.NOT_FOUND
@ -55,7 +108,7 @@ export class MarketDataController {
); );
const canReadOwnAssetProfile = const canReadOwnAssetProfile =
assetProfile.userId === this.request.user.id && assetProfile?.userId === this.request.user.id &&
hasPermission( hasPermission(
this.request.user.permissions, this.request.user.permissions,
permissions.readMarketDataOfOwnAssetProfile permissions.readMarketDataOfOwnAssetProfile
@ -84,7 +137,7 @@ export class MarketDataController {
{ dataSource, symbol } { dataSource, symbol }
]); ]);
if (!assetProfile) { if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND StatusCodes.NOT_FOUND
@ -102,7 +155,7 @@ export class MarketDataController {
); );
const canUpsertOwnAssetProfile = const canUpsertOwnAssetProfile =
assetProfile.userId === this.request.user.id && assetProfile?.userId === this.request.user.id &&
hasPermission( hasPermission(
this.request.user.permissions, this.request.user.permissions,
permissions.createMarketDataOfOwnAssetProfile permissions.createMarketDataOfOwnAssetProfile

8
apps/api/src/app/endpoints/market-data/market-data.module.ts

@ -1,4 +1,5 @@
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module'; import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -8,6 +9,11 @@ import { MarketDataController } from './market-data.controller';
@Module({ @Module({
controllers: [MarketDataController], controllers: [MarketDataController],
imports: [AdminModule, MarketDataServiceModule, SymbolProfileModule] imports: [
AdminModule,
MarketDataServiceModule,
SymbolModule,
SymbolProfileModule
]
}) })
export class MarketDataModule {} export class MarketDataModule {}

5
apps/api/src/app/endpoints/public/public.controller.ts

@ -57,7 +57,7 @@ export class PublicController {
} }
const [ const [
{ holdings, markets }, { createdAt, holdings, markets },
{ performance: performance1d }, { performance: performance1d },
{ performance: performanceMax }, { performance: performanceMax },
{ performance: performanceYtd } { performance: performanceYtd }
@ -81,6 +81,7 @@ export class PublicController {
}); });
const publicPortfolioResponse: PublicPortfolioResponse = { const publicPortfolioResponse: PublicPortfolioResponse = {
createdAt,
hasDetails, hasDetails,
markets, markets,
alias: access.alias, alias: access.alias,
@ -107,7 +108,7 @@ export class PublicController {
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
quantity * marketPrice, quantity * marketPrice,
currency, currency,
this.request.user?.Settings?.settings.baseCurrency ?? this.request.user?.settings?.settings.baseCurrency ??
DEFAULT_CURRENCY DEFAULT_CURRENCY
) )
); );

4
apps/api/src/app/endpoints/public/public.module.ts

@ -9,8 +9,10 @@ import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -25,8 +27,10 @@ import { PublicController } from './public.controller';
controllers: [PublicController], controllers: [PublicController],
imports: [ imports: [
AccessModule, AccessModule,
BenchmarkModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,

51
apps/api/src/app/endpoints/sitemap/sitemap.controller.ts

@ -0,0 +1,51 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DATE_FORMAT,
getYesterday,
interpolate
} from '@ghostfolio/common/helper';
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns';
import { Response } from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';
import { SitemapService } from './sitemap.service';
@Controller('sitemap.xml')
export class SitemapController {
public sitemapXml = '';
public constructor(
private readonly configurationService: ConfigurationService,
private readonly sitemapService: SitemapService
) {
try {
this.sitemapXml = readFileSync(
join(__dirname, 'assets', 'sitemap.xml'),
'utf8'
);
} catch {}
}
@Get()
@Version(VERSION_NEUTRAL)
public getSitemapXml(@Res() response: Response) {
const currentDate = format(getYesterday(), DATE_FORMAT);
response.setHeader('content-type', 'application/xml');
response.send(
interpolate(this.sitemapXml, {
personalFinanceTools: this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? this.sitemapService.getPersonalFinanceTools({ currentDate })
: '',
publicRoutes: this.sitemapService.getPublicRoutes({
currentDate
})
})
);
}
}

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

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

116
apps/api/src/app/endpoints/sitemap/sitemap.service.ts

@ -0,0 +1,116 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { SUPPORTED_LANGUAGE_CODES } from '@ghostfolio/common/config';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { PublicRoute } from '@ghostfolio/common/routes/interfaces/public-route.interface';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Injectable } from '@nestjs/common';
@Injectable()
export class SitemapService {
private static readonly TRANSLATION_TAGGED_MESSAGE_REGEX =
/:.*@@(?<id>[a-zA-Z0-9.]+):(?<message>.+)/;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly i18nService: I18nService
) {}
public getPersonalFinanceTools({ currentDate }: { currentDate: string }) {
const rootUrl = this.configurationService.get('ROOT_URL');
return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => {
return personalFinanceTools.map(({ alias, key }) => {
const route =
publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes
.product;
const params = {
currentDate,
languageCode,
rootUrl,
urlPostfix: alias ?? key
};
return this.createRouteSitemapUrl({ ...params, route });
});
}).join('\n');
}
public getPublicRoutes({ currentDate }: { currentDate: string }) {
const rootUrl = this.configurationService.get('ROOT_URL');
return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => {
const params = {
currentDate,
languageCode,
rootUrl
};
return [
this.createRouteSitemapUrl(params),
...this.createSitemapUrls(params, publicRoutes)
];
}).join('\n');
}
private createRouteSitemapUrl({
currentDate,
languageCode,
rootUrl,
route,
urlPostfix
}: {
currentDate: string;
languageCode: string;
rootUrl: string;
route?: PublicRoute;
urlPostfix?: string;
}): string {
const segments =
route?.routerLink.map((link) => {
const match = link.match(
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX
);
const segment = match
? (this.i18nService.getTranslation({
languageCode,
id: match.groups.id
}) ?? match.groups.message)
: link;
return segment.replace(/^\/+|\/+$/, '');
}) ?? [];
const location =
[rootUrl, languageCode, ...segments].join('/') +
(urlPostfix ? `-${urlPostfix}` : '');
return [
' <url>',
` <loc>${location}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
' </url>'
].join('\n');
}
private createSitemapUrls(
params: { currentDate: string; languageCode: string; rootUrl: string },
routes: Record<string, PublicRoute>
): string[] {
return Object.values(routes).flatMap((route) => {
if (route.excludeFromSitemap) {
return [];
}
const urls = [this.createRouteSitemapUrl({ ...params, route })];
if (route.subRoutes) {
urls.push(...this.createSitemapUrls(params, route.subRoutes));
}
return urls;
});
}
}

10
apps/api/src/app/endpoints/tags/create-tag.dto.ts

@ -0,0 +1,10 @@
import { IsOptional, IsString } from 'class-validator';
export class CreateTagDto {
@IsString()
name: string;
@IsOptional()
@IsString()
userId?: string;
}

87
apps/api/src/app/tag/tag.controller.ts → apps/api/src/app/endpoints/tags/tags.controller.ts

@ -1,6 +1,8 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -8,41 +10,63 @@ import {
Delete, Delete,
Get, Get,
HttpException, HttpException,
Inject,
Param, Param,
Post, Post,
Put, Put,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateTagDto } from './create-tag.dto'; import { CreateTagDto } from './create-tag.dto';
import { TagService } from './tag.service';
import { UpdateTagDto } from './update-tag.dto'; import { UpdateTagDto } from './update-tag.dto';
@Controller('tag') @Controller('tags')
export class TagController { export class TagsController {
public constructor(private readonly tagService: TagService) {} public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
@Get() private readonly tagService: TagService
@HasPermission(permissions.readTags) ) {}
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@Post() @Post()
@HasPermission(permissions.createTag) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createTag(@Body() data: CreateTagDto): Promise<Tag> { public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
const canCreateOwnTag = hasPermission(
this.request.user.permissions,
permissions.createOwnTag
);
const canCreateTag = hasPermission(
this.request.user.permissions,
permissions.createTag
);
if (!canCreateOwnTag && !canCreateTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (canCreateOwnTag && !canCreateTag) {
if (data.userId !== this.request.user.id) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
return this.tagService.createTag(data); return this.tagService.createTag(data);
} }
@HasPermission(permissions.updateTag) @Delete(':id')
@Put(':id') @HasPermission(permissions.deleteTag)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) { public async deleteTag(@Param('id') id: string) {
const originalTag = await this.tagService.getTag({ const originalTag = await this.tagService.getTag({
id id
}); });
@ -54,20 +78,20 @@ export class TagController {
); );
} }
return this.tagService.updateTag({ return this.tagService.deleteTag({ id });
data: {
...data
},
where: {
id
} }
});
@Get()
@HasPermission(permissions.readTags)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() {
return this.tagService.getTagsWithActivityCount();
} }
@Delete(':id') @HasPermission(permissions.updateTag)
@HasPermission(permissions.deleteTag) @Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteTag(@Param('id') id: string) { public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
const originalTag = await this.tagService.getTag({ const originalTag = await this.tagService.getTag({
id id
}); });
@ -79,6 +103,13 @@ export class TagController {
); );
} }
return this.tagService.deleteTag({ id }); return this.tagService.updateTag({
data: {
...data
},
where: {
id
}
});
} }
} }

12
apps/api/src/app/endpoints/tags/tags.module.ts

@ -0,0 +1,12 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
import { TagsController } from './tags.controller';
@Module({
controllers: [TagsController],
imports: [PrismaModule, TagModule]
})
export class TagsModule {}

13
apps/api/src/app/endpoints/tags/update-tag.dto.ts

@ -0,0 +1,13 @@
import { IsOptional, IsString } from 'class-validator';
export class UpdateTagDto {
@IsString()
id: string;
@IsString()
name: string;
@IsOptional()
@IsString()
userId?: string;
}

10
apps/api/src/app/endpoints/watchlist/create-watchlist-item.dto.ts

@ -0,0 +1,10 @@
import { DataSource } from '@prisma/client';
import { IsEnum, IsString } from 'class-validator';
export class CreateWatchlistItemDto {
@IsEnum(DataSource)
dataSource: DataSource;
@IsString()
symbol: string;
}

100
apps/api/src/app/endpoints/watchlist/watchlist.controller.ts

@ -0,0 +1,100 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpException,
Inject,
Param,
Post,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateWatchlistItemDto } from './create-watchlist-item.dto';
import { WatchlistService } from './watchlist.service';
@Controller('watchlist')
export class WatchlistController {
public constructor(
private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly watchlistService: WatchlistService
) {}
@Post()
@HasPermission(permissions.createWatchlistItem)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createWatchlistItem(@Body() data: CreateWatchlistItemDto) {
return this.watchlistService.createWatchlistItem({
dataSource: data.dataSource,
symbol: data.symbol,
userId: this.request.user.id
});
}
@Delete(':dataSource/:symbol')
@HasPermission(permissions.deleteWatchlistItem)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteWatchlistItem(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
const watchlistItems = await this.watchlistService.getWatchlistItems(
this.request.user.id
);
const watchlistItem = watchlistItems.find((item) => {
return item.dataSource === dataSource && item.symbol === symbol;
});
if (!watchlistItem) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return this.watchlistService.deleteWatchlistItem({
dataSource,
symbol,
userId: this.request.user.id
});
}
@Get()
@HasPermission(permissions.readWatchlist)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getWatchlistItems(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<WatchlistResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const watchlist = await this.watchlistService.getWatchlistItems(
impersonationUserId || this.request.user.id
);
return {
watchlist
};
}
}

27
apps/api/src/app/benchmark/benchmark.module.ts → apps/api/src/app/endpoints/watchlist/watchlist.module.ts

@ -1,36 +1,31 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { BenchmarkController } from './benchmark.controller'; import { WatchlistController } from './watchlist.controller';
import { BenchmarkService } from './benchmark.service'; import { WatchlistService } from './watchlist.service';
@Module({ @Module({
controllers: [BenchmarkController], controllers: [WatchlistController],
exports: [BenchmarkService],
imports: [ imports: [
ConfigurationModule, BenchmarkModule,
DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolModule,
SymbolProfileModule, SymbolProfileModule,
TransformDataSourceInRequestModule, TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule TransformDataSourceInResponseModule
], ],
providers: [BenchmarkService] providers: [WatchlistService]
}) })
export class BenchmarkModule {} export class WatchlistModule {}

150
apps/api/src/app/endpoints/watchlist/watchlist.service.ts

@ -0,0 +1,150 @@
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { BadRequestException, Injectable } from '@nestjs/common';
import { DataSource, Prisma } from '@prisma/client';
@Injectable()
export class WatchlistService {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async createWatchlistItem({
dataSource,
symbol,
userId
}: {
dataSource: DataSource;
symbol: string;
userId: string;
}): Promise<void> {
const symbolProfile = await this.prismaService.symbolProfile.findUnique({
where: {
dataSource_symbol: { dataSource, symbol }
}
});
if (!symbolProfile) {
const assetProfiles = await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol }
]);
if (!assetProfiles[symbol]?.currency) {
throw new BadRequestException(
`Asset profile not found for ${symbol} (${dataSource})`
);
}
await this.symbolProfileService.add(
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
);
}
await this.dataGatheringService.gatherSymbol({
dataSource,
symbol
});
await this.prismaService.user.update({
data: {
watchlist: {
connect: {
dataSource_symbol: { dataSource, symbol }
}
}
},
where: { id: userId }
});
}
public async deleteWatchlistItem({
dataSource,
symbol,
userId
}: {
dataSource: DataSource;
symbol: string;
userId: string;
}) {
await this.prismaService.user.update({
data: {
watchlist: {
disconnect: {
dataSource_symbol: { dataSource, symbol }
}
}
},
where: { id: userId }
});
}
public async getWatchlistItems(
userId: string
): Promise<WatchlistResponse['watchlist']> {
const user = await this.prismaService.user.findUnique({
select: {
watchlist: {
select: { dataSource: true, symbol: true }
}
},
where: { id: userId }
});
const [assetProfiles, quotes] = await Promise.all([
this.symbolProfileService.getSymbolProfiles(user.watchlist),
this.dataProviderService.getQuotes({
items: user.watchlist.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
})
]);
const watchlist = await Promise.all(
user.watchlist.map(async ({ dataSource, symbol }) => {
const assetProfile = assetProfiles.find((profile) => {
return profile.dataSource === dataSource && profile.symbol === symbol;
});
const allTimeHigh = await this.marketDataService.getMax({
dataSource,
symbol
});
const performancePercent =
this.benchmarkService.calculateChangeInPercentage(
allTimeHigh?.marketPrice,
quotes[symbol]?.marketPrice
);
return {
dataSource,
symbol,
marketCondition:
this.benchmarkService.getMarketCondition(performancePercent),
name: assetProfile?.name,
performances: {
allTimeHigh: {
performancePercent,
date: allTimeHigh?.date
}
}
};
})
);
return watchlist.sort((a, b) => {
return a.name.localeCompare(b.name);
});
}
}

20
apps/api/src/app/export/export.controller.ts

@ -1,9 +1,17 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { Export } from '@ghostfolio/common/interfaces'; import { Export } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common'; import {
Controller,
Get,
Inject,
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -19,22 +27,28 @@ export class ExportController {
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async export( public async export(
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('activityIds') activityIds?: string[], @Query('activityIds') filterByActivityIds?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<Export> { ): Promise<Export> {
const activityIds = filterByActivityIds?.split(',') ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags filterByTags
}); });
return this.exportService.export({ return this.exportService.export({
activityIds, activityIds,
filters, filters,
userCurrency: this.request.user.Settings.settings.baseCurrency, userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id userId: this.request.user.id
}); });
} }

12
apps/api/src/app/export/export.module.ts

@ -1,6 +1,9 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module'; import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -8,8 +11,15 @@ import { ExportController } from './export.controller';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
@Module({ @Module({
imports: [AccountModule, ApiModule, OrderModule],
controllers: [ExportController], controllers: [ExportController],
imports: [
AccountModule,
ApiModule,
MarketDataModule,
OrderModule,
TagModule,
TransformDataSourceInRequestModule
],
providers: [ExportService] providers: [ExportService]
}) })
export class ExportModule {} export class ExportModule {}

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

@ -1,15 +1,21 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { Filter, Export } from '@ghostfolio/common/interfaces'; import { Filter, Export } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Platform, Prisma } from '@prisma/client';
import { groupBy, uniqBy } from 'lodash';
@Injectable() @Injectable()
export class ExportService { export class ExportService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly orderService: OrderService private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly tagService: TagService
) {} ) {}
public async export({ public async export({
@ -23,17 +29,77 @@ export class ExportService {
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}): Promise<Export> { }): Promise<Export> {
const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => {
return type;
});
const platformsMap: { [platformId: string]: Platform } = {};
let { activities } = await this.orderService.getOrders({
filters,
userCurrency,
userId,
includeDrafts: true,
sortColumn: 'date',
sortDirection: 'asc',
withExcludedAccounts: true
});
if (activityIds?.length > 0) {
activities = activities.filter(({ id }) => {
return activityIds.includes(id);
});
}
const where: Prisma.AccountWhereInput = { userId };
if (filtersByAccount?.length > 0) {
where.id = {
in: filtersByAccount.map(({ id }) => {
return id;
})
};
}
const accounts = ( const accounts = (
await this.accountService.accounts({ await this.accountService.accounts({
where,
include: {
balances: true,
platform: true
},
orderBy: { orderBy: {
name: 'asc' name: 'asc'
}, }
where: { userId } })
)
.filter(({ id }) => {
return activityIds?.length > 0
? activities.some(({ accountId }) => {
return accountId === id;
}) })
).map( : true;
({ balance, comment, currency, id, isExcluded, name, platformId }) => { })
.map(
({
balance,
balances,
comment,
currency,
id,
isExcluded,
name,
platform,
platformId
}) => {
if (platformId) {
platformsMap[platformId] = platform;
}
return { return {
balance, balance,
balances: balances.map(({ date, value }) => {
return { date: date.toISOString(), value };
}),
comment, comment,
currency, currency,
id, id,
@ -44,34 +110,117 @@ export class ExportService {
} }
); );
let { activities } = await this.orderService.getOrders({ const customAssetProfiles = uniqBy(
filters, activities
userCurrency, .map(({ SymbolProfile }) => {
userId, return SymbolProfile;
includeDrafts: true, })
sortColumn: 'date', .filter(({ userId: assetProfileUserId }) => {
sortDirection: 'asc', return assetProfileUserId === userId;
withExcludedAccounts: true }),
}); ({ id }) => {
return id;
}
);
if (activityIds) { const marketDataByAssetProfile = Object.fromEntries(
activities = activities.filter((activity) => { await Promise.all(
return activityIds.includes(activity.id); customAssetProfiles.map(async ({ dataSource, id, symbol }) => {
const marketData = (
await this.marketDataService.marketDataItems({
where: { dataSource, symbol }
})
).map(({ date, marketPrice }) => ({
date: date.toISOString(),
marketPrice
}));
return [id, marketData] as const;
})
)
);
const tags = (await this.tagService.getTagsForUser(userId))
.filter(
({ id, isUsed }) =>
isUsed &&
activities.some((activity) => {
return activity.tags.some(({ id: tagId }) => {
return tagId === id;
});
})
)
.map(({ id, name }) => {
return {
id,
name
};
}); });
}
return { return {
meta: { date: new Date().toISOString(), version: environment.version }, meta: { date: new Date().toISOString(), version: environment.version },
accounts, accounts,
assetProfiles: customAssetProfiles.map(
({
assetClass,
assetSubClass,
comment,
countries,
currency,
cusip,
dataSource,
figi,
figiComposite,
figiShareClass,
holdings,
id,
isActive,
isin,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
url
}) => {
return {
assetClass,
assetSubClass,
comment,
countries: countries as unknown as Prisma.JsonArray,
currency,
cusip,
dataSource,
figi,
figiComposite,
figiShareClass,
holdings: holdings as unknown as Prisma.JsonArray,
isActive,
isin,
marketData: marketDataByAssetProfile[id],
name,
scraperConfiguration:
scraperConfiguration as unknown as Prisma.JsonArray,
sectors: sectors as unknown as Prisma.JsonArray,
symbol,
symbolMapping,
url
};
}
),
platforms: Object.values(platformsMap),
tags,
activities: activities.map( activities: activities.map(
({ ({
accountId, accountId,
comment, comment,
currency,
date, date,
fee, fee,
id, id,
quantity, quantity,
SymbolProfile, SymbolProfile,
tags: currentTags,
type, type,
unitPrice unitPrice
}) => { }) => {
@ -83,16 +232,13 @@ export class ExportService {
quantity, quantity,
type, type,
unitPrice, unitPrice,
currency: SymbolProfile.currency, currency: currency ?? SymbolProfile.currency,
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
date: date.toISOString(), date: date.toISOString(),
symbol: symbol: SymbolProfile.symbol,
type === 'FEE' || tags: currentTags.map(({ id: tagId }) => {
type === 'INTEREST' || return tagId;
type === 'ITEM' || })
type === 'LIABILITY'
? SymbolProfile.name
: SymbolProfile.symbol
}; };
} }
), ),

60
apps/api/src/app/health/health.controller.ts

@ -1,15 +1,20 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import {
DataEnhancerHealthResponse,
DataProviderHealthResponse
} from '@ghostfolio/common/interfaces';
import { import {
Controller, Controller,
Get, Get,
HttpCode,
HttpException, HttpException,
HttpStatus, HttpStatus,
Param, Param,
Res,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { HealthService } from './health.service'; import { HealthService } from './health.service';
@ -19,29 +24,47 @@ export class HealthController {
public constructor(private readonly healthService: HealthService) {} public constructor(private readonly healthService: HealthService) {}
@Get() @Get()
@HttpCode(HttpStatus.OK) public async getHealth(@Res() response: Response) {
public getHealth() { const databaseServiceHealthy = await this.healthService.isDatabaseHealthy();
return { status: getReasonPhrase(StatusCodes.OK) }; const redisCacheServiceHealthy =
await this.healthService.isRedisCacheHealthy();
if (databaseServiceHealthy && redisCacheServiceHealthy) {
return response
.status(HttpStatus.OK)
.json({ status: getReasonPhrase(StatusCodes.OK) });
} else {
return response
.status(HttpStatus.SERVICE_UNAVAILABLE)
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
}
} }
@Get('data-enhancer/:name') @Get('data-enhancer/:name')
public async getHealthOfDataEnhancer(@Param('name') name: string) { public async getHealthOfDataEnhancer(
@Param('name') name: string,
@Res() response: Response
): Promise<Response<DataEnhancerHealthResponse>> {
const hasResponse = const hasResponse =
await this.healthService.hasResponseFromDataEnhancer(name); await this.healthService.hasResponseFromDataEnhancer(name);
if (hasResponse !== true) { if (hasResponse) {
throw new HttpException( return response.status(HttpStatus.OK).json({
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), status: getReasonPhrase(StatusCodes.OK)
StatusCodes.SERVICE_UNAVAILABLE });
); } else {
return response
.status(HttpStatus.SERVICE_UNAVAILABLE)
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
} }
} }
@Get('data-provider/:dataSource') @Get('data-provider/:dataSource')
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getHealthOfDataProvider( public async getHealthOfDataProvider(
@Param('dataSource') dataSource: DataSource @Param('dataSource') dataSource: DataSource,
) { @Res() response: Response
): Promise<Response<DataProviderHealthResponse>> {
if (!DataSource[dataSource]) { if (!DataSource[dataSource]) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),
@ -52,11 +75,14 @@ export class HealthController {
const hasResponse = const hasResponse =
await this.healthService.hasResponseFromDataProvider(dataSource); await this.healthService.hasResponseFromDataProvider(dataSource);
if (hasResponse !== true) { if (hasResponse) {
throw new HttpException( return response
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), .status(HttpStatus.OK)
StatusCodes.SERVICE_UNAVAILABLE .json({ status: getReasonPhrase(StatusCodes.OK) });
); } else {
return response
.status(HttpStatus.SERVICE_UNAVAILABLE)
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
} }
} }
} }

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

@ -1,6 +1,8 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module'; import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -12,6 +14,8 @@ import { HealthService } from './health.service';
imports: [ imports: [
DataEnhancerModule, DataEnhancerModule,
DataProviderModule, DataProviderModule,
PropertyModule,
RedisCacheModule,
TransformDataSourceInRequestModule TransformDataSourceInRequestModule
], ],
providers: [HealthService] providers: [HealthService]

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

@ -1,5 +1,8 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service'; import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -8,7 +11,9 @@ import { DataSource } from '@prisma/client';
export class HealthService { export class HealthService {
public constructor( public constructor(
private readonly dataEnhancerService: DataEnhancerService, private readonly dataEnhancerService: DataEnhancerService,
private readonly dataProviderService: DataProviderService private readonly dataProviderService: DataProviderService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService
) {} ) {}
public async hasResponseFromDataEnhancer(aName: string) { public async hasResponseFromDataEnhancer(aName: string) {
@ -18,4 +23,24 @@ export class HealthService {
public async hasResponseFromDataProvider(aDataSource: DataSource) { public async hasResponseFromDataProvider(aDataSource: DataSource) {
return this.dataProviderService.checkQuote(aDataSource); return this.dataProviderService.checkQuote(aDataSource);
} }
public async isDatabaseHealthy() {
try {
await this.propertyService.getByKey(PROPERTY_CURRENCIES);
return true;
} catch {
return false;
}
}
public async isRedisCacheHealthy() {
try {
const isHealthy = await this.redisCacheService.isHealthy();
return isHealthy;
} catch {
return false;
}
}
} }

10
apps/api/src/app/import/create-account-with-balances.dto.ts

@ -0,0 +1,10 @@
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { AccountBalance } from '@ghostfolio/common/interfaces';
import { IsArray, IsOptional } from 'class-validator';
export class CreateAccountWithBalancesDto extends CreateAccountDto {
@IsArray()
@IsOptional()
balances?: AccountBalance[];
}

17
apps/api/src/app/import/create-asset-profile-with-market-data.dto.ts

@ -0,0 +1,17 @@
import { MarketData } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client';
import { IsArray, IsEnum, IsOptional } from 'class-validator';
import { CreateAssetProfileDto } from '../admin/create-asset-profile.dto';
export class CreateAssetProfileWithMarketDataDto extends CreateAssetProfileDto {
@IsEnum([DataSource.MANUAL], {
message: `dataSource must be '${DataSource.MANUAL}'`
})
dataSource: DataSource;
@IsArray()
@IsOptional()
marketData?: MarketData[];
}

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

@ -1,18 +1,26 @@
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsArray, IsOptional, ValidateNested } from 'class-validator'; import { IsArray, IsOptional, ValidateNested } from 'class-validator';
import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto';
import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto';
export class ImportDataDto { export class ImportDataDto {
@IsOptional()
@IsArray() @IsArray()
@Type(() => CreateAccountDto) @IsOptional()
@Type(() => CreateAccountWithBalancesDto)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
accounts: CreateAccountDto[]; accounts?: CreateAccountWithBalancesDto[];
@IsArray() @IsArray()
@Type(() => CreateOrderDto) @Type(() => CreateOrderDto)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
activities: CreateOrderDto[]; activities: CreateOrderDto[];
@IsArray()
@IsOptional()
@Type(() => CreateAssetProfileWithMarketDataDto)
@ValidateNested({ each: true })
assetProfiles?: CreateAssetProfileWithMarketDataDto[];
} }

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

@ -71,8 +71,9 @@ export class ImportController {
const activities = await this.importService.import({ const activities = await this.importService.import({
isDryRun, isDryRun,
maxActivitiesToImport, maxActivitiesToImport,
accountsDto: importData.accounts ?? [], accountsWithBalancesDto: importData.accounts ?? [],
activitiesDto: importData.activities, activitiesDto: importData.activities,
assetProfilesWithMarketDataDto: importData.assetProfiles ?? [],
user: this.request.user user: this.request.user
}); });
@ -98,12 +99,10 @@ export class ImportController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<ImportResponse> { ): Promise<ImportResponse> {
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const activities = await this.importService.getDividends({ const activities = await this.importService.getDividends({
dataSource, dataSource,
symbol, symbol,
userCurrency userId: this.request.user.id
}); });
return { activities }; return { activities };

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

@ -9,6 +9,7 @@ import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptor
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -27,6 +28,7 @@ import { ImportService } from './import.service';
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule,
OrderModule, OrderModule,
PlatformModule, PlatformModule,
PortfolioModule, PortfolioModule,

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

@ -10,12 +10,11 @@ import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT,
getAssetProfileIdentifier, getAssetProfileIdentifier,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -29,10 +28,13 @@ import {
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns'; import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns';
import { uniqBy } from 'lodash'; import { omit, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { CreateAssetProfileDto } from '../admin/create-asset-profile.dto';
import { ImportDataDto } from './import-data.dto';
@Injectable() @Injectable()
export class ImportService { export class ImportService {
public constructor( public constructor(
@ -40,7 +42,7 @@ export class ImportService {
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService, private readonly orderService: OrderService,
private readonly platformService: PlatformService, private readonly platformService: PlatformService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@ -50,11 +52,16 @@ export class ImportService {
public async getDividends({ public async getDividends({
dataSource, dataSource,
symbol, symbol,
userCurrency userId
}: AssetProfileIdentifier & { userCurrency: string }): Promise<Activity[]> { }: AssetProfileIdentifier & { userId: string }): Promise<Activity[]> {
try { try {
const { firstBuyDate, historicalData, orders } = const { activities, firstBuyDate, historicalData } =
await this.portfolioService.getPosition(dataSource, undefined, symbol); await this.portfolioService.getHolding({
dataSource,
symbol,
userId,
impersonationId: undefined
});
const [[assetProfile], dividends] = await Promise.all([ const [[assetProfile], dividends] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([ this.symbolProfileService.getSymbolProfiles([
@ -72,15 +79,15 @@ export class ImportService {
}) })
]); ]);
const accounts = orders const accounts = activities
.filter(({ Account }) => { .filter(({ account }) => {
return !!Account; return !!account;
}) })
.map(({ Account }) => { .map(({ account }) => {
return Account; return account;
}); });
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined; const account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
return await Promise.all( return await Promise.all(
Object.entries(dividends).map(async ([dateString, { marketPrice }]) => { Object.entries(dividends).map(async ([dateString, { marketPrice }]) => {
@ -92,9 +99,9 @@ export class ImportService {
const value = new Big(quantity).mul(marketPrice).toNumber(); const value = new Big(quantity).mul(marketPrice).toNumber();
const date = parseDate(dateString); const date = parseDate(dateString);
const isDuplicate = orders.some((activity) => { const isDuplicate = activities.some((activity) => {
return ( return (
activity.accountId === Account?.id && activity.accountId === account?.id &&
activity.SymbolProfile.currency === assetProfile.currency && activity.SymbolProfile.currency === assetProfile.currency &&
activity.SymbolProfile.dataSource === assetProfile.dataSource && activity.SymbolProfile.dataSource === assetProfile.dataSource &&
isSameSecond(activity.date, date) && isSameSecond(activity.date, date) &&
@ -110,17 +117,18 @@ export class ImportService {
: undefined; : undefined;
return { return {
Account, account,
date, date,
error, error,
quantity, quantity,
value, value,
accountId: Account?.id, accountId: account?.id,
accountUserId: undefined, accountUserId: undefined,
comment: undefined, comment: undefined,
currency: undefined, currency: undefined,
createdAt: undefined, createdAt: undefined,
fee: 0, fee: 0,
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0, feeInBaseCurrency: 0,
id: assetProfile.id, id: assetProfile.id,
isDraft: false, isDraft: false,
@ -128,15 +136,10 @@ export class ImportService {
symbolProfileId: assetProfile.id, symbolProfileId: assetProfile.id,
type: 'DIVIDEND', type: 'DIVIDEND',
unitPrice: marketPrice, unitPrice: marketPrice,
unitPriceInAssetProfileCurrency: marketPrice,
updatedAt: undefined, updatedAt: undefined,
userId: Account?.userId, userId: account?.userId,
valueInBaseCurrency: valueInBaseCurrency: value
await this.exchangeRateDataService.toCurrencyAtDate(
value,
assetProfile.currency,
userCurrency,
date
)
}; };
}) })
); );
@ -146,27 +149,30 @@ export class ImportService {
} }
public async import({ public async import({
accountsDto, accountsWithBalancesDto,
activitiesDto, activitiesDto,
assetProfilesWithMarketDataDto,
isDryRun = false, isDryRun = false,
maxActivitiesToImport, maxActivitiesToImport,
user user
}: { }: {
accountsDto: Partial<CreateAccountDto>[]; accountsWithBalancesDto: ImportDataDto['accounts'];
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: ImportDataDto['activities'];
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
isDryRun?: boolean; isDryRun?: boolean;
maxActivitiesToImport: number; maxActivitiesToImport: number;
user: UserWithSettings; user: UserWithSettings;
}): Promise<Activity[]> { }): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {}; const accountIdMapping: { [oldAccountId: string]: string } = {};
const userCurrency = user.Settings.settings.baseCurrency; const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {};
const userCurrency = user.settings.settings.baseCurrency;
if (!isDryRun && accountsDto?.length) { if (!isDryRun && accountsWithBalancesDto?.length) {
const [existingAccounts, existingPlatforms] = await Promise.all([ const [existingAccounts, existingPlatforms] = await Promise.all([
this.accountService.accounts({ this.accountService.accounts({
where: { where: {
id: { id: {
in: accountsDto.map(({ id }) => { in: accountsWithBalancesDto.map(({ id }) => {
return id; return id;
}) })
} }
@ -175,14 +181,19 @@ export class ImportService {
this.platformService.getPlatforms() this.platformService.getPlatforms()
]); ]);
for (const account of accountsDto) { for (const accountWithBalances of accountsWithBalancesDto) {
// Check if there is any existing account with the same ID // Check if there is any existing account with the same ID
const accountWithSameId = existingAccounts.find( const accountWithSameId = existingAccounts.find((existingAccount) => {
(existingAccount) => existingAccount.id === account.id return existingAccount.id === accountWithBalances.id;
); });
// If there is no account or if the account belongs to a different user then create a new account // If there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== user.id) { if (!accountWithSameId || accountWithSameId.userId !== user.id) {
const account: CreateAccountDto = omit(
accountWithBalances,
'balances'
);
let oldAccountId: string; let oldAccountId: string;
const platformId = account.platformId; const platformId = account.platformId;
@ -195,7 +206,10 @@ export class ImportService {
let accountObject: Prisma.AccountCreateInput = { let accountObject: Prisma.AccountCreateInput = {
...account, ...account,
User: { connect: { id: user.id } } balances: {
create: accountWithBalances.balances ?? []
},
user: { connect: { id: user.id } }
}; };
if ( if (
@ -205,7 +219,7 @@ export class ImportService {
) { ) {
accountObject = { accountObject = {
...accountObject, ...accountObject,
Platform: { connect: { id: platformId } } platform: { connect: { id: platformId } }
}; };
} }
@ -222,9 +236,66 @@ export class ImportService {
} }
} }
if (!isDryRun && assetProfilesWithMarketDataDto?.length) {
const existingAssetProfiles =
await this.symbolProfileService.getSymbolProfiles(
assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
);
for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) {
// Check if there is any existing asset profile
const existingAssetProfile = existingAssetProfiles.find(
({ dataSource, symbol }) => {
return (
dataSource === assetProfileWithMarketData.dataSource &&
symbol === assetProfileWithMarketData.symbol
);
}
);
// If there is no asset profile or if the asset profile belongs to a different user, then create a new asset profile
if (!existingAssetProfile || existingAssetProfile.userId !== user.id) {
const assetProfile: CreateAssetProfileDto = omit(
assetProfileWithMarketData,
'marketData'
);
// Asset profile belongs to a different user
if (existingAssetProfile) {
const symbol = uuidv4();
assetProfileSymbolMapping[assetProfile.symbol] = symbol;
assetProfile.symbol = symbol;
}
// Create a new asset profile
const assetProfileObject: Prisma.SymbolProfileCreateInput = {
...assetProfile,
user: { connect: { id: user.id } }
};
await this.symbolProfileService.add(assetProfileObject);
}
// Insert or update market data
const marketDataObjects = assetProfileWithMarketData.marketData.map(
(marketData) => {
return {
...marketData,
dataSource: assetProfileWithMarketData.dataSource,
symbol: assetProfileWithMarketData.symbol
} as Prisma.MarketDataUpdateInput;
}
);
await this.marketDataService.updateMany({ data: marketDataObjects });
}
}
for (const activity of activitiesDto) { for (const activity of activitiesDto) {
if (!activity.dataSource) { if (!activity.dataSource) {
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(activity.type)) { if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) {
activity.dataSource = DataSource.MANUAL; activity.dataSource = DataSource.MANUAL;
} else { } else {
activity.dataSource = activity.dataSource =
@ -232,11 +303,16 @@ export class ImportService {
} }
} }
// If a new account is created, then update the accountId in all activities
if (!isDryRun) { if (!isDryRun) {
if (Object.keys(accountIdMapping).includes(activity.accountId)) { // If a new account is created, then update the accountId in all activities
if (accountIdMapping[activity.accountId]) {
activity.accountId = accountIdMapping[activity.accountId]; activity.accountId = accountIdMapping[activity.accountId];
} }
// If a new asset profile is created, then update the symbol in all activities
if (assetProfileSymbolMapping[activity.symbol]) {
activity.symbol = assetProfileSymbolMapping[activity.symbol];
}
} }
} }
@ -259,24 +335,24 @@ export class ImportService {
); );
if (isDryRun) { if (isDryRun) {
accountsDto.forEach(({ id, name }) => { accountsWithBalancesDto.forEach(({ id, name }) => {
accounts.push({ id, name }); accounts.push({ id, name });
}); });
} }
const activities: Activity[] = []; const activities: Activity[] = [];
for (const [index, activity] of activitiesExtendedWithErrors.entries()) { for (const activity of activitiesExtendedWithErrors) {
const accountId = activity.accountId; const accountId = activity.accountId;
const comment = activity.comment; const comment = activity.comment;
const currency = activity.currency; const currency = activity.currency;
const date = activity.date; const date = activity.date;
const error = activity.error; const error = activity.error;
let fee = activity.fee; const fee = activity.fee;
const quantity = activity.quantity; const quantity = activity.quantity;
const SymbolProfile = activity.SymbolProfile; const SymbolProfile = activity.SymbolProfile;
const type = activity.type; const type = activity.type;
let unitPrice = activity.unitPrice; const unitPrice = activity.unitPrice;
const assetProfile = assetProfiles[ const assetProfile = assetProfiles[
getAssetProfileIdentifier({ getAssetProfileIdentifier({
@ -284,7 +360,6 @@ export class ImportService {
symbol: SymbolProfile.symbol symbol: SymbolProfile.symbol
}) })
] ?? { ] ?? {
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol symbol: SymbolProfile.symbol
}; };
@ -293,12 +368,14 @@ export class ImportService {
assetSubClass, assetSubClass,
countries, countries,
createdAt, createdAt,
cusip,
dataSource, dataSource,
figi, figi,
figiComposite, figiComposite,
figiShareClass, figiShareClass,
holdings, holdings,
id, id,
isActive,
isin, isin,
name, name,
scraperConfiguration, scraperConfiguration,
@ -318,35 +395,6 @@ export class ImportService {
Account?: { id: string; name: string }; Account?: { id: string; name: string };
}); });
if (SymbolProfile.currency !== assetProfile.currency) {
// Convert the unit price and fee to the asset currency if the imported
// activity is in a different currency
unitPrice = await this.exchangeRateDataService.toCurrencyAtDate(
unitPrice,
SymbolProfile.currency,
assetProfile.currency,
date
);
if (!unitPrice) {
throw new Error(
`activities.${index} historical exchange rate at ${format(
date,
DATE_FORMAT
)} is not available from "${SymbolProfile.currency}" to "${
assetProfile.currency
}"`
);
}
fee = await this.exchangeRateDataService.toCurrencyAtDate(
fee,
SymbolProfile.currency,
assetProfile.currency,
date
);
}
if (isDryRun) { if (isDryRun) {
order = { order = {
comment, comment,
@ -367,12 +415,14 @@ export class ImportService {
assetSubClass, assetSubClass,
countries, countries,
createdAt, createdAt,
cusip,
dataSource, dataSource,
figi, figi,
figiComposite, figiComposite,
figiShareClass, figiShareClass,
holdings, holdings,
id, id,
isActive,
isin, isin,
name, name,
scraperConfiguration, scraperConfiguration,
@ -396,6 +446,7 @@ export class ImportService {
order = await this.orderService.createOrder({ order = await this.orderService.createOrder({
comment, comment,
currency,
date, date,
fee, fee,
quantity, quantity,
@ -419,7 +470,7 @@ export class ImportService {
} }
}, },
updateAccountBalance: false, updateAccountBalance: false,
User: { connect: { id: user.id } }, user: { connect: { id: user.id } },
userId: user.id userId: user.id
}); });
@ -435,21 +486,8 @@ export class ImportService {
...order, ...order,
error, error,
value, value,
feeInBaseCurrency: await this.exchangeRateDataService.toCurrencyAtDate(
fee,
assetProfile.currency,
userCurrency,
date
),
// @ts-ignore // @ts-ignore
SymbolProfile: assetProfile, SymbolProfile: assetProfile
valueInBaseCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
value,
assetProfile.currency,
userCurrency,
date
)
}); });
} }
@ -515,7 +553,9 @@ export class ImportService {
const isDuplicate = existingActivities.some((activity) => { const isDuplicate = existingActivities.some((activity) => {
return ( return (
activity.accountId === accountId && activity.accountId === accountId &&
activity.SymbolProfile.currency === currency && activity.comment === comment &&
(activity.currency === currency ||
activity.SymbolProfile.currency === currency) &&
activity.SymbolProfile.dataSource === dataSource && activity.SymbolProfile.dataSource === dataSource &&
isSameSecond(activity.date, date) && isSameSecond(activity.date, date) &&
activity.fee === fee && activity.fee === fee &&
@ -533,6 +573,7 @@ export class ImportService {
return { return {
accountId, accountId,
comment, comment,
currency,
date, date,
error, error,
fee, fee,
@ -540,7 +581,6 @@ export class ImportService {
type, type,
unitPrice, unitPrice,
SymbolProfile: { SymbolProfile: {
currency,
dataSource, dataSource,
symbol, symbol,
activitiesCount: undefined, activitiesCount: undefined,
@ -548,8 +588,10 @@ export class ImportService {
assetSubClass: undefined, assetSubClass: undefined,
countries: undefined, countries: undefined,
createdAt: undefined, createdAt: undefined,
currency: undefined,
holdings: undefined, holdings: undefined,
id: undefined, id: undefined,
isActive: true,
sectors: undefined, sectors: undefined,
updatedAt: undefined updatedAt: undefined
} }
@ -584,12 +626,18 @@ export class ImportService {
const assetProfiles: { const assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>; [assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {}; } = {};
const dataSources = await this.dataProviderService.getDataSources(); const dataSources = await this.dataProviderService.getDataSources({ user });
for (const [ for (const [
index, index,
{ currency, dataSource, symbol, type } { currency, dataSource, symbol, type }
] of activitiesDto.entries()) { ] of activitiesDto.entries()) {
if (type === 'ITEM') {
throw new Error(
`activities.${index}.type ("${type}") is deprecated, please use "BUY" instead`
);
}
if (!dataSources.includes(dataSource)) { if (!dataSources.includes(dataSource)) {
throw new Error( throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid` `activities.${index}.dataSource ("${dataSource}") is not valid`
@ -621,18 +669,16 @@ export class ImportService {
)?.[symbol] )?.[symbol]
}; };
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') { if (
(dataSource !== 'MANUAL' && type === 'BUY') ||
type === 'DIVIDEND' ||
type === 'SELL'
) {
if (!assetProfile?.name) { if (!assetProfile?.name) {
throw new Error( throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
); );
} }
if (assetProfile.currency !== currency) {
throw new Error(
`activities.${index}.currency ("${currency}") does not match with currency of ${assetProfile.symbol} ("${assetProfile.currency}")`
);
}
} }
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] = assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =

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

@ -1,8 +1,9 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -31,6 +32,7 @@ import { InfoService } from './info.service';
PlatformModule, PlatformModule,
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SubscriptionModule,
SymbolProfileModule, SymbolProfileModule,
TransformDataSourceInResponseModule, TransformDataSourceInResponseModule,
UserModule UserModule

91
apps/api/src/app/info/info.service.ts

@ -1,7 +1,8 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
@ -13,27 +14,20 @@ import {
PROPERTY_DEMO_USER_ID, PROPERTY_DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS, PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_STRIPE_CONFIG, ghostfolioFearAndGreedIndexDataSourceStocks
ghostfolioFearAndGreedIndexDataSource
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
encodeDataSource, encodeDataSource,
extractNumberFromString extractNumberFromString
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import { InfoItem, Statistics } from '@ghostfolio/common/interfaces';
InfoItem,
Statistics,
SubscriptionOffer
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { SubscriptionOfferKey } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
import got from 'got';
@Injectable() @Injectable()
export class InfoService { export class InfoService {
@ -47,6 +41,7 @@ export class InfoService {
private readonly platformService: PlatformService, private readonly platformService: PlatformService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService,
private readonly subscriptionService: SubscriptionService,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -59,19 +54,20 @@ export class InfoService {
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
info.fearAndGreedDataSource = encodeDataSource( info.fearAndGreedDataSource = encodeDataSource(
ghostfolioFearAndGreedIndexDataSource ghostfolioFearAndGreedIndexDataSourceStocks
); );
} else { } else {
info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource; info.fearAndGreedDataSource =
ghostfolioFearAndGreedIndexDataSourceStocks;
} }
globalPermissions.push(permissions.enableFearAndGreedIndex); globalPermissions.push(permissions.enableFearAndGreedIndex);
} }
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
isReadOnlyMode = (await this.propertyService.getByKey( isReadOnlyMode = await this.propertyService.getByKey<boolean>(
PROPERTY_IS_READ_ONLY_MODE PROPERTY_IS_READ_ONLY_MODE
)) as boolean; );
} }
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) { if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
@ -86,9 +82,9 @@ export class InfoService {
globalPermissions.push(permissions.enableSubscription); globalPermissions.push(permissions.enableSubscription);
info.countriesOfSubscribers = info.countriesOfSubscribers =
((await this.propertyService.getByKey( (await this.propertyService.getByKey<string[]>(
PROPERTY_COUNTRIES_OF_SUBSCRIBERS PROPERTY_COUNTRIES_OF_SUBSCRIBERS
)) as string[]) ?? []; )) ?? [];
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY'); info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
} }
@ -102,7 +98,7 @@ export class InfoService {
isUserSignupEnabled, isUserSignupEnabled,
platforms, platforms,
statistics, statistics,
subscriptionOffers subscriptionOffer
] = await Promise.all([ ] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(), this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(), this.getDemoAuthToken(),
@ -111,7 +107,7 @@ export class InfoService {
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}), }),
this.getStatistics(), this.getStatistics(),
this.getSubscriptionOffers() this.subscriptionService.getSubscriptionOffer({ key: 'default' })
]); ]);
if (isUserSignupEnabled) { if (isUserSignupEnabled) {
@ -126,7 +122,7 @@ export class InfoService {
isReadOnlyMode, isReadOnlyMode,
platforms, platforms,
statistics, statistics,
subscriptionOffers, subscriptionOffer,
baseCurrency: DEFAULT_CURRENCY, baseCurrency: DEFAULT_CURRENCY,
currencies: this.exchangeRateDataService.getCurrencies() currencies: this.exchangeRateDataService.getCurrencies()
}; };
@ -138,11 +134,11 @@ export class InfoService {
AND: [ AND: [
{ {
NOT: { NOT: {
Analytics: null analytics: null
} }
}, },
{ {
Analytics: { analytics: {
lastRequestAt: { lastRequestAt: {
gt: subDays(new Date(), aDays) gt: subDays(new Date(), aDays)
} }
@ -155,16 +151,15 @@ export class InfoService {
private async countDockerHubPulls(): Promise<number> { private async countDockerHubPulls(): Promise<number> {
try { try {
const { pull_count } = await got( const { pull_count } = (await fetch(
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`, 'https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio',
{ {
headers: { 'User-Agent': 'request' }, headers: { 'User-Agent': 'request' },
// @ts-ignore
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')
) )
} }
).json<any>(); ).then((res) => res.json())) as { pull_count: number };
return pull_count; return pull_count;
} catch (error) { } catch (error) {
@ -176,18 +171,17 @@ export class InfoService {
private async countGitHubContributors(): Promise<number> { private async countGitHubContributors(): Promise<number> {
try { try {
const { body } = await got('https://github.com/ghostfolio/ghostfolio', { const body = await fetch('https://github.com/ghostfolio/ghostfolio', {
// @ts-ignore
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')
) )
}); }).then((res) => res.text());
const $ = cheerio.load(body); const $ = cheerio.load(body);
return extractNumberFromString({ return extractNumberFromString({
value: $( value: $(
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter` 'a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter'
).text() ).text()
}); });
} catch (error) { } catch (error) {
@ -199,16 +193,15 @@ export class InfoService {
private async countGitHubStargazers(): Promise<number> { private async countGitHubStargazers(): Promise<number> {
try { try {
const { stargazers_count } = await got( const { stargazers_count } = (await fetch(
`https://api.github.com/repos/ghostfolio/ghostfolio`, 'https://api.github.com/repos/ghostfolio/ghostfolio',
{ {
headers: { 'User-Agent': 'request' }, headers: { 'User-Agent': 'request' },
// @ts-ignore
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')
) )
} }
).json<any>(); ).then((res) => res.json())) as { stargazers_count: number };
return stargazers_count; return stargazers_count;
} catch (error) { } catch (error) {
@ -224,7 +217,7 @@ export class InfoService {
AND: [ AND: [
{ {
NOT: { NOT: {
Analytics: null analytics: null
} }
}, },
{ {
@ -238,15 +231,15 @@ export class InfoService {
} }
private async countSlackCommunityUsers() { private async countSlackCommunityUsers() {
return (await this.propertyService.getByKey( return await this.propertyService.getByKey<string>(
PROPERTY_SLACK_COMMUNITY_USERS PROPERTY_SLACK_COMMUNITY_USERS
)) as string; );
} }
private async getDemoAuthToken() { private async getDemoAuthToken() {
const demoUserId = (await this.propertyService.getByKey( const demoUserId = await this.propertyService.getByKey<string>(
PROPERTY_DEMO_USER_ID PROPERTY_DEMO_USER_ID
)) as string; );
if (demoUserId) { if (demoUserId) {
return this.jwtService.sign({ return this.jwtService.sign({
@ -303,27 +296,14 @@ export class InfoService {
return statistics; return statistics;
} }
private async getSubscriptionOffers(): Promise<{
[offer in SubscriptionOfferKey]: SubscriptionOffer;
}> {
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
return undefined;
}
return (
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
{}
);
}
private async getUptime(): Promise<number> { private async getUptime(): Promise<number> {
{ {
try { try {
const monitorId = (await this.propertyService.getByKey( const monitorId = await this.propertyService.getByKey<string>(
PROPERTY_BETTER_UPTIME_MONITOR_ID PROPERTY_BETTER_UPTIME_MONITOR_ID
)) as string; );
const { data } = await got( const { data } = await fetch(
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
subDays(new Date(), 90), subDays(new Date(), 90),
DATE_FORMAT DATE_FORMAT
@ -334,12 +314,11 @@ export class InfoService {
'API_KEY_BETTER_UPTIME' 'API_KEY_BETTER_UPTIME'
)}` )}`
}, },
// @ts-ignore
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')
) )
} }
).json<any>(); ).then((res) => res.json());
return data.attributes.availability / 100; return data.attributes.availability / 100;
} catch (error) { } catch (error) {

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

@ -26,12 +26,13 @@ export class LogoController {
@Res() response: Response @Res() response: Response
) { ) {
try { try {
const buffer = await this.logoService.getLogoByDataSourceAndSymbol({ const { buffer, type } =
await this.logoService.getLogoByDataSourceAndSymbol({
dataSource, dataSource,
symbol symbol
}); });
response.contentType('image/png'); response.contentType(type);
response.send(buffer); response.send(buffer);
} catch { } catch {
response.status(HttpStatus.NOT_FOUND).send(); response.status(HttpStatus.NOT_FOUND).send();
@ -44,9 +45,9 @@ export class LogoController {
@Res() response: Response @Res() response: Response
) { ) {
try { try {
const buffer = await this.logoService.getLogoByUrl(url); const { buffer, type } = await this.logoService.getLogoByUrl(url);
response.contentType('image/png'); response.contentType(type);
response.send(buffer); response.send(buffer);
} catch { } catch {
response.status(HttpStatus.NOT_FOUND).send(); response.status(HttpStatus.NOT_FOUND).send();

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

@ -4,7 +4,6 @@ import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import got from 'got';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable() @Injectable()
@ -29,7 +28,7 @@ export class LogoService {
{ dataSource, symbol } { dataSource, symbol }
]); ]);
if (!assetProfile) { if (!assetProfile?.url) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND StatusCodes.NOT_FOUND
@ -39,20 +38,26 @@ export class LogoService {
return this.getBuffer(assetProfile.url); return this.getBuffer(assetProfile.url);
} }
public async getLogoByUrl(aUrl: string) { public getLogoByUrl(aUrl: string) {
return this.getBuffer(aUrl); return this.getBuffer(aUrl);
} }
private getBuffer(aUrl: string) { private async getBuffer(aUrl: string) {
return got( const blob = await fetch(
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, `https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
{ {
headers: { 'User-Agent': 'request' }, headers: { 'User-Agent': 'request' },
// @ts-ignore
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')
) )
} }
).buffer(); ).then((res) => res.blob());
return {
buffer: await blob.arrayBuffer().then((arrayBuffer) => {
return Buffer.from(arrayBuffer);
}),
type: blob.type
};
} }
} }

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

@ -27,12 +27,12 @@ export class CreateOrderDto {
@IsString() @IsString()
accountId?: string; accountId?: string;
@IsOptional()
@IsEnum(AssetClass, { each: true }) @IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass; assetClass?: AssetClass;
@IsOptional()
@IsEnum(AssetSubClass, { each: true }) @IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
@IsOptional() @IsOptional()
@ -49,8 +49,8 @@ export class CreateOrderDto {
@IsOptional() @IsOptional()
customCurrency?: string; customCurrency?: string;
@IsEnum(DataSource)
@IsOptional() @IsOptional()
@IsEnum(DataSource, { each: true })
dataSource?: DataSource; dataSource?: DataSource;
@IsISO8601() @IsISO8601()

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

@ -9,11 +9,13 @@ export interface Activities {
} }
export interface Activity extends Order { export interface Activity extends Order {
Account?: AccountWithPlatform; account?: AccountWithPlatform;
error?: ActivityError; error?: ActivityError;
feeInAssetProfileCurrency: number;
feeInBaseCurrency: number; feeInBaseCurrency: number;
SymbolProfile?: EnhancedSymbolProfile; SymbolProfile?: EnhancedSymbolProfile;
tags?: Tag[]; tags?: Tag[];
unitPriceInAssetProfileCurrency: number;
updateAccountBalance?: boolean; updateAccountBalance?: boolean;
value: number; value: number;
valueInBaseCurrency: number; valueInBaseCurrency: number;

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

@ -53,14 +53,19 @@ export class OrderController {
@Delete() @Delete()
@HasPermission(permissions.deleteOrder) @HasPermission(permissions.deleteOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteOrders( public async deleteOrders(
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<number> { ): Promise<number> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags filterByTags
}); });
@ -97,7 +102,7 @@ export class OrderController {
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders( public async getAllOrders(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@ -126,7 +131,7 @@ export class OrderController {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities, count } = await this.orderService.getOrders({ const { activities, count } = await this.orderService.getOrders({
endDate, endDate,
@ -150,12 +155,12 @@ export class OrderController {
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getOrderById( public async getOrderById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string @Param('id') id: string
): Promise<Activity> { ): Promise<Activity> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities } = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
userCurrency, userCurrency,
@ -184,6 +189,7 @@ export class OrderController {
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> { public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
const currency = data.currency; const currency = data.currency;
const customCurrency = data.customCurrency; const customCurrency = data.customCurrency;
const dataSource = data.dataSource;
if (customCurrency) { if (customCurrency) {
data.currency = customCurrency; data.currency = customCurrency;
@ -191,6 +197,8 @@ export class OrderController {
delete data.customCurrency; delete data.customCurrency;
} }
delete data.dataSource;
const order = await this.orderService.createOrder({ const order = await this.orderService.createOrder({
...data, ...data,
date: parseISO(data.date), date: parseISO(data.date),
@ -198,28 +206,28 @@ export class OrderController {
connectOrCreate: { connectOrCreate: {
create: { create: {
currency, currency,
dataSource: data.dataSource, dataSource,
symbol: data.symbol symbol: data.symbol
}, },
where: { where: {
dataSource_symbol: { dataSource_symbol: {
dataSource: data.dataSource, dataSource,
symbol: data.symbol symbol: data.symbol
} }
} }
} }
}, },
User: { connect: { id: this.request.user.id } }, user: { connect: { id: this.request.user.id } },
userId: this.request.user.id userId: this.request.user.id
}); });
if (data.dataSource && !order.isDraft) { if (dataSource && !order.isDraft) {
// Gather symbol data in the background, if data source is set // Gather symbol data in the background, if data source is set
// (not MANUAL) and not draft // (not MANUAL) and not draft
this.dataGatheringService.gatherSymbols({ this.dataGatheringService.gatherSymbols({
dataGatheringItems: [ dataGatheringItems: [
{ {
dataSource: data.dataSource, dataSource,
date: order.date, date: order.date,
symbol: data.symbol symbol: data.symbol
} }
@ -251,6 +259,7 @@ export class OrderController {
const accountId = data.accountId; const accountId = data.accountId;
const customCurrency = data.customCurrency; const customCurrency = data.customCurrency;
const dataSource = data.dataSource;
delete data.accountId; delete data.accountId;
@ -260,11 +269,13 @@ export class OrderController {
delete data.customCurrency; delete data.customCurrency;
} }
delete data.dataSource;
return this.orderService.updateOrder({ return this.orderService.updateOrder({
data: { data: {
...data, ...data,
date, date,
Account: { account: {
connect: { connect: {
id_userId: { id: accountId, userId: this.request.user.id } id_userId: { id: accountId, userId: this.request.user.id }
} }
@ -272,7 +283,7 @@ export class OrderController {
SymbolProfile: { SymbolProfile: {
connect: { connect: {
dataSource_symbol: { dataSource_symbol: {
dataSource: data.dataSource, dataSource,
symbol: data.symbol symbol: data.symbol
} }
}, },
@ -282,7 +293,7 @@ export class OrderController {
name: data.symbol name: data.symbol
} }
}, },
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
where: { where: {
id id

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

@ -7,8 +7,9 @@ import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathe
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
ghostfolioPrefix
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
@ -30,6 +31,7 @@ import {
Type as ActivityType Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isUUID } from 'class-validator';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { groupBy, uniqBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -63,14 +65,14 @@ export class OrderService {
} }
}); });
return Promise.all( await Promise.all(
orders.map(({ id }) => orders.map(({ id }) =>
this.prismaService.order.update({ this.prismaService.order.update({
data: { data: {
tags: { tags: {
// The set operation replaces all existing connections with the provided ones // The set operation replaces all existing connections with the provided ones
set: tags.map(({ id }) => { set: tags.map((tag) => {
return { id }; return { id: tag.id };
}) })
} }
}, },
@ -78,6 +80,13 @@ export class OrderService {
}) })
) )
); );
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId
})
);
} }
public async createOrder( public async createOrder(
@ -86,17 +95,16 @@ export class OrderService {
assetClass?: AssetClass; assetClass?: AssetClass;
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
currency?: string; currency?: string;
dataSource?: DataSource;
symbol?: string; symbol?: string;
tags?: Tag[]; tags?: Tag[];
updateAccountBalance?: boolean; updateAccountBalance?: boolean;
userId: string; userId: string;
} }
): Promise<Order> { ): Promise<Order> {
let Account: Prisma.AccountCreateNestedOneWithoutOrderInput; let account: Prisma.AccountCreateNestedOneWithoutActivitiesInput;
if (data.accountId) { if (data.accountId) {
Account = { account = {
connect: { connect: {
id_userId: { id_userId: {
userId: data.userId, userId: data.userId,
@ -111,23 +119,41 @@ export class OrderService {
const updateAccountBalance = data.updateAccountBalance ?? false; const updateAccountBalance = data.updateAccountBalance ?? false;
const userId = data.userId; const userId = data.userId;
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) { if (
['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) ||
(data.SymbolProfile.connectOrCreate.create.dataSource === 'MANUAL' &&
data.type === 'BUY')
) {
const assetClass = data.assetClass; const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass; const assetSubClass = data.assetSubClass;
const dataSource: DataSource = 'MANUAL'; const dataSource: DataSource = 'MANUAL';
const id = uuidv4();
const name = data.SymbolProfile.connectOrCreate.create.symbol;
data.id = id; let name: string;
let symbol: string;
if (
data.SymbolProfile.connectOrCreate.create.symbol.startsWith(
`${ghostfolioPrefix}_`
) ||
isUUID(data.SymbolProfile.connectOrCreate.create.symbol)
) {
// Connect custom asset profile (clone)
symbol = data.SymbolProfile.connectOrCreate.create.symbol;
} else {
// Create custom asset profile
name = data.SymbolProfile.connectOrCreate.create.symbol;
symbol = uuidv4();
}
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass; data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass; data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource; data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
data.SymbolProfile.connectOrCreate.create.name = name; data.SymbolProfile.connectOrCreate.create.name = name;
data.SymbolProfile.connectOrCreate.create.symbol = id; data.SymbolProfile.connectOrCreate.create.symbol = symbol;
data.SymbolProfile.connectOrCreate.create.userId = userId; data.SymbolProfile.connectOrCreate.create.userId = userId;
data.SymbolProfile.connectOrCreate.where.dataSource_symbol = { data.SymbolProfile.connectOrCreate.where.dataSource_symbol = {
dataSource, dataSource,
symbol: id symbol
}; };
} }
@ -137,9 +163,9 @@ export class OrderService {
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol symbol: data.SymbolProfile.connectOrCreate.create.symbol
}, },
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
jobId: getAssetProfileIdentifier({ jobId: getAssetProfileIdentifier({
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol symbol: data.SymbolProfile.connectOrCreate.create.symbol
@ -157,7 +183,6 @@ export class OrderService {
delete data.comment; delete data.comment;
} }
delete data.dataSource;
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;
delete data.updateAccountBalance; delete data.updateAccountBalance;
@ -165,14 +190,14 @@ export class OrderService {
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
const isDraft = ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type) const isDraft = ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type)
? false ? false
: isAfter(data.date as Date, endOfToday()); : isAfter(data.date as Date, endOfToday());
const order = await this.prismaService.order.create({ const order = await this.prismaService.order.create({
data: { data: {
...orderData, ...orderData,
Account, account,
isDraft, isDraft,
tags: { tags: {
connect: tags.map(({ id }) => { connect: tags.map(({ id }) => {
@ -468,8 +493,8 @@ export class OrderService {
if (withExcludedAccounts === false) { if (withExcludedAccounts === false) {
where.OR = [ where.OR = [
{ Account: null }, { account: null },
{ Account: { NOT: { isExcluded: true } } } { account: { NOT: { isExcluded: true } } }
]; ];
} }
@ -480,10 +505,9 @@ export class OrderService {
take, take,
where, where,
include: { include: {
// eslint-disable-next-line @typescript-eslint/naming-convention account: {
Account: {
include: { include: {
Platform: true platform: true
} }
}, },
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
@ -524,24 +548,46 @@ export class OrderService {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
return { const [
...order, feeInAssetProfileCurrency,
value, feeInBaseCurrency,
feeInBaseCurrency: unitPriceInAssetProfileCurrency,
await this.exchangeRateDataService.toCurrencyAtDate( valueInBaseCurrency
] = await Promise.all([
this.exchangeRateDataService.toCurrencyAtDate(
order.fee, order.fee,
order.currency ?? order.SymbolProfile.currency,
order.SymbolProfile.currency, order.SymbolProfile.currency,
order.date
),
this.exchangeRateDataService.toCurrencyAtDate(
order.fee,
order.currency ?? order.SymbolProfile.currency,
userCurrency, userCurrency,
order.date order.date
), ),
SymbolProfile: assetProfile, this.exchangeRateDataService.toCurrencyAtDate(
valueInBaseCurrency: order.unitPrice,
await this.exchangeRateDataService.toCurrencyAtDate( order.currency ?? order.SymbolProfile.currency,
value,
order.SymbolProfile.currency, order.SymbolProfile.currency,
order.date
),
this.exchangeRateDataService.toCurrencyAtDate(
value,
order.currency ?? order.SymbolProfile.currency,
userCurrency, userCurrency,
order.date order.date
) )
]);
return {
...order,
feeInAssetProfileCurrency,
feeInBaseCurrency,
unitPriceInAssetProfileCurrency,
value,
valueInBaseCurrency,
SymbolProfile: assetProfile
}; };
}) })
); );
@ -603,7 +649,6 @@ export class OrderService {
assetClass?: AssetClass; assetClass?: AssetClass;
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
currency?: string; currency?: string;
dataSource?: DataSource;
symbol?: string; symbol?: string;
tags?: Tag[]; tags?: Tag[];
type?: ActivityType; type?: ActivityType;
@ -618,12 +663,17 @@ export class OrderService {
let isDraft = false; let isDraft = false;
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) { if (
delete data.SymbolProfile.connect; ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) ||
(data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' &&
if (data.Account?.connect?.id_userId?.id === null) { data.type === 'BUY')
data.Account = { disconnect: true }; ) {
if (data.account?.connect?.id_userId?.id === null) {
data.account = { disconnect: true };
} }
delete data.SymbolProfile.connect;
delete data.SymbolProfile.update.name;
} else { } else {
delete data.SymbolProfile.update; delete data.SymbolProfile.update;
@ -647,17 +697,17 @@ export class OrderService {
delete data.assetClass; delete data.assetClass;
delete data.assetSubClass; delete data.assetSubClass;
delete data.dataSource;
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;
// Remove existing tags // Remove existing tags
await this.prismaService.order.update({ await this.prismaService.order.update({
data: { tags: { set: [] } }, where,
where data: { tags: { set: [] } }
}); });
const order = await this.prismaService.order.update({ const order = await this.prismaService.order.update({
where,
data: { data: {
...data, ...data,
isDraft, isDraft,
@ -666,8 +716,7 @@ export class OrderService {
return { id }; return { id };
}) })
} }
}, }
where
}); });
this.eventEmitter.emit( this.eventEmitter.emit(

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

@ -54,7 +54,7 @@ export class PlatformService {
await this.prismaService.platform.findMany({ await this.prismaService.platform.findMany({
include: { include: {
_count: { _count: {
select: { Account: true } select: { accounts: true }
} }
} }
}); });
@ -64,7 +64,7 @@ export class PlatformService {
id, id,
name, name,
url, url,
accountCount: _count.Account accountCount: _count.accounts
}; };
}); });
} }

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

@ -4,12 +4,17 @@ import {
SymbolMetrics SymbolMetrics
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
export class MWRPortfolioCalculator extends PortfolioCalculator { export class MwrPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot { protected calculateOverallPerformance(): PortfolioSnapshot {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
protected getPerformanceCalculationType() {
return PerformanceCalculationType.MWR;
}
protected getSymbolMetrics({}: { protected getSymbolMetrics({}: {
end: Date; end: Date;
exchangeRates: { [dateString: string]: number }; exchangeRates: { [dateString: string]: number };

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

@ -6,10 +6,14 @@ export const activityDummyData = {
comment: undefined, comment: undefined,
createdAt: new Date(), createdAt: new Date(),
currency: undefined, currency: undefined,
fee: undefined,
feeInAssetProfileCurrency: undefined,
feeInBaseCurrency: undefined, feeInBaseCurrency: undefined,
id: undefined, id: undefined,
isDraft: false, isDraft: false,
symbolProfileId: undefined, symbolProfileId: undefined,
unitPrice: undefined,
unitPriceInAssetProfileCurrency: undefined,
updatedAt: new Date(), updatedAt: new Date(),
userId: undefined, userId: undefined,
value: undefined, value: undefined,
@ -24,6 +28,7 @@ export const symbolProfileDummyData = {
createdAt: undefined, createdAt: undefined,
holdings: [], holdings: [],
id: undefined, id: undefined,
isActive: true,
sectors: [], sectors: [],
updatedAt: undefined updatedAt: undefined
}; };

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

@ -5,17 +5,15 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces'; import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator'; import { MwrPortfolioCalculator } from './mwr/portfolio-calculator';
import { PortfolioCalculator } from './portfolio-calculator'; import { PortfolioCalculator } from './portfolio-calculator';
import { TWRPortfolioCalculator } from './twr/portfolio-calculator'; import { RoaiPortfolioCalculator } from './roai/portfolio-calculator';
import { RoiPortfolioCalculator } from './roi/portfolio-calculator';
export enum PerformanceCalculationType { import { TwrPortfolioCalculator } from './twr/portfolio-calculator';
MWR = 'MWR', // Money-Weighted Rate of Return
TWR = 'TWR' // Time-Weighted Rate of Return
}
@Injectable() @Injectable()
export class PortfolioCalculatorFactory { export class PortfolioCalculatorFactory {
@ -44,7 +42,7 @@ export class PortfolioCalculatorFactory {
}): PortfolioCalculator { }): PortfolioCalculator {
switch (calculationType) { switch (calculationType) {
case PerformanceCalculationType.MWR: case PerformanceCalculationType.MWR:
return new MWRPortfolioCalculator({ return new MwrPortfolioCalculator({
accountBalanceItems, accountBalanceItems,
activities, activities,
currency, currency,
@ -56,19 +54,49 @@ export class PortfolioCalculatorFactory {
portfolioSnapshotService: this.portfolioSnapshotService, portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService redisCacheService: this.redisCacheService
}); });
case PerformanceCalculationType.TWR:
return new TWRPortfolioCalculator({ case PerformanceCalculationType.ROAI:
return new RoaiPortfolioCalculator({
accountBalanceItems,
activities,
currency,
filters,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.ROI:
return new RoiPortfolioCalculator({
accountBalanceItems, accountBalanceItems,
activities, activities,
currency, currency,
filters,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.TWR:
return new TwrPortfolioCalculator({
accountBalanceItems,
activities,
currency,
filters, filters,
userId, userId,
configurationService: this.configurationService, configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService, exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService, portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService redisCacheService: this.redisCacheService
}); });
default: default:
throw new Error('Invalid calculation type'); throw new Error('Invalid calculation type');
} }

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

@ -13,6 +13,7 @@ import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfac
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import {
INVESTMENT_ACTIVITY_TYPES,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS, PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH, PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH,
@ -35,6 +36,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { GroupBy } from '@ghostfolio/common/types'; import { GroupBy } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { Big } from 'big.js'; import { Big } from 'big.js';
@ -49,7 +51,7 @@ import {
min, min,
subDays subDays
} from 'date-fns'; } from 'date-fns';
import { first, isNumber, last, sortBy, sum, uniq, uniqBy } from 'lodash'; import { isNumber, sortBy, sum, uniqBy } from 'lodash';
export abstract class PortfolioCalculator { export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false; protected static readonly ENABLE_LOGGING = false;
@ -112,12 +114,12 @@ export abstract class PortfolioCalculator {
.map( .map(
({ ({
date, date,
fee, feeInAssetProfileCurrency,
quantity, quantity,
SymbolProfile, SymbolProfile,
tags = [], tags = [],
type, type,
unitPrice unitPriceInAssetProfileCurrency
}) => { }) => {
if (isBefore(date, dateOfFirstActivity)) { if (isBefore(date, dateOfFirstActivity)) {
dateOfFirstActivity = date; dateOfFirstActivity = date;
@ -134,9 +136,9 @@ export abstract class PortfolioCalculator {
tags, tags,
type, type,
date: format(date, DATE_FORMAT), date: format(date, DATE_FORMAT),
fee: new Big(fee), fee: new Big(feeInAssetProfileCurrency),
quantity: new Big(quantity), quantity: new Big(quantity),
unitPrice: new Big(unitPrice) unitPrice: new Big(unitPriceInAssetProfileCurrency)
}; };
} }
) )
@ -167,7 +169,7 @@ export abstract class PortfolioCalculator {
@LogPerformance @LogPerformance
public async computeSnapshot(): Promise<PortfolioSnapshot> { public async computeSnapshot(): Promise<PortfolioSnapshot> {
const lastTransactionPoint = last(this.transactionPoints); const lastTransactionPoint = this.transactionPoints.at(-1);
const transactionPoints = this.transactionPoints?.filter(({ date }) => { const transactionPoints = this.transactionPoints?.filter(({ date }) => {
return isBefore(parseDate(date), this.endDate); return isBefore(parseDate(date), this.endDate);
@ -175,6 +177,8 @@ export abstract class PortfolioCalculator {
if (!transactionPoints.length) { if (!transactionPoints.length) {
return { return {
activitiesCount: 0,
createdAt: new Date(),
currentValueInBaseCurrency: new Big(0), currentValueInBaseCurrency: new Big(0),
errors: [], errors: [],
hasErrors: false, hasErrors: false,
@ -184,8 +188,7 @@ export abstract class PortfolioCalculator {
totalInterestWithCurrencyEffect: new Big(0), totalInterestWithCurrencyEffect: new Big(0),
totalInvestment: new Big(0), totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0), totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilitiesWithCurrencyEffect: new Big(0), totalLiabilitiesWithCurrencyEffect: new Big(0)
totalValuablesWithCurrencyEffect: new Big(0)
}; };
} }
@ -195,7 +198,6 @@ export abstract class PortfolioCalculator {
let firstTransactionPoint: TransactionPoint = null; let firstTransactionPoint: TransactionPoint = null;
let totalInterestWithCurrencyEffect = new Big(0); let totalInterestWithCurrencyEffect = new Big(0);
let totalLiabilitiesWithCurrencyEffect = new Big(0); let totalLiabilitiesWithCurrencyEffect = new Big(0);
let totalValuablesWithCurrencyEffect = new Big(0);
for (const { currency, dataSource, symbol } of transactionPoints[ for (const { currency, dataSource, symbol } of transactionPoints[
firstIndex - 1 firstIndex - 1
@ -220,7 +222,7 @@ export abstract class PortfolioCalculator {
const exchangeRatesByCurrency = const exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({ await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: uniq(Object.values(currencies)), currencies: Array.from(new Set(Object.values(currencies))),
endDate: endOfDay(this.endDate), endDate: endOfDay(this.endDate),
startDate: this.startDate, startDate: this.startDate,
targetCurrency: this.currency targetCurrency: this.currency
@ -286,10 +288,12 @@ export abstract class PortfolioCalculator {
firstIndex--; firstIndex--;
} }
const positions: TimelinePosition[] = []; const errors: ResponseError['errors'] = [];
let hasAnySymbolMetricsErrors = false; let hasAnySymbolMetricsErrors = false;
const errors: ResponseError['errors'] = []; const positions: (TimelinePosition & {
includeInHoldings: boolean;
})[] = [];
const accumulatedValuesByDate: { const accumulatedValuesByDate: {
[date: string]: { [date: string]: {
@ -361,8 +365,7 @@ export abstract class PortfolioCalculator {
totalInterestInBaseCurrency, totalInterestInBaseCurrency,
totalInvestment, totalInvestment,
totalInvestmentWithCurrencyEffect, totalInvestmentWithCurrencyEffect,
totalLiabilitiesInBaseCurrency, totalLiabilitiesInBaseCurrency
totalValuablesInBaseCurrency
} = this.getSymbolMetrics({ } = this.getSymbolMetrics({
chartDateMap, chartDateMap,
marketSymbolMap, marketSymbolMap,
@ -409,6 +412,7 @@ export abstract class PortfolioCalculator {
grossPerformanceWithCurrencyEffect: !hasErrors grossPerformanceWithCurrencyEffect: !hasErrors
? (grossPerformanceWithCurrencyEffect ?? null) ? (grossPerformanceWithCurrencyEffect ?? null)
: null, : null,
includeInHoldings: item.includeInHoldings,
investment: totalInvestment, investment: totalInvestment,
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
marketPrice: marketPrice:
@ -441,16 +445,13 @@ export abstract class PortfolioCalculator {
totalLiabilitiesWithCurrencyEffect = totalLiabilitiesWithCurrencyEffect =
totalLiabilitiesWithCurrencyEffect.plus(totalLiabilitiesInBaseCurrency); totalLiabilitiesWithCurrencyEffect.plus(totalLiabilitiesInBaseCurrency);
totalValuablesWithCurrencyEffect = totalValuablesWithCurrencyEffect.plus(
totalValuablesInBaseCurrency
);
if ( if (
(hasErrors || (hasErrors ||
currentRateErrors.find(({ dataSource, symbol }) => { currentRateErrors.find(({ dataSource, symbol }) => {
return dataSource === item.dataSource && symbol === item.symbol; return dataSource === item.dataSource && symbol === item.symbol;
})) && })) &&
item.investment.gt(0) item.investment.gt(0) &&
item.skipErrors === false
) { ) {
errors.push({ dataSource: item.dataSource, symbol: item.symbol }); errors.push({ dataSource: item.dataSource, symbol: item.symbol });
} }
@ -594,7 +595,6 @@ export abstract class PortfolioCalculator {
netPerformance: totalNetPerformanceValue.toNumber(), netPerformance: totalNetPerformanceValue.toNumber(),
netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffect:
totalNetPerformanceValueWithCurrencyEffect.toNumber(), totalNetPerformanceValueWithCurrencyEffect.toNumber(),
// TODO: Add valuables
netWorth: totalCurrentValueWithCurrencyEffect netWorth: totalCurrentValueWithCurrencyEffect
.plus(totalAccountBalanceWithCurrencyEffect) .plus(totalAccountBalanceWithCurrencyEffect)
.toNumber(), .toNumber(),
@ -609,18 +609,28 @@ export abstract class PortfolioCalculator {
const overall = this.calculateOverallPerformance(positions); const overall = this.calculateOverallPerformance(positions);
const positionsIncludedInHoldings = positions
.filter(({ includeInHoldings }) => {
return includeInHoldings;
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(({ includeInHoldings, ...rest }) => {
return rest;
});
return { return {
...overall, ...overall,
errors, errors,
historicalData, historicalData,
positions,
totalInterestWithCurrencyEffect, totalInterestWithCurrencyEffect,
totalLiabilitiesWithCurrencyEffect, totalLiabilitiesWithCurrencyEffect,
totalValuablesWithCurrencyEffect, hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors,
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors positions: positionsIncludedInHoldings
}; };
} }
protected abstract getPerformanceCalculationType(): PerformanceCalculationType;
public getDataProviderInfos() { public getDataProviderInfos() {
return this.dataProviderInfos; return this.dataProviderInfos;
} }
@ -749,7 +759,7 @@ export abstract class PortfolioCalculator {
? 0 ? 0
: netPerformanceWithCurrencyEffectSinceStartDate / : netPerformanceWithCurrencyEffectSinceStartDate /
timeWeightedInvestmentValue timeWeightedInvestmentValue
// TODO: Add net worth with valuables // TODO: Add net worth
// netWorth: totalCurrentValueWithCurrencyEffect // netWorth: totalCurrentValueWithCurrencyEffect
// .plus(totalAccountBalanceWithCurrencyEffect) // .plus(totalAccountBalanceWithCurrencyEffect)
// .toNumber() // .toNumber()
@ -772,9 +782,7 @@ export abstract class PortfolioCalculator {
let firstActivityDate: Date; let firstActivityDate: Date;
try { try {
const firstAccountBalanceDateString = first( const firstAccountBalanceDateString = this.accountBalanceItems[0]?.date;
this.accountBalanceItems
)?.date;
firstAccountBalanceDate = firstAccountBalanceDateString firstAccountBalanceDate = firstAccountBalanceDateString
? parseDate(firstAccountBalanceDateString) ? parseDate(firstAccountBalanceDateString)
: new Date(); : new Date();
@ -816,12 +824,6 @@ export abstract class PortfolioCalculator {
return this.transactionPoints; return this.transactionPoints;
} }
public async getValuablesInBaseCurrency() {
await this.snapshotPromise;
return this.snapshot.totalValuablesWithCurrencyEffect;
}
private getChartDateMap({ private getChartDateMap({
endDate, endDate,
startDate, startDate,
@ -899,8 +901,8 @@ export abstract class PortfolioCalculator {
let lastTransactionPoint: TransactionPoint = null; let lastTransactionPoint: TransactionPoint = null;
for (const { for (const {
fee,
date, date,
fee,
quantity, quantity,
SymbolProfile, SymbolProfile,
tags, tags,
@ -908,9 +910,14 @@ export abstract class PortfolioCalculator {
unitPrice unitPrice
} of this.activities) { } of this.activities) {
let currentTransactionPointItem: TransactionPointSymbol; let currentTransactionPointItem: TransactionPointSymbol;
const oldAccumulatedSymbol = symbols[SymbolProfile.symbol];
const currency = SymbolProfile.currency;
const dataSource = SymbolProfile.dataSource;
const factor = getFactor(type); const factor = getFactor(type);
const skipErrors = !!SymbolProfile.userId; // Skip errors for custom asset profiles
const symbol = SymbolProfile.symbol;
const oldAccumulatedSymbol = symbols[symbol];
if (oldAccumulatedSymbol) { if (oldAccumulatedSymbol) {
let investment = oldAccumulatedSymbol.investment; let investment = oldAccumulatedSymbol.investment;
@ -930,32 +937,36 @@ export abstract class PortfolioCalculator {
} }
currentTransactionPointItem = { currentTransactionPointItem = {
currency,
dataSource,
investment, investment,
skipErrors,
symbol,
averagePrice: newQuantity.gt(0) averagePrice: newQuantity.gt(0)
? investment.div(newQuantity) ? investment.div(newQuantity)
: new Big(0), : new Big(0),
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
dividend: new Big(0), dividend: new Big(0),
fee: oldAccumulatedSymbol.fee.plus(fee), fee: oldAccumulatedSymbol.fee.plus(fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate, firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
includeInHoldings: oldAccumulatedSymbol.includeInHoldings,
quantity: newQuantity, quantity: newQuantity,
symbol: SymbolProfile.symbol,
tags: oldAccumulatedSymbol.tags.concat(tags), tags: oldAccumulatedSymbol.tags.concat(tags),
transactionCount: oldAccumulatedSymbol.transactionCount + 1 transactionCount: oldAccumulatedSymbol.transactionCount + 1
}; };
} else { } else {
currentTransactionPointItem = { currentTransactionPointItem = {
currency,
dataSource,
fee, fee,
skipErrors,
symbol,
tags, tags,
averagePrice: unitPrice, averagePrice: unitPrice,
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
dividend: new Big(0), dividend: new Big(0),
firstBuyDate: date, firstBuyDate: date,
includeInHoldings: INVESTMENT_ACTIVITY_TYPES.includes(type),
investment: unitPrice.mul(quantity).mul(factor), investment: unitPrice.mul(quantity).mul(factor),
quantity: quantity.mul(factor), quantity: quantity.mul(factor),
symbol: SymbolProfile.symbol,
transactionCount: 1 transactionCount: 1
}; };
} }
@ -997,19 +1008,12 @@ export abstract class PortfolioCalculator {
liabilities = quantity.mul(unitPrice); liabilities = quantity.mul(unitPrice);
} }
let valuables = new Big(0);
if (type === 'ITEM') {
valuables = quantity.mul(unitPrice);
}
if (lastDate !== date || lastTransactionPoint === null) { if (lastDate !== date || lastTransactionPoint === null) {
lastTransactionPoint = { lastTransactionPoint = {
date, date,
fees, fees,
interest, interest,
liabilities, liabilities,
valuables,
items: newItems items: newItems
}; };
@ -1021,8 +1025,6 @@ export abstract class PortfolioCalculator {
lastTransactionPoint.items = newItems; lastTransactionPoint.items = newItems;
lastTransactionPoint.liabilities = lastTransactionPoint.liabilities =
lastTransactionPoint.liabilities.plus(liabilities); lastTransactionPoint.liabilities.plus(liabilities);
lastTransactionPoint.valuables =
lastTransactionPoint.valuables.plus(valuables);
} }
lastDate = date; lastDate = date;
@ -1073,6 +1075,7 @@ export abstract class PortfolioCalculator {
// Compute in the background // Compute in the background
this.portfolioSnapshotService.addJobToQueue({ this.portfolioSnapshotService.addJobToQueue({
data: { data: {
calculationType: this.getPerformanceCalculationType(),
filters: this.filters, filters: this.filters,
userCurrency: this.currency, userCurrency: this.currency,
userId: this.userId userId: this.userId
@ -1089,6 +1092,7 @@ export abstract class PortfolioCalculator {
// Wait for computation // Wait for computation
await this.portfolioSnapshotService.addJobToQueue({ await this.portfolioSnapshotService.addJobToQueue({
data: { data: {
calculationType: this.getPerformanceCalculationType(),
filters: this.filters, filters: this.filters,
userCurrency: this.currency, userCurrency: this.currency,
userId: this.userId userId: this.userId

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

@ -4,10 +4,7 @@ import {
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -17,9 +14,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -92,7 +89,7 @@ describe('PortfolioCalculator', () => {
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-22'), date: new Date('2021-11-22'),
fee: 1.55, feeInAssetProfileCurrency: 1.55,
quantity: 2, quantity: 2,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -102,12 +99,12 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
type: 'BUY', type: 'BUY',
unitPrice: 142.9 unitPriceInAssetProfileCurrency: 142.9
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), date: new Date('2021-11-30'),
fee: 1.65, feeInAssetProfileCurrency: 1.65,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -117,12 +114,12 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
type: 'SELL', type: 'SELL',
unitPrice: 136.6 unitPriceInAssetProfileCurrency: 136.6
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), date: new Date('2021-11-30'),
fee: 0, feeInAssetProfileCurrency: 0,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -132,13 +129,13 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
type: 'SELL', type: 'SELL',
unitPrice: 136.6 unitPriceInAssetProfileCurrency: 136.6
} }
]; ];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -197,11 +194,10 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'), totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'), totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({ expect.objectContaining({
netPerformance: -15.8, netPerformance: -15.8,
netPerformanceInPercentage: -0.05528341497550734703, netPerformanceInPercentage: -0.05528341497550734703,

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

@ -4,10 +4,7 @@ import {
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -17,9 +14,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -92,7 +89,7 @@ describe('PortfolioCalculator', () => {
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-22'), date: new Date('2021-11-22'),
fee: 1.55, feeInAssetProfileCurrency: 1.55,
quantity: 2, quantity: 2,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -102,12 +99,12 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
type: 'BUY', type: 'BUY',
unitPrice: 142.9 unitPriceInAssetProfileCurrency: 142.9
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), date: new Date('2021-11-30'),
fee: 1.65, feeInAssetProfileCurrency: 1.65,
quantity: 2, quantity: 2,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -117,13 +114,13 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
type: 'SELL', type: 'SELL',
unitPrice: 136.6 unitPriceInAssetProfileCurrency: 136.6
} }
]; ];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -182,11 +179,10 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'), totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'), totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({ expect.objectContaining({
netPerformance: -15.8, netPerformance: -15.8,
netPerformanceInPercentage: -0.05528341497550734703, netPerformanceInPercentage: -0.05528341497550734703,

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

@ -4,10 +4,7 @@ import {
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -17,9 +14,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -92,7 +89,7 @@ describe('PortfolioCalculator', () => {
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), date: new Date('2021-11-30'),
fee: 1.55, feeInAssetProfileCurrency: 1.55,
quantity: 2, quantity: 2,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -102,13 +99,13 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
type: 'BUY', type: 'BUY',
unitPrice: 136.6 unitPriceInAssetProfileCurrency: 136.6
} }
]; ];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -173,11 +170,10 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('273.2'), totalInvestment: new Big('273.2'),
totalInvestmentWithCurrencyEffect: new Big('273.2'), totalInvestmentWithCurrencyEffect: new Big('273.2'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({ expect.objectContaining({
netPerformance: 23.05, netPerformance: 23.05,
netPerformanceInPercentage: 0.08437042459736457, netPerformanceInPercentage: 0.08437042459736457,
@ -196,5 +192,83 @@ describe('PortfolioCalculator', () => {
{ date: '2021-12-01', investment: 0 } { date: '2021-12-01', investment: 0 }
]); ]);
}); });
it.only('with BALN.SW buy (with unit price lower than closing price)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 135.0
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const snapshotOnBuyDate = portfolioSnapshot.historicalData.find(
({ date }) => {
return date === '2021-11-30';
}
);
// Closing price on 2021-11-30: 136.6
expect(snapshotOnBuyDate?.netPerformanceWithCurrencyEffect).toEqual(1.65); // 2 * (136.6 - 135.0) - 1.55 = 1.65
});
it.only('with BALN.SW buy (with unit price lower than closing price), calculated on buy date', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-11-30').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 135.0
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const snapshotOnBuyDate = portfolioSnapshot.historicalData.find(
({ date }) => {
return date === '2021-11-30';
}
);
// Closing price on 2021-11-30: 136.6
expect(snapshotOnBuyDate?.netPerformanceWithCurrencyEffect).toEqual(1.65); // 2 * (136.6 - 135.0) - 1.55 = 1.65
});
}); });
}); });

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

@ -0,0 +1,240 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
join(__dirname, '../../../../../../../test/import/ok/btceur.json')
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with BTCUSD buy (in EUR)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: 44558.42
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2021-12-11',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
/**
* Closing price on 2021-12-12: 50098.3
*/
expect(portfolioSnapshot.historicalData[1]).toEqual({
date: '2021-12-12',
investmentValueWithCurrencyEffect: 44558.42,
netPerformance: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
netPerformanceInPercentage: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
netPerformanceWithCurrencyEffect: 5535.42,
netWorth: 50098.3, // 1 * 50098.3 = 50098.3
totalAccountBalance: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,
value: 50098.3, // 1 * 50098.3 = 50098.3
valueWithCurrencyEffect: 50098.3
});
expect(
portfolioSnapshot.historicalData[
portfolioSnapshot.historicalData.length - 1
]
).toEqual({
date: '2022-01-14',
investmentValueWithCurrencyEffect: 0,
netPerformance: -1463.18,
netPerformanceInPercentage: -0.032837340282712,
netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712,
netPerformanceWithCurrencyEffect: -1463.18,
netWorth: 43099.7,
totalAccountBalance: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,
value: 43099.7,
valueWithCurrencyEffect: 43099.7
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('43099.7'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('44558.42'),
currency: 'USD',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4.46'),
feeInBaseCurrency: new Big('4.46'),
firstBuyDate: '2021-12-12',
grossPerformance: new Big('-1458.72'),
grossPerformancePercentage: new Big('-0.03273724696701543726'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.03273724696701543726'
),
grossPerformanceWithCurrencyEffect: new Big('-1458.72'),
investment: new Big('44558.42'),
investmentWithCurrencyEffect: new Big('44558.42'),
netPerformance: new Big('-1463.18'),
netPerformancePercentage: new Big('-0.03283734028271199921'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.03283734028271199921')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('-1463.18')
},
marketPrice: 43099.7,
marketPriceInBaseCurrency: 43099.7,
quantity: new Big('1'),
symbol: 'BTCUSD',
tags: [],
timeWeightedInvestment: new Big('44558.42'),
timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'),
transactionCount: 1,
valueInBaseCurrency: new Big('43099.7')
}
],
totalFeesWithCurrencyEffect: new Big('4.46'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('44558.42'),
totalInvestmentWithCurrencyEffect: new Big('44558.42'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([
{ date: '2021-12-12', investment: new Big('44558.42') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-12-01', investment: 44558.42 },
{ date: '2022-01-01', investment: 0 }
]);
});
});
});

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

@ -4,10 +4,7 @@ import {
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -18,9 +15,9 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -106,7 +103,7 @@ describe('PortfolioCalculator', () => {
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2015-01-01'), date: new Date('2015-01-01'),
fee: 0, feeInAssetProfileCurrency: 0,
quantity: 2, quantity: 2,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -116,12 +113,12 @@ describe('PortfolioCalculator', () => {
symbol: 'BTCUSD' symbol: 'BTCUSD'
}, },
type: 'BUY', type: 'BUY',
unitPrice: 320.43 unitPriceInAssetProfileCurrency: 320.43
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2017-12-31'), date: new Date('2017-12-31'),
fee: 0, feeInAssetProfileCurrency: 0,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -131,13 +128,13 @@ describe('PortfolioCalculator', () => {
symbol: 'BTCUSD' symbol: 'BTCUSD'
}, },
type: 'SELL', type: 'SELL',
unitPrice: 14156.4 unitPriceInAssetProfileCurrency: 14156.4
} }
]; ];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -201,11 +198,10 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('320.43').mul(0.97373), totalInvestment: new Big('320.43').mul(0.97373),
totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957'), totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({ expect.objectContaining({
netPerformance: new Big('27172.74').mul(0.97373).toNumber(), netPerformance: new Big('27172.74').mul(0.97373).toNumber(),
netPerformanceInPercentage: 42.41983590271396609433, netPerformanceInPercentage: 42.41983590271396609433,

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

@ -0,0 +1,240 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
join(__dirname, '../../../../../../../test/import/ok/btcusd.json')
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with BTCUSD buy (in USD)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: 44558.42
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2021-12-11',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
/**
* Closing price on 2021-12-12: 50098.3
*/
expect(portfolioSnapshot.historicalData[1]).toEqual({
date: '2021-12-12',
investmentValueWithCurrencyEffect: 44558.42,
netPerformance: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
netPerformanceInPercentage: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
netPerformanceWithCurrencyEffect: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
netWorth: 50098.3, // 1 * 50098.3 = 50098.3
totalAccountBalance: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,
value: 50098.3, // 1 * 50098.3 = 50098.3
valueWithCurrencyEffect: 50098.3
});
expect(
portfolioSnapshot.historicalData[
portfolioSnapshot.historicalData.length - 1
]
).toEqual({
date: '2022-01-14',
investmentValueWithCurrencyEffect: 0,
netPerformance: -1463.18,
netPerformanceInPercentage: -0.032837340282712,
netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712,
netPerformanceWithCurrencyEffect: -1463.18,
netWorth: 43099.7,
totalAccountBalance: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,
value: 43099.7,
valueWithCurrencyEffect: 43099.7
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('43099.7'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('44558.42'),
currency: 'USD',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4.46'),
feeInBaseCurrency: new Big('4.46'),
firstBuyDate: '2021-12-12',
grossPerformance: new Big('-1458.72'),
grossPerformancePercentage: new Big('-0.03273724696701543726'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.03273724696701543726'
),
grossPerformanceWithCurrencyEffect: new Big('-1458.72'),
investment: new Big('44558.42'),
investmentWithCurrencyEffect: new Big('44558.42'),
netPerformance: new Big('-1463.18'),
netPerformancePercentage: new Big('-0.03283734028271199921'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.03283734028271199921')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('-1463.18')
},
marketPrice: 43099.7,
marketPriceInBaseCurrency: 43099.7,
quantity: new Big('1'),
symbol: 'BTCUSD',
tags: [],
timeWeightedInvestment: new Big('44558.42'),
timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'),
transactionCount: 1,
valueInBaseCurrency: new Big('43099.7')
}
],
totalFeesWithCurrencyEffect: new Big('4.46'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('44558.42'),
totalInvestmentWithCurrencyEffect: new Big('44558.42'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([
{ date: '2021-12-12', investment: new Big('44558.42') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-12-01', investment: 44558.42 },
{ date: '2022-01-01', investment: 0 }
]);
});
});
});

50
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts

@ -4,10 +4,7 @@ import {
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -17,9 +14,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -92,7 +89,7 @@ describe('PortfolioCalculator', () => {
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-09-01'), date: new Date('2021-09-01'),
fee: 49, feeInAssetProfileCurrency: 49,
quantity: 0, quantity: 0,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -102,13 +99,13 @@ describe('PortfolioCalculator', () => {
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141' symbol: '2c463fb3-af07-486e-adb0-8301b3d72141'
}, },
type: 'FEE', type: 'FEE',
unitPrice: 0 unitPriceInAssetProfileCurrency: 0
} }
]; ];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'USD', currency: 'USD',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -119,46 +116,15 @@ describe('PortfolioCalculator', () => {
currentValueInBaseCurrency: new Big('0'), currentValueInBaseCurrency: new Big('0'),
errors: [], errors: [],
hasErrors: true, hasErrors: true,
positions: [ positions: [],
{
averagePrice: new Big('0'),
currency: 'USD',
dataSource: 'MANUAL',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('49'),
feeInBaseCurrency: new Big('49'),
firstBuyDate: '2021-09-01',
grossPerformance: null,
grossPerformancePercentage: null,
grossPerformancePercentageWithCurrencyEffect: null,
grossPerformanceWithCurrencyEffect: null,
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
marketPrice: null,
marketPriceInBaseCurrency: 0,
netPerformance: null,
netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffectMap: null,
netPerformanceWithCurrencyEffectMap: null,
quantity: new Big('0'),
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141',
tags: [],
timeWeightedInvestment: new Big('0'),
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
transactionCount: 1,
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('49'), totalFeesWithCurrencyEffect: new Big('49'),
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'), totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'), totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({ expect.objectContaining({
netPerformance: 0, netPerformance: 0,
netPerformanceInPercentage: 0, netPerformanceInPercentage: 0,

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

@ -4,10 +4,7 @@ import {
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -18,9 +15,9 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -105,7 +102,7 @@ describe('PortfolioCalculator', () => {
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2023-01-03'), date: new Date('2023-01-03'),
fee: 1, feeInAssetProfileCurrency: 1,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -115,13 +112,13 @@ describe('PortfolioCalculator', () => {
symbol: 'GOOGL' symbol: 'GOOGL'
}, },
type: 'BUY', type: 'BUY',
unitPrice: 89.12 unitPriceInAssetProfileCurrency: 89.12
} }
]; ];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -180,11 +177,10 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('89.12').mul(0.8854), totalInvestment: new Big('89.12').mul(0.8854),
totalInvestmentWithCurrencyEffect: new Big('82.329056'), totalInvestmentWithCurrencyEffect: new Big('82.329056'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({ expect.objectContaining({
netPerformance: new Big('26.33').mul(0.8854).toNumber(), netPerformance: new Big('26.33').mul(0.8854).toNumber(),
netPerformanceInPercentage: 0.29544434470377019749, netPerformanceInPercentage: 0.29544434470377019749,

12
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts

@ -4,10 +4,7 @@ import {
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -17,6 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
@ -91,7 +89,7 @@ describe('PortfolioCalculator', () => {
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2023-01-01'), // Date in future date: new Date('2023-01-01'), // Date in future
fee: 0, feeInAssetProfileCurrency: 0,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -101,13 +99,13 @@ describe('PortfolioCalculator', () => {
symbol: '55196015-1365-4560-aa60-8751ae6d18f8' symbol: '55196015-1365-4560-aa60-8751ae6d18f8'
}, },
type: 'LIABILITY', type: 'LIABILITY',
unitPrice: 3000 unitPriceInAssetProfileCurrency: 3000
} }
]; ];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'USD', currency: 'USD',
userId: userDummyData.id userId: userDummyData.id
}); });

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

@ -4,23 +4,19 @@ import {
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -52,18 +48,6 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
}; };
}); });
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
@ -105,7 +89,7 @@ describe('PortfolioCalculator', () => {
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-09-16'), date: new Date('2021-09-16'),
fee: 19, feeInAssetProfileCurrency: 19,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -115,12 +99,12 @@ describe('PortfolioCalculator', () => {
symbol: 'MSFT' symbol: 'MSFT'
}, },
type: 'BUY', type: 'BUY',
unitPrice: 298.58 unitPriceInAssetProfileCurrency: 298.58
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-16'), date: new Date('2021-11-16'),
fee: 0, feeInAssetProfileCurrency: 0,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -130,13 +114,13 @@ describe('PortfolioCalculator', () => {
symbol: 'MSFT' symbol: 'MSFT'
}, },
type: 'DIVIDEND', type: 'DIVIDEND',
unitPrice: 0.62 unitPriceInAssetProfileCurrency: 0.62
} }
]; ];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'USD', currency: 'USD',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -186,11 +170,10 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('298.58'), totalInvestment: new Big('298.58'),
totalInvestmentWithCurrencyEffect: new Big('298.58'), totalInvestmentWithCurrencyEffect: new Big('298.58'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({ expect.objectContaining({
totalInvestmentValueWithCurrencyEffect: 298.58 totalInvestmentValueWithCurrencyEffect: 298.58
}) })

11
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts

@ -1,8 +1,5 @@
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -12,6 +9,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
@ -84,7 +82,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities: [], activities: [],
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -107,8 +105,7 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big(0), totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0), totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(investments).toEqual([]); expect(investments).toEqual([]);

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

@ -6,10 +6,7 @@ import {
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -19,9 +16,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
import { join } from 'path'; import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
@ -68,7 +65,7 @@ describe('PortfolioCalculator', () => {
activityDtos = loadActivityExportFile( activityDtos = loadActivityExportFile(
join( join(
__dirname, __dirname,
'../../../../../../../test/import/ok-novn-buy-and-sell-partially.json' '../../../../../../../test/import/ok/novn-buy-and-sell-partially.json'
) )
); );
}); });
@ -106,18 +103,20 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
...activity, ...activity,
date: parseDate(activity.date), date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
currency: activity.currency, currency: activity.currency,
dataSource: activity.dataSource, dataSource: activity.dataSource,
name: 'Novartis AG', name: 'Novartis AG',
symbol: activity.symbol symbol: activity.symbol
} },
unitPriceInAssetProfileCurrency: activity.unitPrice
})); }));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -178,11 +177,10 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('75.80'), totalInvestment: new Big('75.80'),
totalInvestmentWithCurrencyEffect: new Big('75.80'), totalInvestmentWithCurrencyEffect: new Big('75.80'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({ expect.objectContaining({
netPerformance: 17.68, netPerformance: 17.68,
netPerformanceInPercentage: 0.12184460284330327256, netPerformanceInPercentage: 0.12184460284330327256,

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

@ -6,10 +6,7 @@ import {
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -19,9 +16,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
import { join } from 'path'; import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
@ -68,7 +65,7 @@ describe('PortfolioCalculator', () => {
activityDtos = loadActivityExportFile( activityDtos = loadActivityExportFile(
join( join(
__dirname, __dirname,
'../../../../../../../test/import/ok-novn-buy-and-sell.json' '../../../../../../../test/import/ok/novn-buy-and-sell.json'
) )
); );
}); });
@ -106,18 +103,20 @@ describe('PortfolioCalculator', () => {
...activityDummyData, ...activityDummyData,
...activity, ...activity,
date: parseDate(activity.date), date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
currency: activity.currency, currency: activity.currency,
dataSource: activity.dataSource, dataSource: activity.dataSource,
name: 'Novartis AG', name: 'Novartis AG',
symbol: activity.symbol symbol: activity.symbol
} },
unitPriceInAssetProfileCurrency: activity.unitPrice
})); }));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -146,19 +145,23 @@ describe('PortfolioCalculator', () => {
valueWithCurrencyEffect: 0 valueWithCurrencyEffect: 0
}); });
/**
* Closing price on 2022-03-07 is unknown,
* hence it uses the last unit price (2022-04-11): 87.8
*/
expect(portfolioSnapshot.historicalData[1]).toEqual({ expect(portfolioSnapshot.historicalData[1]).toEqual({
date: '2022-03-07', date: '2022-03-07',
investmentValueWithCurrencyEffect: 151.6, investmentValueWithCurrencyEffect: 151.6,
netPerformance: 0, netPerformance: 24, // 2 * (87.8 - 75.8) = 24
netPerformanceInPercentage: 0, netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
netPerformanceWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 24,
netWorth: 151.6, netWorth: 175.6, // 2 * 87.8 = 175.6
totalAccountBalance: 0, totalAccountBalance: 0,
totalInvestment: 151.6, totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6, totalInvestmentValueWithCurrencyEffect: 151.6,
value: 151.6, value: 175.6, // 2 * 87.8 = 175.6
valueWithCurrencyEffect: 151.6 valueWithCurrencyEffect: 175.6
}); });
expect( expect(
@ -225,11 +228,10 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'), totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'), totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({ expect.objectContaining({
netPerformance: 19.86, netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744063, netPerformanceInPercentage: 0.13100263852242744063,

64
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts

@ -4,10 +4,7 @@ import {
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -17,9 +14,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -85,14 +82,14 @@ describe('PortfolioCalculator', () => {
}); });
describe('compute portfolio snapshot', () => { describe('compute portfolio snapshot', () => {
it.only('with item activity', async () => { it.only('with valuable activity', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-31').getTime()); jest.useFakeTimers().setSystemTime(parseDate('2022-01-31').getTime());
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2022-01-01'), date: new Date('2022-01-01'),
fee: 0, feeInAssetProfileCurrency: 0,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -101,14 +98,14 @@ describe('PortfolioCalculator', () => {
name: 'Penthouse Apartment', name: 'Penthouse Apartment',
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde' symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
}, },
type: 'ITEM', type: 'BUY',
unitPrice: 500000 unitPriceInAssetProfileCurrency: 500000
} }
]; ];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'USD', currency: 'USD',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -116,9 +113,9 @@ describe('PortfolioCalculator', () => {
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(portfolioSnapshot).toMatchObject({ expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'), currentValueInBaseCurrency: new Big('500000'),
errors: [], errors: [],
hasErrors: true, hasErrors: false,
positions: [ positions: [
{ {
averagePrice: new Big('500000'), averagePrice: new Big('500000'),
@ -129,42 +126,45 @@ describe('PortfolioCalculator', () => {
fee: new Big('0'), fee: new Big('0'),
feeInBaseCurrency: new Big('0'), feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-01-01', firstBuyDate: '2022-01-01',
grossPerformance: null, grossPerformance: new Big('0'),
grossPerformancePercentage: null, grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: null, grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: null, grossPerformanceWithCurrencyEffect: new Big('0'),
investment: new Big('0'), investment: new Big('500000'),
investmentWithCurrencyEffect: new Big('0'), investmentWithCurrencyEffect: new Big('500000'),
marketPrice: null, marketPrice: null,
marketPriceInBaseCurrency: 500000, marketPriceInBaseCurrency: 500000,
netPerformance: null, netPerformance: new Big('0'),
netPerformancePercentage: null, netPerformancePercentage: new Big('0'),
netPerformancePercentageWithCurrencyEffectMap: null, netPerformancePercentageWithCurrencyEffectMap: {
netPerformanceWithCurrencyEffectMap: null, max: new Big('0')
quantity: new Big('0'), },
netPerformanceWithCurrencyEffectMap: {
max: new Big('0')
},
quantity: new Big('1'),
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde', symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde',
tags: [], tags: [],
timeWeightedInvestment: new Big('0'), timeWeightedInvestment: new Big('500000'),
timeWeightedInvestmentWithCurrencyEffect: new Big('0'), timeWeightedInvestmentWithCurrencyEffect: new Big('500000'),
transactionCount: 1, transactionCount: 1,
valueInBaseCurrency: new Big('0') valueInBaseCurrency: new Big('500000')
} }
], ],
totalFeesWithCurrencyEffect: new Big('0'), totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'), totalInvestment: new Big('500000'),
totalInvestmentWithCurrencyEffect: new Big('0'), totalInvestmentWithCurrencyEffect: new Big('500000'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({ expect.objectContaining({
netPerformance: 0, netPerformance: 0,
netPerformanceInPercentage: 0, netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0,
totalInvestmentValueWithCurrencyEffect: 0 totalInvestmentValueWithCurrencyEffect: 500000
}) })
); );
}); });

0
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts

985
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts

@ -0,0 +1,985 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { DateRange } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
import { addMilliseconds, differenceInDays, format, isBefore } from 'date-fns';
import { cloneDeep, sortBy } from 'lodash';
export class RoaiPortfolioCalculator extends PortfolioCalculator {
private chartDates: string[];
protected calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot {
let currentValueInBaseCurrency = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0);
let hasErrors = false;
let netPerformance = new Big(0);
let totalFeesWithCurrencyEffect = new Big(0);
const totalInterestWithCurrencyEffect = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalTimeWeightedInvestment = new Big(0);
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency
);
}
if (currentPosition.valueInBaseCurrency) {
currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
currentPosition.valueInBaseCurrency
);
} else {
hasErrors = true;
}
if (currentPosition.investment) {
totalInvestment = totalInvestment.plus(currentPosition.investment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
currentPosition.investmentWithCurrencyEffect
);
} else {
hasErrors = true;
}
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
grossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.plus(
currentPosition.grossPerformanceWithCurrencyEffect
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
if (currentPosition.timeWeightedInvestment) {
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
currentPosition.timeWeightedInvestment
);
totalTimeWeightedInvestmentWithCurrencyEffect =
totalTimeWeightedInvestmentWithCurrencyEffect.plus(
currentPosition.timeWeightedInvestmentWithCurrencyEffect
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
'PortfolioCalculator'
);
hasErrors = true;
}
}
return {
currentValueInBaseCurrency,
hasErrors,
positions,
totalFeesWithCurrencyEffect,
totalInterestWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
activitiesCount: this.activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,
createdAt: new Date(),
errors: [],
historicalData: [],
totalLiabilitiesWithCurrencyEffect: new Big(0)
};
}
protected getPerformanceCalculationType() {
return PerformanceCalculationType.ROAI;
}
protected getSymbolMetrics({
chartDateMap,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
}: {
chartDateMap?: { [date: string]: boolean };
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
} & AssetProfileIdentifier): SymbolMetrics {
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
const currentValues: { [date: string]: Big } = {};
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};
let fees = new Big(0);
let feesAtStartDate = new Big(0);
let feesAtStartDateWithCurrencyEffect = new Big(0);
let feesWithCurrencyEffect = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0);
let grossPerformanceAtStartDate = new Big(0);
let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0);
let grossPerformanceFromSells = new Big(0);
let grossPerformanceFromSellsWithCurrencyEffect = new Big(0);
let initialValue: Big;
let initialValueWithCurrencyEffect: Big;
let investmentAtStartDate: Big;
let investmentAtStartDateWithCurrencyEffect: Big;
const investmentValuesAccumulated: { [date: string]: Big } = {};
const investmentValuesAccumulatedWithCurrencyEffect: {
[date: string]: Big;
} = {};
const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0);
let lastAveragePriceWithCurrencyEffect = new Big(0);
const netPerformanceValues: { [date: string]: Big } = {};
const netPerformanceValuesWithCurrencyEffect: { [date: string]: Big } = {};
const timeWeightedInvestmentValues: { [date: string]: Big } = {};
const timeWeightedInvestmentValuesWithCurrencyEffect: {
[date: string]: Big;
} = {};
const totalAccountBalanceInBaseCurrency = new Big(0);
let totalDividend = new Big(0);
let totalDividendInBaseCurrency = new Big(0);
let totalInterest = new Big(0);
let totalInterestInBaseCurrency = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentFromBuyTransactions = new Big(0);
let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalLiabilities = new Big(0);
let totalLiabilitiesInBaseCurrency = new Big(0);
let totalQuantityFromBuyTransactions = new Big(0);
let totalUnits = new Big(0);
let valueAtStartDate: Big;
let valueAtStartDateWithCurrencyEffect: Big;
// Clone orders to keep the original values in this.orders
let orders: PortfolioOrderItem[] = cloneDeep(
this.activities.filter(({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol;
})
);
if (orders.length <= 0) {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
netPerformanceWithCurrencyEffectMap: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0)
};
}
const dateOfFirstTransaction = new Date(orders[0].date);
const endDateString = format(end, DATE_FORMAT);
const startDateString = format(start, DATE_FORMAT);
const unitPriceAtStartDate = marketSymbolMap[startDateString]?.[symbol];
let unitPriceAtEndDate = marketSymbolMap[endDateString]?.[symbol];
let latestActivity = orders.at(-1);
if (
dataSource === 'MANUAL' &&
['BUY', 'SELL'].includes(latestActivity?.type) &&
latestActivity?.unitPrice &&
!unitPriceAtEndDate
) {
// For BUY / SELL activities with a MANUAL data source where no historical market price is available,
// the calculation should fall back to using the activity’s unit price.
unitPriceAtEndDate = latestActivity.unitPrice;
}
if (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
) {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: true,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0)
};
}
// Add a synthetic order at the start and the end date
orders.push({
date: startDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'start',
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: unitPriceAtStartDate
});
orders.push({
date: endDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'end',
SymbolProfile: {
dataSource,
symbol
},
quantity: new Big(0),
type: 'BUY',
unitPrice: unitPriceAtEndDate
});
let lastUnitPrice: Big;
const ordersByDate: { [date: string]: PortfolioOrderItem[] } = {};
for (const order of orders) {
ordersByDate[order.date] = ordersByDate[order.date] ?? [];
ordersByDate[order.date].push(order);
}
if (!this.chartDates) {
this.chartDates = Object.keys(chartDateMap).sort();
}
for (const dateString of this.chartDates) {
if (dateString < startDateString) {
continue;
} else if (dateString > endDateString) {
break;
}
if (ordersByDate[dateString]?.length > 0) {
for (const order of ordersByDate[dateString]) {
order.unitPriceFromMarketData =
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice;
}
} else {
orders.push({
date: dateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,
unitPriceFromMarketData:
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice
});
}
latestActivity = orders.at(-1);
lastUnitPrice =
latestActivity.unitPriceFromMarketData ?? latestActivity.unitPrice;
}
// Sort orders so that the start and end placeholder order are at the correct
// position
orders = sortBy(orders, ({ date, itemType }) => {
let sortIndex = new Date(date);
if (itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
} else if (itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
return sortIndex.getTime();
});
const indexOfStartOrder = orders.findIndex(({ itemType }) => {
return itemType === 'start';
});
const indexOfEndOrder = orders.findIndex(({ itemType }) => {
return itemType === 'end';
});
let totalInvestmentDays = 0;
let sumOfTimeWeightedInvestments = new Big(0);
let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0);
for (let i = 0; i < orders.length; i += 1) {
const order = orders[i];
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log();
console.log();
console.log(
i + 1,
order.date,
order.type,
order.itemType ? `(${order.itemType})` : ''
);
}
const exchangeRateAtOrderDate = exchangeRates[order.date];
if (order.type === 'DIVIDEND') {
const dividend = order.quantity.mul(order.unitPrice);
totalDividend = totalDividend.plus(dividend);
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
dividend.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'INTEREST') {
const interest = order.quantity.mul(order.unitPrice);
totalInterest = totalInterest.plus(interest);
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
interest.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'LIABILITY') {
const liabilities = order.quantity.mul(order.unitPrice);
totalLiabilities = totalLiabilities.plus(liabilities);
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus(
liabilities.mul(exchangeRateAtOrderDate ?? 1)
);
}
if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date
order.unitPrice =
indexOfStartOrder === 0
? orders[i + 1]?.unitPrice
: unitPriceAtStartDate;
}
if (order.fee) {
order.feeInBaseCurrency = order.fee.mul(currentExchangeRate ?? 1);
order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul(
exchangeRateAtOrderDate ?? 1
);
}
const unitPrice = ['BUY', 'SELL'].includes(order.type)
? order.unitPrice
: order.unitPriceFromMarketData;
if (unitPrice) {
order.unitPriceInBaseCurrency = unitPrice.mul(currentExchangeRate ?? 1);
order.unitPriceInBaseCurrencyWithCurrencyEffect = unitPrice.mul(
exchangeRateAtOrderDate ?? 1
);
}
const marketPriceInBaseCurrency =
order.unitPriceFromMarketData?.mul(currentExchangeRate ?? 1) ??
new Big(0);
const marketPriceInBaseCurrencyWithCurrencyEffect =
order.unitPriceFromMarketData?.mul(exchangeRateAtOrderDate ?? 1) ??
new Big(0);
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
marketPriceInBaseCurrency
);
const valueOfInvestmentBeforeTransactionWithCurrencyEffect =
totalUnits.mul(marketPriceInBaseCurrencyWithCurrencyEffect);
if (!investmentAtStartDate && i >= indexOfStartOrder) {
investmentAtStartDate = totalInvestment ?? new Big(0);
investmentAtStartDateWithCurrencyEffect =
totalInvestmentWithCurrencyEffect ?? new Big(0);
valueAtStartDate = valueOfInvestmentBeforeTransaction;
valueAtStartDateWithCurrencyEffect =
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
}
let transactionInvestment = new Big(0);
let transactionInvestmentWithCurrencyEffect = new Big(0);
if (order.type === 'BUY') {
transactionInvestment = order.quantity
.mul(order.unitPriceInBaseCurrency)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect = order.quantity
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect)
.mul(getFactor(order.type));
totalQuantityFromBuyTransactions =
totalQuantityFromBuyTransactions.plus(order.quantity);
totalInvestmentFromBuyTransactions =
totalInvestmentFromBuyTransactions.plus(transactionInvestment);
totalInvestmentFromBuyTransactionsWithCurrencyEffect =
totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
} else if (order.type === 'SELL') {
if (totalUnits.gt(0)) {
transactionInvestment = totalInvestment
.div(totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect
.div(totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
}
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('order.quantity', order.quantity.toNumber());
console.log('transactionInvestment', transactionInvestment.toNumber());
console.log(
'transactionInvestmentWithCurrencyEffect',
transactionInvestmentWithCurrencyEffect.toNumber()
);
}
const totalInvestmentBeforeTransaction = totalInvestment;
const totalInvestmentBeforeTransactionWithCurrencyEffect =
totalInvestmentWithCurrencyEffect;
totalInvestment = totalInvestment.plus(transactionInvestment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
if (i >= indexOfStartOrder && !initialValue) {
if (
i === indexOfStartOrder &&
!valueOfInvestmentBeforeTransaction.eq(0)
) {
initialValue = valueOfInvestmentBeforeTransaction;
initialValueWithCurrencyEffect =
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
} else if (transactionInvestment.gt(0)) {
initialValue = transactionInvestment;
initialValueWithCurrencyEffect =
transactionInvestmentWithCurrencyEffect;
}
}
fees = fees.plus(order.feeInBaseCurrency ?? 0);
feesWithCurrencyEffect = feesWithCurrencyEffect.plus(
order.feeInBaseCurrencyWithCurrencyEffect ?? 0
);
totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type)));
const valueOfInvestment = totalUnits.mul(marketPriceInBaseCurrency);
const valueOfInvestmentWithCurrencyEffect = totalUnits.mul(
marketPriceInBaseCurrencyWithCurrencyEffect
);
const grossPerformanceFromSell =
order.type === 'SELL'
? order.unitPriceInBaseCurrency
.minus(lastAveragePrice)
.mul(order.quantity)
: new Big(0);
const grossPerformanceFromSellWithCurrencyEffect =
order.type === 'SELL'
? order.unitPriceInBaseCurrencyWithCurrencyEffect
.minus(lastAveragePriceWithCurrencyEffect)
.mul(order.quantity)
: new Big(0);
grossPerformanceFromSells = grossPerformanceFromSells.plus(
grossPerformanceFromSell
);
grossPerformanceFromSellsWithCurrencyEffect =
grossPerformanceFromSellsWithCurrencyEffect.plus(
grossPerformanceFromSellWithCurrencyEffect
);
lastAveragePrice = totalQuantityFromBuyTransactions.eq(0)
? new Big(0)
: totalInvestmentFromBuyTransactions.div(
totalQuantityFromBuyTransactions
);
lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq(
0
)
? new Big(0)
: totalInvestmentFromBuyTransactionsWithCurrencyEffect.div(
totalQuantityFromBuyTransactions
);
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
'grossPerformanceFromSells',
grossPerformanceFromSells.toNumber()
);
console.log(
'grossPerformanceFromSellWithCurrencyEffect',
grossPerformanceFromSellWithCurrencyEffect.toNumber()
);
}
const newGrossPerformance = valueOfInvestment
.minus(totalInvestment)
.plus(grossPerformanceFromSells);
const newGrossPerformanceWithCurrencyEffect =
valueOfInvestmentWithCurrencyEffect
.minus(totalInvestmentWithCurrencyEffect)
.plus(grossPerformanceFromSellsWithCurrencyEffect);
grossPerformance = newGrossPerformance;
grossPerformanceWithCurrencyEffect =
newGrossPerformanceWithCurrencyEffect;
if (order.itemType === 'start') {
feesAtStartDate = fees;
feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect;
grossPerformanceAtStartDate = grossPerformance;
grossPerformanceAtStartDateWithCurrencyEffect =
grossPerformanceWithCurrencyEffect;
}
if (i > indexOfStartOrder) {
// Only consider periods with an investment for the calculation of
// the time weighted investment
if (
valueOfInvestmentBeforeTransaction.gt(0) &&
['BUY', 'SELL'].includes(order.type)
) {
// Calculate the number of days since the previous order
const orderDate = new Date(order.date);
const previousOrderDate = new Date(orders[i - 1].date);
let daysSinceLastOrder = differenceInDays(
orderDate,
previousOrderDate
);
if (daysSinceLastOrder <= 0) {
// The time between two activities on the same day is unknown
// -> Set it to the smallest floating point number greater than 0
daysSinceLastOrder = Number.EPSILON;
}
// Sum up the total investment days since the start date to calculate
// the time weighted investment
totalInvestmentDays += daysSinceLastOrder;
sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add(
valueAtStartDate
.minus(investmentAtStartDate)
.plus(totalInvestmentBeforeTransaction)
.mul(daysSinceLastOrder)
);
sumOfTimeWeightedInvestmentsWithCurrencyEffect =
sumOfTimeWeightedInvestmentsWithCurrencyEffect.add(
valueAtStartDateWithCurrencyEffect
.minus(investmentAtStartDateWithCurrencyEffect)
.plus(totalInvestmentBeforeTransactionWithCurrencyEffect)
.mul(daysSinceLastOrder)
);
}
currentValues[order.date] = valueOfInvestment;
currentValuesWithCurrencyEffect[order.date] =
valueOfInvestmentWithCurrencyEffect;
netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
netPerformanceValuesWithCurrencyEffect[order.date] =
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.minus(
feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect)
);
investmentValuesAccumulated[order.date] = totalInvestment;
investmentValuesAccumulatedWithCurrencyEffect[order.date] =
totalInvestmentWithCurrencyEffect;
investmentValuesWithCurrencyEffect[order.date] = (
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0)
).add(transactionInvestmentWithCurrencyEffect);
// If duration is effectively zero (first day), use the actual investment as the base.
// Otherwise, use the calculated time-weighted average.
timeWeightedInvestmentValues[order.date] =
totalInvestmentDays > Number.EPSILON
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: totalInvestment.gt(0)
? totalInvestment
: new Big(0);
timeWeightedInvestmentValuesWithCurrencyEffect[order.date] =
totalInvestmentDays > Number.EPSILON
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays
)
: totalInvestmentWithCurrencyEffect.gt(0)
? totalInvestmentWithCurrencyEffect
: new Big(0);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('totalInvestment', totalInvestment.toNumber());
console.log(
'totalInvestmentWithCurrencyEffect',
totalInvestmentWithCurrencyEffect.toNumber()
);
console.log(
'totalGrossPerformance',
grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
);
console.log(
'totalGrossPerformanceWithCurrencyEffect',
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.toNumber()
);
}
if (i === indexOfEndOrder) {
break;
}
}
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalGrossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.minus(
grossPerformanceAtStartDateWithCurrencyEffect
);
const totalNetPerformance = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
const timeWeightedAverageInvestmentBetweenStartAndEndDate =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
const timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays
)
: new Big(0);
const grossPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalGrossPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
: new Big(0);
const grossPerformancePercentageWithCurrencyEffect =
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt(
0
)
? totalGrossPerformanceWithCurrencyEffect.div(
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
)
: new Big(0);
const feesPerUnit = totalUnits.gt(0)
? fees.minus(feesAtStartDate).div(totalUnits)
: new Big(0);
const feesPerUnitWithCurrencyEffect = totalUnits.gt(0)
? feesWithCurrencyEffect
.minus(feesAtStartDateWithCurrencyEffect)
.div(totalUnits)
: new Big(0);
const netPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalNetPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
: new Big(0);
const netPerformancePercentageWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
const netPerformanceWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
for (const dateRange of [
'1d',
'1y',
'5y',
'max',
'mtd',
'wtd',
'ytd'
// TODO:
// ...eachYearOfInterval({ end, start })
// .filter((date) => {
// return !isThisYear(date);
// })
// .map((date) => {
// return format(date, 'yyyy');
// })
] as DateRange[]) {
const dateInterval = getIntervalFromDateRange(dateRange);
const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate;
if (isBefore(startDate, start)) {
startDate = start;
}
const rangeEndDateString = format(endDate, DATE_FORMAT);
const rangeStartDateString = format(startDate, DATE_FORMAT);
const currentValuesAtDateRangeStartWithCurrencyEffect =
currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0);
const investmentValuesAccumulatedAtStartDateWithCurrencyEffect =
investmentValuesAccumulatedWithCurrencyEffect[rangeStartDateString] ??
new Big(0);
const grossPerformanceAtDateRangeStartWithCurrencyEffect =
currentValuesAtDateRangeStartWithCurrencyEffect.minus(
investmentValuesAccumulatedAtStartDateWithCurrencyEffect
);
let average = new Big(0);
let dayCount = 0;
for (let i = this.chartDates.length - 1; i >= 0; i -= 1) {
const date = this.chartDates[i];
if (date > rangeEndDateString) {
continue;
} else if (date < rangeStartDateString) {
break;
}
if (
investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big &&
investmentValuesAccumulatedWithCurrencyEffect[date].gt(0)
) {
average = average.add(
investmentValuesAccumulatedWithCurrencyEffect[date].add(
grossPerformanceAtDateRangeStartWithCurrencyEffect
)
);
dayCount++;
}
}
if (dayCount > 0) {
average = average.div(dayCount);
}
netPerformanceWithCurrencyEffectMap[dateRange] =
netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus(
// If the date range is 'max', take 0 as a start value. Otherwise,
// the value of the end of the day of the start date is taken which
// differs from the buying price.
dateRange === 'max'
? new Big(0)
: (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ??
new Big(0))
) ?? new Big(0);
netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0)
? netPerformanceWithCurrencyEffectMap[dateRange].div(average)
: new Big(0);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
`
${symbol}
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
2
)} -> ${unitPriceAtEndDate.toFixed(2)}
Total investment: ${totalInvestment.toFixed(2)}
Total investment with currency effect: ${totalInvestmentWithCurrencyEffect.toFixed(
2
)}
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed(
2
)}
Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed(
2
)}
Total dividend: ${totalDividend.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed(
2
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed(
2
)} / ${grossPerformancePercentageWithCurrencyEffect
.mul(100)
.toFixed(2)}%
Fees per unit: ${feesPerUnit.toFixed(2)}
Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed(
2
)}
Net performance: ${totalNetPerformance.toFixed(
2
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%
Net performance with currency effect: ${netPerformancePercentageWithCurrencyEffectMap[
'max'
].toFixed(2)}%`
);
}
return {
currentValues,
currentValuesWithCurrencyEffect,
feesWithCurrencyEffect,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
initialValue,
initialValueWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffectMap,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
netPerformanceWithCurrencyEffectMap,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect,
totalAccountBalanceInBaseCurrency,
totalDividend,
totalDividendInBaseCurrency,
totalInterest,
totalInterestInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect,
totalLiabilities,
totalLiabilitiesInBaseCurrency,
grossPerformance: totalGrossPerformance,
grossPerformanceWithCurrencyEffect:
totalGrossPerformanceWithCurrencyEffect,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance,
timeWeightedInvestment:
timeWeightedAverageInvestmentBetweenStartAndEndDate,
timeWeightedInvestmentWithCurrencyEffect:
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
};
}
}

29
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts

@ -0,0 +1,29 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import {
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
export class RoiPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot {
throw new Error('Method not implemented.');
}
protected getPerformanceCalculationType() {
return PerformanceCalculationType.ROI;
}
protected getSymbolMetrics({}: {
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
step?: number;
} & AssetProfileIdentifier): SymbolMetrics {
throw new Error('Method not implemented.');
}
}

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

@ -1,965 +1,29 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
SymbolMetrics SymbolMetrics
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { DateRange } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common'; export class TwrPortfolioCalculator extends PortfolioCalculator {
import { Big } from 'big.js'; protected calculateOverallPerformance(): PortfolioSnapshot {
import { addMilliseconds, differenceInDays, format, isBefore } from 'date-fns'; throw new Error('Method not implemented.');
import { cloneDeep, first, last, sortBy } from 'lodash';
export class TWRPortfolioCalculator extends PortfolioCalculator {
private chartDates: string[];
protected calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot {
let currentValueInBaseCurrency = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0);
let hasErrors = false;
let netPerformance = new Big(0);
let totalFeesWithCurrencyEffect = new Big(0);
const totalInterestWithCurrencyEffect = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalTimeWeightedInvestment = new Big(0);
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency
);
}
if (currentPosition.valueInBaseCurrency) {
currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
currentPosition.valueInBaseCurrency
);
} else {
hasErrors = true;
}
if (currentPosition.investment) {
totalInvestment = totalInvestment.plus(currentPosition.investment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
currentPosition.investmentWithCurrencyEffect
);
} else {
hasErrors = true;
} }
if (currentPosition.grossPerformance) { protected getPerformanceCalculationType() {
grossPerformance = grossPerformance.plus( return PerformanceCalculationType.TWR;
currentPosition.grossPerformance
);
grossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.plus(
currentPosition.grossPerformanceWithCurrencyEffect
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
} }
if (currentPosition.timeWeightedInvestment) { protected getSymbolMetrics({}: {
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
currentPosition.timeWeightedInvestment
);
totalTimeWeightedInvestmentWithCurrencyEffect =
totalTimeWeightedInvestmentWithCurrencyEffect.plus(
currentPosition.timeWeightedInvestmentWithCurrencyEffect
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
'PortfolioCalculator'
);
hasErrors = true;
}
}
return {
currentValueInBaseCurrency,
hasErrors,
positions,
totalFeesWithCurrencyEffect,
totalInterestWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
errors: [],
historicalData: [],
totalLiabilitiesWithCurrencyEffect: new Big(0),
totalValuablesWithCurrencyEffect: new Big(0)
};
}
protected getSymbolMetrics({
chartDateMap,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
}: {
chartDateMap?: { [date: string]: boolean };
end: Date; end: Date;
exchangeRates: { [dateString: string]: number }; exchangeRates: { [dateString: string]: number };
marketSymbolMap: { marketSymbolMap: {
[date: string]: { [symbol: string]: Big }; [date: string]: { [symbol: string]: Big };
}; };
start: Date; start: Date;
step?: number;
} & AssetProfileIdentifier): SymbolMetrics { } & AssetProfileIdentifier): SymbolMetrics {
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; throw new Error('Method not implemented.');
const currentValues: { [date: string]: Big } = {};
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};
let fees = new Big(0);
let feesAtStartDate = new Big(0);
let feesAtStartDateWithCurrencyEffect = new Big(0);
let feesWithCurrencyEffect = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0);
let grossPerformanceAtStartDate = new Big(0);
let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0);
let grossPerformanceFromSells = new Big(0);
let grossPerformanceFromSellsWithCurrencyEffect = new Big(0);
let initialValue: Big;
let initialValueWithCurrencyEffect: Big;
let investmentAtStartDate: Big;
let investmentAtStartDateWithCurrencyEffect: Big;
const investmentValuesAccumulated: { [date: string]: Big } = {};
const investmentValuesAccumulatedWithCurrencyEffect: {
[date: string]: Big;
} = {};
const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0);
let lastAveragePriceWithCurrencyEffect = new Big(0);
const netPerformanceValues: { [date: string]: Big } = {};
const netPerformanceValuesWithCurrencyEffect: { [date: string]: Big } = {};
const timeWeightedInvestmentValues: { [date: string]: Big } = {};
const timeWeightedInvestmentValuesWithCurrencyEffect: {
[date: string]: Big;
} = {};
const totalAccountBalanceInBaseCurrency = new Big(0);
let totalDividend = new Big(0);
let totalDividendInBaseCurrency = new Big(0);
let totalInterest = new Big(0);
let totalInterestInBaseCurrency = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentFromBuyTransactions = new Big(0);
let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalLiabilities = new Big(0);
let totalLiabilitiesInBaseCurrency = new Big(0);
let totalQuantityFromBuyTransactions = new Big(0);
let totalUnits = new Big(0);
let totalValuables = new Big(0);
let totalValuablesInBaseCurrency = new Big(0);
let valueAtStartDate: Big;
let valueAtStartDateWithCurrencyEffect: Big;
// Clone orders to keep the original values in this.orders
let orders: PortfolioOrderItem[] = cloneDeep(
this.activities.filter(({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol;
})
);
if (orders.length <= 0) {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
netPerformanceWithCurrencyEffectMap: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
};
}
const dateOfFirstTransaction = new Date(first(orders).date);
const endDateString = format(end, DATE_FORMAT);
const startDateString = format(start, DATE_FORMAT);
const unitPriceAtStartDate = marketSymbolMap[startDateString]?.[symbol];
const unitPriceAtEndDate = marketSymbolMap[endDateString]?.[symbol];
if (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
) {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: true,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
};
}
// Add a synthetic order at the start and the end date
orders.push({
date: startDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'start',
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: unitPriceAtStartDate
});
orders.push({
date: endDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'end',
SymbolProfile: {
dataSource,
symbol
},
quantity: new Big(0),
type: 'BUY',
unitPrice: unitPriceAtEndDate
});
let lastUnitPrice: Big;
const ordersByDate: { [date: string]: PortfolioOrderItem[] } = {};
for (const order of orders) {
ordersByDate[order.date] = ordersByDate[order.date] ?? [];
ordersByDate[order.date].push(order);
}
if (!this.chartDates) {
this.chartDates = Object.keys(chartDateMap).sort();
}
for (const dateString of this.chartDates) {
if (dateString < startDateString) {
continue;
} else if (dateString > endDateString) {
break;
}
if (ordersByDate[dateString]?.length > 0) {
for (const order of ordersByDate[dateString]) {
order.unitPriceFromMarketData =
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice;
}
} else {
orders.push({
date: dateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,
unitPriceFromMarketData:
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice
});
}
const lastOrder = last(orders);
lastUnitPrice = lastOrder.unitPriceFromMarketData ?? lastOrder.unitPrice;
}
// Sort orders so that the start and end placeholder order are at the correct
// position
orders = sortBy(orders, ({ date, itemType }) => {
let sortIndex = new Date(date);
if (itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
} else if (itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
return sortIndex.getTime();
});
const indexOfStartOrder = orders.findIndex(({ itemType }) => {
return itemType === 'start';
});
const indexOfEndOrder = orders.findIndex(({ itemType }) => {
return itemType === 'end';
});
let totalInvestmentDays = 0;
let sumOfTimeWeightedInvestments = new Big(0);
let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0);
for (let i = 0; i < orders.length; i += 1) {
const order = orders[i];
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log();
console.log();
console.log(
i + 1,
order.date,
order.type,
order.itemType ? `(${order.itemType})` : ''
);
}
const exchangeRateAtOrderDate = exchangeRates[order.date];
if (order.type === 'DIVIDEND') {
const dividend = order.quantity.mul(order.unitPrice);
totalDividend = totalDividend.plus(dividend);
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
dividend.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'INTEREST') {
const interest = order.quantity.mul(order.unitPrice);
totalInterest = totalInterest.plus(interest);
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
interest.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'ITEM') {
const valuables = order.quantity.mul(order.unitPrice);
totalValuables = totalValuables.plus(valuables);
totalValuablesInBaseCurrency = totalValuablesInBaseCurrency.plus(
valuables.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'LIABILITY') {
const liabilities = order.quantity.mul(order.unitPrice);
totalLiabilities = totalLiabilities.plus(liabilities);
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus(
liabilities.mul(exchangeRateAtOrderDate ?? 1)
);
}
if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date
order.unitPrice =
indexOfStartOrder === 0
? orders[i + 1]?.unitPrice
: unitPriceAtStartDate;
}
if (order.fee) {
order.feeInBaseCurrency = order.fee.mul(currentExchangeRate ?? 1);
order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul(
exchangeRateAtOrderDate ?? 1
);
}
const unitPrice = ['BUY', 'SELL'].includes(order.type)
? order.unitPrice
: order.unitPriceFromMarketData;
if (unitPrice) {
order.unitPriceInBaseCurrency = unitPrice.mul(currentExchangeRate ?? 1);
order.unitPriceInBaseCurrencyWithCurrencyEffect = unitPrice.mul(
exchangeRateAtOrderDate ?? 1
);
}
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
order.unitPriceInBaseCurrency
);
const valueOfInvestmentBeforeTransactionWithCurrencyEffect =
totalUnits.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect);
if (!investmentAtStartDate && i >= indexOfStartOrder) {
investmentAtStartDate = totalInvestment ?? new Big(0);
investmentAtStartDateWithCurrencyEffect =
totalInvestmentWithCurrencyEffect ?? new Big(0);
valueAtStartDate = valueOfInvestmentBeforeTransaction;
valueAtStartDateWithCurrencyEffect =
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
}
let transactionInvestment = new Big(0);
let transactionInvestmentWithCurrencyEffect = new Big(0);
if (order.type === 'BUY') {
transactionInvestment = order.quantity
.mul(order.unitPriceInBaseCurrency)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect = order.quantity
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect)
.mul(getFactor(order.type));
totalQuantityFromBuyTransactions =
totalQuantityFromBuyTransactions.plus(order.quantity);
totalInvestmentFromBuyTransactions =
totalInvestmentFromBuyTransactions.plus(transactionInvestment);
totalInvestmentFromBuyTransactionsWithCurrencyEffect =
totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
} else if (order.type === 'SELL') {
if (totalUnits.gt(0)) {
transactionInvestment = totalInvestment
.div(totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect
.div(totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
}
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('order.quantity', order.quantity.toNumber());
console.log('transactionInvestment', transactionInvestment.toNumber());
console.log(
'transactionInvestmentWithCurrencyEffect',
transactionInvestmentWithCurrencyEffect.toNumber()
);
}
const totalInvestmentBeforeTransaction = totalInvestment;
const totalInvestmentBeforeTransactionWithCurrencyEffect =
totalInvestmentWithCurrencyEffect;
totalInvestment = totalInvestment.plus(transactionInvestment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
if (i >= indexOfStartOrder && !initialValue) {
if (
i === indexOfStartOrder &&
!valueOfInvestmentBeforeTransaction.eq(0)
) {
initialValue = valueOfInvestmentBeforeTransaction;
initialValueWithCurrencyEffect =
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
} else if (transactionInvestment.gt(0)) {
initialValue = transactionInvestment;
initialValueWithCurrencyEffect =
transactionInvestmentWithCurrencyEffect;
}
}
fees = fees.plus(order.feeInBaseCurrency ?? 0);
feesWithCurrencyEffect = feesWithCurrencyEffect.plus(
order.feeInBaseCurrencyWithCurrencyEffect ?? 0
);
totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type)));
const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency);
const valueOfInvestmentWithCurrencyEffect = totalUnits.mul(
order.unitPriceInBaseCurrencyWithCurrencyEffect
);
const grossPerformanceFromSell =
order.type === 'SELL'
? order.unitPriceInBaseCurrency
.minus(lastAveragePrice)
.mul(order.quantity)
: new Big(0);
const grossPerformanceFromSellWithCurrencyEffect =
order.type === 'SELL'
? order.unitPriceInBaseCurrencyWithCurrencyEffect
.minus(lastAveragePriceWithCurrencyEffect)
.mul(order.quantity)
: new Big(0);
grossPerformanceFromSells = grossPerformanceFromSells.plus(
grossPerformanceFromSell
);
grossPerformanceFromSellsWithCurrencyEffect =
grossPerformanceFromSellsWithCurrencyEffect.plus(
grossPerformanceFromSellWithCurrencyEffect
);
lastAveragePrice = totalQuantityFromBuyTransactions.eq(0)
? new Big(0)
: totalInvestmentFromBuyTransactions.div(
totalQuantityFromBuyTransactions
);
lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq(
0
)
? new Big(0)
: totalInvestmentFromBuyTransactionsWithCurrencyEffect.div(
totalQuantityFromBuyTransactions
);
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
'grossPerformanceFromSells',
grossPerformanceFromSells.toNumber()
);
console.log(
'grossPerformanceFromSellWithCurrencyEffect',
grossPerformanceFromSellWithCurrencyEffect.toNumber()
);
}
const newGrossPerformance = valueOfInvestment
.minus(totalInvestment)
.plus(grossPerformanceFromSells);
const newGrossPerformanceWithCurrencyEffect =
valueOfInvestmentWithCurrencyEffect
.minus(totalInvestmentWithCurrencyEffect)
.plus(grossPerformanceFromSellsWithCurrencyEffect);
grossPerformance = newGrossPerformance;
grossPerformanceWithCurrencyEffect =
newGrossPerformanceWithCurrencyEffect;
if (order.itemType === 'start') {
feesAtStartDate = fees;
feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect;
grossPerformanceAtStartDate = grossPerformance;
grossPerformanceAtStartDateWithCurrencyEffect =
grossPerformanceWithCurrencyEffect;
}
if (i > indexOfStartOrder) {
// Only consider periods with an investment for the calculation of
// the time weighted investment
if (
valueOfInvestmentBeforeTransaction.gt(0) &&
['BUY', 'SELL'].includes(order.type)
) {
// Calculate the number of days since the previous order
const orderDate = new Date(order.date);
const previousOrderDate = new Date(orders[i - 1].date);
let daysSinceLastOrder = differenceInDays(
orderDate,
previousOrderDate
);
if (daysSinceLastOrder <= 0) {
// The time between two activities on the same day is unknown
// -> Set it to the smallest floating point number greater than 0
daysSinceLastOrder = Number.EPSILON;
}
// Sum up the total investment days since the start date to calculate
// the time weighted investment
totalInvestmentDays += daysSinceLastOrder;
sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add(
valueAtStartDate
.minus(investmentAtStartDate)
.plus(totalInvestmentBeforeTransaction)
.mul(daysSinceLastOrder)
);
sumOfTimeWeightedInvestmentsWithCurrencyEffect =
sumOfTimeWeightedInvestmentsWithCurrencyEffect.add(
valueAtStartDateWithCurrencyEffect
.minus(investmentAtStartDateWithCurrencyEffect)
.plus(totalInvestmentBeforeTransactionWithCurrencyEffect)
.mul(daysSinceLastOrder)
);
}
currentValues[order.date] = valueOfInvestment;
currentValuesWithCurrencyEffect[order.date] =
valueOfInvestmentWithCurrencyEffect;
netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
netPerformanceValuesWithCurrencyEffect[order.date] =
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.minus(
feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect)
);
investmentValuesAccumulated[order.date] = totalInvestment;
investmentValuesAccumulatedWithCurrencyEffect[order.date] =
totalInvestmentWithCurrencyEffect;
investmentValuesWithCurrencyEffect[order.date] = (
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0)
).add(transactionInvestmentWithCurrencyEffect);
timeWeightedInvestmentValues[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
timeWeightedInvestmentValuesWithCurrencyEffect[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays
)
: new Big(0);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('totalInvestment', totalInvestment.toNumber());
console.log(
'totalInvestmentWithCurrencyEffect',
totalInvestmentWithCurrencyEffect.toNumber()
);
console.log(
'totalGrossPerformance',
grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
);
console.log(
'totalGrossPerformanceWithCurrencyEffect',
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.toNumber()
);
}
if (i === indexOfEndOrder) {
break;
}
}
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalGrossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.minus(
grossPerformanceAtStartDateWithCurrencyEffect
);
const totalNetPerformance = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
const timeWeightedAverageInvestmentBetweenStartAndEndDate =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
const timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays
)
: new Big(0);
const grossPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalGrossPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
: new Big(0);
const grossPerformancePercentageWithCurrencyEffect =
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt(
0
)
? totalGrossPerformanceWithCurrencyEffect.div(
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
)
: new Big(0);
const feesPerUnit = totalUnits.gt(0)
? fees.minus(feesAtStartDate).div(totalUnits)
: new Big(0);
const feesPerUnitWithCurrencyEffect = totalUnits.gt(0)
? feesWithCurrencyEffect
.minus(feesAtStartDateWithCurrencyEffect)
.div(totalUnits)
: new Big(0);
const netPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalNetPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
: new Big(0);
const netPerformancePercentageWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
const netPerformanceWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
for (const dateRange of [
'1d',
'1y',
'5y',
'max',
'mtd',
'wtd',
'ytd'
// TODO:
// ...eachYearOfInterval({ end, start })
// .filter((date) => {
// return !isThisYear(date);
// })
// .map((date) => {
// return format(date, 'yyyy');
// })
] as DateRange[]) {
const dateInterval = getIntervalFromDateRange(dateRange);
const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate;
if (isBefore(startDate, start)) {
startDate = start;
}
const rangeEndDateString = format(endDate, DATE_FORMAT);
const rangeStartDateString = format(startDate, DATE_FORMAT);
const currentValuesAtDateRangeStartWithCurrencyEffect =
currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0);
const investmentValuesAccumulatedAtStartDateWithCurrencyEffect =
investmentValuesAccumulatedWithCurrencyEffect[rangeStartDateString] ??
new Big(0);
const grossPerformanceAtDateRangeStartWithCurrencyEffect =
currentValuesAtDateRangeStartWithCurrencyEffect.minus(
investmentValuesAccumulatedAtStartDateWithCurrencyEffect
);
let average = new Big(0);
let dayCount = 0;
for (let i = this.chartDates.length - 1; i >= 0; i -= 1) {
const date = this.chartDates[i];
if (date > rangeEndDateString) {
continue;
} else if (date < rangeStartDateString) {
break;
}
if (
investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big &&
investmentValuesAccumulatedWithCurrencyEffect[date].gt(0)
) {
average = average.add(
investmentValuesAccumulatedWithCurrencyEffect[date].add(
grossPerformanceAtDateRangeStartWithCurrencyEffect
)
);
dayCount++;
}
}
if (dayCount > 0) {
average = average.div(dayCount);
}
netPerformanceWithCurrencyEffectMap[dateRange] =
netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus(
// If the date range is 'max', take 0 as a start value. Otherwise,
// the value of the end of the day of the start date is taken which
// differs from the buying price.
dateRange === 'max'
? new Big(0)
: (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ??
new Big(0))
) ?? new Big(0);
netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0)
? netPerformanceWithCurrencyEffectMap[dateRange].div(average)
: new Big(0);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
`
${symbol}
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
2
)} -> ${unitPriceAtEndDate.toFixed(2)}
Total investment: ${totalInvestment.toFixed(2)}
Total investment with currency effect: ${totalInvestmentWithCurrencyEffect.toFixed(
2
)}
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed(
2
)}
Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed(
2
)}
Total dividend: ${totalDividend.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed(
2
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed(
2
)} / ${grossPerformancePercentageWithCurrencyEffect
.mul(100)
.toFixed(2)}%
Fees per unit: ${feesPerUnit.toFixed(2)}
Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed(
2
)}
Net performance: ${totalNetPerformance.toFixed(
2
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%
Net performance with currency effect: ${netPerformancePercentageWithCurrencyEffectMap[
'max'
].toFixed(2)}%`
);
}
return {
currentValues,
currentValuesWithCurrencyEffect,
feesWithCurrencyEffect,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
initialValue,
initialValueWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffectMap,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
netPerformanceWithCurrencyEffectMap,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect,
totalAccountBalanceInBaseCurrency,
totalDividend,
totalDividendInBaseCurrency,
totalInterest,
totalInterestInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect,
totalLiabilities,
totalLiabilitiesInBaseCurrency,
totalValuables,
totalValuablesInBaseCurrency,
grossPerformance: totalGrossPerformance,
grossPerformanceWithCurrencyEffect:
totalGrossPerformanceWithCurrencyEffect,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance,
timeWeightedInvestment:
timeWeightedAverageInvestmentBetweenStartAndEndDate,
timeWeightedInvestmentWithCurrencyEffect:
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
};
} }
} }

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

Loading…
Cancel
Save