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. 53
      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. 39
      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. 373
      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. 50
      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. 89
      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. 202
      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. 15
      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. 149
      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. 958
      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
NX_ADD_PLUGINS=false
NX_NATIVE_COMMAND_RUNNER=false

4
.env.example

@ -1,7 +1,7 @@
COMPOSE_PROJECT_NAME=ghostfolio
# CACHE
REDIS_HOST=localhost
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
@ -12,5 +12,5 @@ POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
# VARIOUS
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>

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

2
.github/workflows/build-code.yml

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

2
.github/workflows/docker-image.yml

@ -19,7 +19,7 @@ jobs:
id: meta
uses: docker/metadata-action@v4
with:
images: ghostfolio/ghostfolio
images: ${{ vars.DOCKER_REPOSITORY || 'ghostfolio/ghostfolio' }}
tags: |
type=semver,pattern={{major}}
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
/.angular/cache
.cursor/rules/nx-rules.mdc
.env
.env.prod
.github/instructions/nx.instructions.md
.nx/cache
.nx/workspace-data
/.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
- [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)
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
### Setup
1. Run `npm install`
1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `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. Start the [server](#start-server) and the [client](#start-client)
1. Open https://localhost:4200/en in your browser
@ -30,7 +30,13 @@ Run `npm run start:server`
### 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_
@ -60,6 +66,10 @@ Remove permission in `UserService` using `without()`
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
## Component Library (_Storybook_)
https://ghostfol.io/development/storybook
## Git
### 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`
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
WORKDIR /ghostfolio
@ -25,32 +25,33 @@ RUN npm install
COPY ./decorate-angular-cli.js decorate-angular-cli.js
RUN node decorate-angular-cli.js
COPY ./nx.json nx.json
COPY ./replace.build.js replace.build.js
COPY ./jest.preset.js jest.preset.js
COPY ./apps apps
COPY ./libs libs
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 ./libs libs
COPY ./apps apps
ENV NX_DAEMON=false
RUN npm run build:production
# Prepare the dist image with additional node_modules
WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original
# 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
RUN npm install
COPY prisma /ghostfolio/dist/apps/api/prisma
# Overwrite the generated package.json with the original one to ensure having
# all the scripts
# all the scripts
COPY package.json /ghostfolio/dist/apps/api
RUN npm run database:generate-typings
# 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"
ENV NODE_ENV=production

53
README.md

@ -7,7 +7,7 @@
**Open Source Wealth Management Software**
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://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: 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
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.
@ -47,7 +47,7 @@ Ghostfolio is for you if you are...
- ✅ Create, update and delete transactions
- ✅ Multi account management
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Portfolio performance: Return on Average Investment (ROAI) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio
- ✅ 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):
```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
@ -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:
```bash
docker compose --env-file ./.env -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 build
docker compose -f docker/docker-compose.build.yml up -d
```
#### Setup
@ -137,9 +137,18 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
#### Upgrade Version
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
1. Run the following command to start the new Docker image: `docker compose --env-file ./.env -f docker/docker-compose.yml up -d`
At each start, the container will automatically apply the database schema migrations if needed.
1. Update the _Ghostfolio_ Docker image
- Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
- 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)
@ -212,18 +221,18 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
}
```
| Field | Type | Description |
| ------------ | ------------------- | ----------------------------------------------------------------------------- |
| `accountId` | `string` (optional) | Id of the account |
| `comment` | `string` (optional) | Comment of the activity |
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
| `date` | `string` | Date in the format `ISO-8601` |
| `fee` | `number` | Fee of the activity |
| `quantity` | `number` | Quantity of the activity |
| `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
| `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
| `unitPrice` | `number` | Price per unit of the activity |
| Field | Type | Description |
| ------------ | ------------------- | ------------------------------------------------------------------- |
| `accountId` | `string` (optional) | Id of the account |
| `comment` | `string` (optional) | Comment of the activity |
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` \| `YAHOO` |
| `date` | `string` | Date in the format `ISO-8601` |
| `fee` | `number` | Fee of the activity |
| `quantity` | `number` | Quantity of the activity |
| `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
| `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `LIABILITY` \| `SELL` |
| `unitPrice` | `number` | Price per unit of the activity |
#### Response
@ -296,6 +305,6 @@ If you like to support this project, get [**Ghostfolio Premium**](https://ghostf
## 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).

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

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

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

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

@ -30,7 +30,7 @@ export class AccountBalanceService {
): Promise<AccountBalance | null> {
return this.prismaService.accountBalance.findFirst({
include: {
Account: true
account: true
},
where: accountBalanceWhereInput
});
@ -46,7 +46,7 @@ export class AccountBalanceService {
}): Promise<AccountBalance> {
const accountBalance = await this.prismaService.accountBalance.upsert({
create: {
Account: {
account: {
connect: {
id_userId: {
userId,
@ -154,7 +154,7 @@ export class AccountBalanceService {
}
if (withExcludedAccounts === false) {
where.Account = { isExcluded: false };
where.account = { isExcluded: false };
}
const balances = await this.prismaService.accountBalance.findMany({
@ -163,7 +163,7 @@ export class AccountBalanceService {
date: 'asc'
},
select: {
Account: true,
account: true,
date: true,
id: true,
value: true
@ -174,10 +174,10 @@ export class AccountBalanceService {
balances: balances.map((balance) => {
return {
...balance,
accountId: balance.Account.id,
accountId: balance.account.id,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
balance.value,
balance.Account.currency,
balance.account.currency,
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 {
AccountBalancesResponse,
Accounts
AccountsResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type {
@ -57,17 +57,17 @@ export class AccountController {
@HasPermission(permissions.deleteAccount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
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: this.request.user.id
}
},
{ Order: true }
{ activities: true }
);
if (!account || account?.Order.length > 0) {
if (!account || account?.activities.length > 0) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -87,10 +87,10 @@ export class AccountController {
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getAllAccounts(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string
): Promise<Accounts> {
): Promise<AccountsResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
@ -110,7 +110,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string
): Promise<AccountWithValue> {
const impersonationUserId =
@ -134,7 +134,7 @@ export class AccountController {
): Promise<AccountBalancesResponse> {
return this.accountBalanceService.getAccountBalances({
filters: [{ id, type: 'ACCOUNT' }],
userCurrency: this.request.user.Settings.settings.baseCurrency,
userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id
});
}
@ -152,8 +152,8 @@ export class AccountController {
return this.accountService.createAccount(
{
...data,
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
@ -163,7 +163,7 @@ export class AccountController {
return this.accountService.createAccount(
{
...data,
User: { connect: { id: this.request.user.id } }
user: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
@ -250,8 +250,8 @@ export class AccountController {
{
data: {
...data,
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
@ -270,10 +270,10 @@ export class AccountController {
{
data: {
...data,
Platform: originalAccount.platformId
platform: originalAccount.platformId
? { disconnect: true }
: undefined,
User: { connect: { id: this.request.user.id } }
user: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {

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

@ -7,7 +7,13 @@ import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Account, Order, Platform, Prisma } from '@prisma/client';
import {
Account,
AccountBalance,
Order,
Platform,
Prisma
} from '@prisma/client';
import { Big } from 'big.js';
import { format } from 'date-fns';
import { groupBy } from 'lodash';
@ -33,12 +39,12 @@ export class AccountService {
return account;
}
public async accountWithOrders(
public async accountWithActivities(
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput,
accountInclude: Prisma.AccountInclude
): Promise<
Account & {
Order?: Order[];
activities?: Order[];
}
> {
return this.prismaService.account.findUnique({
@ -56,13 +62,19 @@ export class AccountService {
orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise<
(Account & {
Order?: Order[];
Platform?: Platform;
activities?: Order[];
balances?: AccountBalance[];
platform?: Platform;
})[]
> {
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({
cursor,
@ -76,7 +88,9 @@ export class AccountService {
return accounts.map((account) => {
account = { ...account, balance: account.balances[0]?.value ?? 0 };
delete account.balances;
if (!isBalancesIncluded) {
delete account.balances;
}
return account;
});
@ -126,7 +140,10 @@ export class AccountService {
public async getAccounts(aUserId: string): Promise<Account[]> {
const accounts = await this.accounts({
include: { Order: true, Platform: true },
include: {
activities: true,
platform: true
},
orderBy: { name: 'asc' },
where: { userId: aUserId }
});
@ -134,15 +151,15 @@ export class AccountService {
return accounts.map((account) => {
let transactionCount = 0;
for (const order of account.Order) {
if (!order.isDraft) {
for (const { isDraft } of account.activities) {
if (!isDraft) {
transactionCount += 1;
}
}
const result = { ...account, transactionCount };
delete result.Order;
delete result.activities;
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 { ApiService } from '@ghostfolio/api/services/api/api.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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS
} from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails,
AdminUsers,
EnhancedSymbolProfile
EnhancedSymbolProfile,
ScraperConfiguration
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type {
@ -50,8 +50,6 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin')
export class AdminController {
@ -59,8 +57,8 @@ export class AdminController {
private readonly adminService: AdminService,
private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService,
private readonly demoService: DemoService,
private readonly manualService: ManualService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@ -68,7 +66,14 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
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)
@ -83,7 +88,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMax(): Promise<void> {
const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -92,9 +97,9 @@ export class AdminController {
dataSource,
symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
}
@ -110,7 +115,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileData(): Promise<void> {
const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -119,9 +124,9 @@ export class AdminController {
dataSource,
symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
}
@ -142,9 +147,9 @@ export class AdminController {
dataSource,
symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
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)
@Post('market-data/:dataSource/:symbol/test')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async testMarketData(
@Body() data: { scraperConfiguration: string },
@Body() data: { scraperConfiguration: ScraperConfiguration },
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<{ price: number }> {
try {
const scraperConfiguration = JSON.parse(data.scraperConfiguration);
const price = await this.manualService.test(scraperConfiguration);
const price = await this.manualService.test(data.scraperConfiguration);
if (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)
@Post('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -316,7 +255,7 @@ export class AdminController {
return this.adminService.addAssetProfile({
dataSource,
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')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async patchAssetProfileData(
@Body() assetProfileData: UpdateAssetProfileDto,
@Body() assetProfile: UpdateAssetProfileDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<EnhancedSymbolProfile> {
return this.adminService.patchAssetProfileData({
...assetProfileData,
dataSource,
symbol
});
return this.adminService.patchAssetProfileData(
{ dataSource, symbol },
assetProfile
);
}
@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 { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
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 { DemoModule } from '@ghostfolio/api/services/demo/demo.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';
@ -25,13 +25,13 @@ import { QueueModule } from './queue/queue.module';
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
DemoModule,
ExchangeRateDataModule,
MarketDataModule,
OrderModule,
PrismaModule,
PropertyModule,
QueueModule,
SubscriptionModule,
SymbolProfileModule,
TransformDataSourceInRequestModule
],

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

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

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

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

@ -21,37 +21,31 @@ export class ApiKeyStrategy extends PassportStrategy(
private readonly prismaService: PrismaService,
private readonly userService: UserService
) {
super(
{ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' },
true,
async (apiKey: string, done: (error: any, user?: any) => void) => {
try {
const user = await this.validateApiKey(apiKey);
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (hasRole(user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
super({ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, false);
}
await this.prismaService.analytics.upsert({
create: { User: { connect: { id: user.id } } },
update: {
activityCount: { increment: 1 },
lastRequestAt: new Date()
},
where: { userId: user.id }
});
}
public async validate(apiKey: string) {
const user = await this.validateApiKey(apiKey);
done(null, user);
} catch (error) {
done(error, null);
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (hasRole(user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
);
await this.prismaService.analytics.upsert({
create: { user: { connect: { id: user.id } } },
update: {
activityCount: { increment: 1 },
lastRequestAt: new Date()
},
where: { userId: user.id }
});
}
return user;
}
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);
}
@Post('webauthn/generate-assertion-options')
public async generateAssertionOptions(@Body() body: { deviceId: string }) {
return this.webAuthService.generateAssertionOptions(body.deviceId);
@Post('webauthn/generate-authentication-options')
public async generateAuthenticationOptions(
@Body() body: { deviceId: string }
) {
return this.webAuthService.generateAuthenticationOptions(body.deviceId);
}
@Post('webauthn/verify-assertion')
public async verifyAssertion(
@Post('webauthn/verify-authentication')
public async verifyAuthentication(
@Body() body: { deviceId: string; credential: AssertionCredentialJSON }
) {
try {
const authToken = await this.webAuthService.verifyAssertion(
const authToken = await this.webAuthService.verifyAuthentication(
body.deviceId,
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> {
return new Promise(async (resolve, reject) => {
try {
const hashedAccessToken = this.userService.createAccessToken(
accessToken,
this.configurationService.get('ACCESS_TOKEN_SALT')
);
const hashedAccessToken = this.userService.createAccessToken({
password: accessToken,
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
});
const [user] = await this.userService.users({
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
import {
DEFAULT_CURRENCY,
DEFAULT_LANGUAGE_CODE,
HEADER_KEY_TIMEZONE
} from '@ghostfolio/common/config';
import { hasRole } from '@ghostfolio/common/permissions';
import { HttpException, Injectable } from '@nestjs/common';
@ -42,7 +46,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
await this.prismaService.analytics.upsert({
create: { country, User: { connect: { id: user.id } } },
create: { country, user: { connect: { id: user.id } } },
update: {
country,
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;
} else {
throw new HttpException(

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

@ -24,6 +24,8 @@ import {
verifyRegistrationResponse,
VerifyRegistrationResponseOpts
} from '@simplewebauthn/server';
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
import ms from 'ms';
import {
AssertionCredentialJSON,
@ -40,43 +42,42 @@ export class WebAuthService {
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
get rpID() {
return new URL(this.configurationService.get('ROOT_URL')).hostname;
private get expectedOrigin() {
return this.configurationService.get('ROOT_URL');
}
get expectedOrigin() {
return this.configurationService.get('ROOT_URL');
private get rpID() {
return new URL(this.configurationService.get('ROOT_URL')).hostname;
}
public async generateRegistrationOptions() {
const user = this.request.user;
const opts: GenerateRegistrationOptionsOpts = {
rpName: 'Ghostfolio',
rpID: this.rpID,
userID: user.id,
userName: '',
timeout: 60000,
attestationType: 'indirect',
authenticatorSelection: {
authenticatorAttachment: 'platform',
requireResidentKey: false,
userVerification: 'required'
}
residentKey: '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({
data: {
authChallenge: options.challenge
authChallenge: registrationOptions.challenge
},
where: {
id: user.id
}
});
return options;
return registrationOptions;
}
public async verifyAttestation(
@ -84,13 +85,14 @@ export class WebAuthService {
): Promise<AuthDeviceDto> {
const user = this.request.user;
const expectedChallenge = user.authChallenge;
let verification: VerifiedRegistrationResponse;
try {
const opts: VerifyRegistrationResponseOpts = {
expectedChallenge,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID,
requireUserVerification: false,
response: {
clientExtensionResults: credential.clientExtensionResults,
id: credential.id,
@ -99,6 +101,7 @@ export class WebAuthService {
type: 'public-key'
}
};
verification = await verifyRegistrationResponse(opts);
} catch (error) {
Logger.error(error, 'WebAuthService');
@ -111,11 +114,17 @@ export class WebAuthService {
where: { userId: user.id }
});
if (registrationInfo && verified) {
const { counter, credentialID, credentialPublicKey } = registrationInfo;
const {
credential: {
counter,
id: credentialId,
publicKey: credentialPublicKey
}
} = registrationInfo;
let existingDevice = devices.find(
(device) => device.credentialId === credentialID
);
let existingDevice = devices.find((device) => {
return isoBase64URL.fromBuffer(device.credentialId) === credentialId;
});
if (!existingDevice) {
/**
@ -123,9 +132,9 @@ export class WebAuthService {
*/
existingDevice = await this.deviceService.createAuthDevice({
counter,
credentialId: Buffer.from(credentialID),
credentialId: Buffer.from(credentialId),
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');
}
public async generateAssertionOptions(deviceId: string) {
public async generateAuthenticationOptions(deviceId: string) {
const device = await this.deviceService.authDevice({ id: deviceId });
if (!device) {
@ -146,33 +155,27 @@ export class WebAuthService {
}
const opts: GenerateAuthenticationOptionsOpts = {
allowCredentials: [
{
id: device.credentialId,
transports: ['internal'],
type: 'public-key'
}
],
allowCredentials: [],
rpID: this.rpID,
timeout: 60000,
timeout: ms('60 seconds'),
userVerification: 'preferred'
};
const options = await generateAuthenticationOptions(opts);
const authenticationOptions = await generateAuthenticationOptions(opts);
await this.userService.updateUser({
data: {
authChallenge: options.challenge
authChallenge: authenticationOptions.challenge
},
where: {
id: device.userId
}
});
return options;
return authenticationOptions;
}
public async verifyAssertion(
public async verifyAuthentication(
deviceId: string,
credential: AssertionCredentialJSON
) {
@ -185,16 +188,18 @@ export class WebAuthService {
const user = await this.userService.user({ id: device.userId });
let verification: VerifiedAuthenticationResponse;
try {
const opts: VerifyAuthenticationResponseOpts = {
authenticator: {
credentialID: device.credentialId,
credentialPublicKey: device.credentialPublicKey,
counter: device.counter
credential: {
counter: device.counter,
id: isoBase64URL.fromBuffer(device.credentialId),
publicKey: device.credentialPublicKey
},
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID,
requireUserVerification: false,
response: {
clientExtensionResults: credential.clientExtensionResults,
id: credential.id,
@ -203,13 +208,14 @@ export class WebAuthService {
type: 'public-key'
}
};
verification = await verifyAuthenticationResponse(opts);
} catch (error) {
Logger.error(error, 'WebAuthService');
throw new InternalServerErrorException({ error: error.message });
}
const { verified, authenticationInfo } = verification;
const { authenticationInfo, verified } = verification;
if (verified) {
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 { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import type {
AssetProfileIdentifier,
BenchmarkMarketDataDetails,
@ -16,6 +19,7 @@ import {
Controller,
Delete,
Get,
Headers,
HttpException,
Inject,
Param,
@ -29,12 +33,14 @@ import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { BenchmarkService } from './benchmark.service';
import { BenchmarksService } from './benchmarks.service';
@Controller('benchmark')
export class BenchmarkController {
@Controller('benchmarks')
export class BenchmarksController {
public constructor(
private readonly apiService: ApiService,
private readonly benchmarkService: BenchmarkService,
private readonly benchmarksService: BenchmarksService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@ -108,23 +114,43 @@ export class BenchmarkController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataForUser(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: 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> {
const { endDate, startDate } = getIntervalFromDateRange(
dateRange,
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,
dateRange,
endDate,
filters,
impersonationId,
startDate,
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 { parseDate } from '@ghostfolio/common/helper';
import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderGhostfolioStatusResponse,
DividendsResponse,
HistoricalResponse,
@ -23,6 +24,7 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { isISIN } from 'class-validator';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { GetDividendsDto } from './get-dividends.dto';
@ -37,16 +39,12 @@ export class GhostfolioController {
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
/**
* @deprecated
*/
@Get('dividends/:symbol')
@Get('asset-profile/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getDividendsV1(
@Param('symbol') symbol: string,
@Query() query: GetDividendsDto
): Promise<DividendsResponse> {
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
public async getAssetProfile(
@Param('symbol') symbol: string
): Promise<DataProviderGhostfolioAssetProfileResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
@ -59,18 +57,15 @@ export class GhostfolioController {
}
try {
const dividends = await this.ghostfolioService.getDividends({
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
const assetProfile = await this.ghostfolioService.getAssetProfile({
symbol
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return dividends;
return assetProfile;
} catch {
throw new HttpException(
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')
@HasPermission(permissions.enableDataProviderGhostfolio)
@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')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@ -265,7 +177,9 @@ export class GhostfolioController {
try {
const result = await this.ghostfolioService.lookup({
includeIndices,
query: query.toLowerCase()
query: isISIN(query.toUpperCase())
? query.toUpperCase()
: query.toLowerCase()
});
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')
@HasPermission(permissions.enableDataProviderGhostfolio)
@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')
@HasPermission(permissions.enableDataProviderGhostfolio)
@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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service';
import {
GetAssetProfileParams,
GetDividendsParams,
GetHistoricalParams,
GetQuotesParams,
@ -15,6 +17,7 @@ import {
} from '@ghostfolio/common/config';
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderInfo,
DividendsResponse,
HistoricalResponse,
@ -25,7 +28,7 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { DataSource, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
@Injectable()
@ -37,6 +40,44 @@ export class GhostfolioService {
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({
from,
granularity,
@ -123,9 +164,9 @@ export class GhostfolioService {
public async getMaxDailyRequests() {
return parseInt(
((await this.propertyService.getByKey(
(await this.propertyService.getByKey<string>(
PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS
)) as string) || '0',
)) || '0',
10
);
}
@ -277,6 +318,7 @@ export class GhostfolioService {
});
results.items = filteredItems;
return results;
} catch (error) {
Logger.error(error, 'GhostfolioService');
@ -286,10 +328,15 @@ export class GhostfolioService {
}
private getDataProviderInfo(): DataProviderInfo {
const ghostfolioDataProviderService = new GhostfolioDataProviderService(
this.configurationService,
this.propertyService
);
return {
...ghostfolioDataProviderService.getDataProviderInfo(),
isPremium: false,
name: 'Ghostfolio Premium',
url: 'https://ghostfol.io'
name: 'Ghostfolio Premium'
};
}

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 { 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 { 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 { RequestWithUser } from '@ghostfolio/common/types';
@ -13,6 +26,7 @@ import {
Inject,
Param,
Post,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
@ -29,9 +43,48 @@ export class MarketDataController {
private readonly adminService: AdminService,
private readonly marketDataService: MarketDataService,
@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')
@UseGuards(AuthGuard('jwt'))
public async getMarketDataBySymbol(
@ -42,7 +95,7 @@ export class MarketDataController {
{ dataSource, symbol }
]);
if (!assetProfile) {
if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
@ -55,7 +108,7 @@ export class MarketDataController {
);
const canReadOwnAssetProfile =
assetProfile.userId === this.request.user.id &&
assetProfile?.userId === this.request.user.id &&
hasPermission(
this.request.user.permissions,
permissions.readMarketDataOfOwnAssetProfile
@ -84,7 +137,7 @@ export class MarketDataController {
{ dataSource, symbol }
]);
if (!assetProfile) {
if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
@ -102,7 +155,7 @@ export class MarketDataController {
);
const canUpsertOwnAssetProfile =
assetProfile.userId === this.request.user.id &&
assetProfile?.userId === this.request.user.id &&
hasPermission(
this.request.user.permissions,
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 { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.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';
@ -8,6 +9,11 @@ import { MarketDataController } from './market-data.controller';
@Module({
controllers: [MarketDataController],
imports: [AdminModule, MarketDataServiceModule, SymbolProfileModule]
imports: [
AdminModule,
MarketDataServiceModule,
SymbolModule,
SymbolProfileModule
]
})
export class MarketDataModule {}

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

@ -57,7 +57,7 @@ export class PublicController {
}
const [
{ holdings, markets },
{ createdAt, holdings, markets },
{ performance: performance1d },
{ performance: performanceMax },
{ performance: performanceYtd }
@ -81,6 +81,7 @@ export class PublicController {
});
const publicPortfolioResponse: PublicPortfolioResponse = {
createdAt,
hasDetails,
markets,
alias: access.alias,
@ -107,7 +108,7 @@ export class PublicController {
this.exchangeRateDataService.toCurrency(
quantity * marketPrice,
currency,
this.request.user?.Settings?.settings.baseCurrency ??
this.request.user?.settings?.settings.baseCurrency ??
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 { 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 { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -25,8 +27,10 @@ import { PublicController } from './public.controller';
controllers: [PublicController],
imports: [
AccessModule,
BenchmarkModule,
DataProviderModule,
ExchangeRateDataModule,
I18nModule,
ImpersonationModule,
MarketDataModule,
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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { Module } from '@nestjs/common';
import { SitemapController } from './sitemap.controller';
import { SitemapService } from './sitemap.service';
@Module({
controllers: [SitemapController],
imports: [ConfigurationModule]
imports: [ConfigurationModule, I18nModule],
providers: [SitemapService]
})
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;
}

89
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 { 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 {
Body,
@ -8,41 +10,63 @@ import {
Delete,
Get,
HttpException,
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateTagDto } from './create-tag.dto';
import { TagService } from './tag.service';
import { UpdateTagDto } from './update-tag.dto';
@Controller('tag')
export class TagController {
public constructor(private readonly tagService: TagService) {}
@Get()
@HasPermission(permissions.readTags)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@Controller('tags')
export class TagsController {
public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly tagService: TagService
) {}
@Post()
@HasPermission(permissions.createTag)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseGuards(AuthGuard('jwt'))
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);
}
@HasPermission(permissions.updateTag)
@Put(':id')
@Delete(':id')
@HasPermission(permissions.deleteTag)
@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({
id
});
@ -54,20 +78,20 @@ export class TagController {
);
}
return this.tagService.updateTag({
data: {
...data
},
where: {
id
}
});
return this.tagService.deleteTag({ id });
}
@Delete(':id')
@HasPermission(permissions.deleteTag)
@Get()
@HasPermission(permissions.readTags)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteTag(@Param('id') id: string) {
public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@HasPermission(permissions.updateTag)
@Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
const originalTag = await this.tagService.getTag({
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 { 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 { 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 { 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 { Module } from '@nestjs/common';
import { BenchmarkController } from './benchmark.controller';
import { BenchmarkService } from './benchmark.service';
import { WatchlistController } from './watchlist.controller';
import { WatchlistService } from './watchlist.service';
@Module({
controllers: [BenchmarkController],
exports: [BenchmarkService],
controllers: [WatchlistController],
imports: [
ConfigurationModule,
BenchmarkModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
MarketDataModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
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 { 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 { Export } from '@ghostfolio/common/interfaces';
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 { AuthGuard } from '@nestjs/passport';
@ -19,22 +27,28 @@ export class ExportController {
@Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async export(
@Query('accounts') filterByAccounts?: string,
@Query('activityIds') activityIds?: string[],
@Query('activityIds') filterByActivityIds?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<Export> {
const activityIds = filterByActivityIds?.split(',') ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});
return this.exportService.export({
activityIds,
filters,
userCurrency: this.request.user.Settings.settings.baseCurrency,
userCurrency: this.request.user.settings.settings.baseCurrency,
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 { 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 { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
@ -8,8 +11,15 @@ import { ExportController } from './export.controller';
import { ExportService } from './export.service';
@Module({
imports: [AccountModule, ApiModule, OrderModule],
controllers: [ExportController],
imports: [
AccountModule,
ApiModule,
MarketDataModule,
OrderModule,
TagModule,
TransformDataSourceInRequestModule
],
providers: [ExportService]
})
export class ExportModule {}

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

@ -1,15 +1,21 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
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 { Injectable } from '@nestjs/common';
import { Platform, Prisma } from '@prisma/client';
import { groupBy, uniqBy } from 'lodash';
@Injectable()
export class ExportService {
public constructor(
private readonly accountService: AccountService,
private readonly orderService: OrderService
private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly tagService: TagService
) {}
public async export({
@ -23,55 +29,198 @@ export class ExportService {
userCurrency: string;
userId: string;
}): 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 = (
await this.accountService.accounts({
where,
include: {
balances: true,
platform: true
},
orderBy: {
name: 'asc'
},
where: { userId }
}
})
).map(
({ balance, comment, currency, id, isExcluded, name, platformId }) => {
return {
)
.filter(({ id }) => {
return activityIds?.length > 0
? activities.some(({ accountId }) => {
return accountId === id;
})
: true;
})
.map(
({
balance,
balances,
comment,
currency,
id,
isExcluded,
name,
platform,
platformId
};
}) => {
if (platformId) {
platformsMap[platformId] = platform;
}
return {
balance,
balances: balances.map(({ date, value }) => {
return { date: date.toISOString(), value };
}),
comment,
currency,
id,
isExcluded,
name,
platformId
};
}
);
const customAssetProfiles = uniqBy(
activities
.map(({ SymbolProfile }) => {
return SymbolProfile;
})
.filter(({ userId: assetProfileUserId }) => {
return assetProfileUserId === userId;
}),
({ id }) => {
return id;
}
);
let { activities } = await this.orderService.getOrders({
filters,
userCurrency,
userId,
includeDrafts: true,
sortColumn: 'date',
sortDirection: 'asc',
withExcludedAccounts: true
});
const marketDataByAssetProfile = Object.fromEntries(
await Promise.all(
customAssetProfiles.map(async ({ dataSource, id, symbol }) => {
const marketData = (
await this.marketDataService.marketDataItems({
where: { dataSource, symbol }
})
).map(({ date, marketPrice }) => ({
date: date.toISOString(),
marketPrice
}));
if (activityIds) {
activities = activities.filter((activity) => {
return activityIds.includes(activity.id);
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 {
meta: { date: new Date().toISOString(), version: environment.version },
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(
({
accountId,
comment,
currency,
date,
fee,
id,
quantity,
SymbolProfile,
tags: currentTags,
type,
unitPrice
}) => {
@ -83,16 +232,13 @@ export class ExportService {
quantity,
type,
unitPrice,
currency: SymbolProfile.currency,
currency: currency ?? SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
date: date.toISOString(),
symbol:
type === 'FEE' ||
type === 'INTEREST' ||
type === 'ITEM' ||
type === 'LIABILITY'
? SymbolProfile.name
: SymbolProfile.symbol
symbol: SymbolProfile.symbol,
tags: currentTags.map(({ id: tagId }) => {
return tagId;
})
};
}
),

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 {
DataEnhancerHealthResponse,
DataProviderHealthResponse
} from '@ghostfolio/common/interfaces';
import {
Controller,
Get,
HttpCode,
HttpException,
HttpStatus,
Param,
Res,
UseInterceptors
} from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { HealthService } from './health.service';
@ -19,29 +24,47 @@ export class HealthController {
public constructor(private readonly healthService: HealthService) {}
@Get()
@HttpCode(HttpStatus.OK)
public getHealth() {
return { status: getReasonPhrase(StatusCodes.OK) };
public async getHealth(@Res() response: Response) {
const databaseServiceHealthy = await this.healthService.isDatabaseHealthy();
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')
public async getHealthOfDataEnhancer(@Param('name') name: string) {
public async getHealthOfDataEnhancer(
@Param('name') name: string,
@Res() response: Response
): Promise<Response<DataEnhancerHealthResponse>> {
const hasResponse =
await this.healthService.hasResponseFromDataEnhancer(name);
if (hasResponse !== true) {
throw new HttpException(
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
StatusCodes.SERVICE_UNAVAILABLE
);
if (hasResponse) {
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-provider/:dataSource')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getHealthOfDataProvider(
@Param('dataSource') dataSource: DataSource
) {
@Param('dataSource') dataSource: DataSource,
@Res() response: Response
): Promise<Response<DataProviderHealthResponse>> {
if (!DataSource[dataSource]) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
@ -52,11 +75,14 @@ export class HealthController {
const hasResponse =
await this.healthService.hasResponseFromDataProvider(dataSource);
if (hasResponse !== true) {
throw new HttpException(
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
StatusCodes.SERVICE_UNAVAILABLE
);
if (hasResponse) {
return response
.status(HttpStatus.OK)
.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 { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.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';
@ -12,6 +14,8 @@ import { HealthService } from './health.service';
imports: [
DataEnhancerModule,
DataProviderModule,
PropertyModule,
RedisCacheModule,
TransformDataSourceInRequestModule
],
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 { 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 { DataSource } from '@prisma/client';
@ -8,7 +11,9 @@ import { DataSource } from '@prisma/client';
export class HealthService {
public constructor(
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) {
@ -18,4 +23,24 @@ export class HealthService {
public async hasResponseFromDataProvider(aDataSource: DataSource) {
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 { Type } from 'class-transformer';
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 {
@IsOptional()
@IsArray()
@Type(() => CreateAccountDto)
@IsOptional()
@Type(() => CreateAccountWithBalancesDto)
@ValidateNested({ each: true })
accounts: CreateAccountDto[];
accounts?: CreateAccountWithBalancesDto[];
@IsArray()
@Type(() => CreateOrderDto)
@ValidateNested({ each: true })
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({
isDryRun,
maxActivitiesToImport,
accountsDto: importData.accounts ?? [],
accountsWithBalancesDto: importData.accounts ?? [],
activitiesDto: importData.activities,
assetProfilesWithMarketDataDto: importData.assetProfiles ?? [],
user: this.request.user
});
@ -98,12 +99,10 @@ export class ImportController {
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<ImportResponse> {
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const activities = await this.importService.getDividends({
dataSource,
symbol,
userCurrency
userId: this.request.user.id
});
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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -27,6 +28,7 @@ import { ImportService } from './import.service';
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
OrderModule,
PlatformModule,
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
@ -29,10 +28,13 @@ import {
import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
import { uniqBy } from 'lodash';
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns';
import { omit, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { CreateAssetProfileDto } from '../admin/create-asset-profile.dto';
import { ImportDataDto } from './import-data.dto';
@Injectable()
export class ImportService {
public constructor(
@ -40,7 +42,7 @@ export class ImportService {
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly platformService: PlatformService,
private readonly portfolioService: PortfolioService,
@ -50,11 +52,16 @@ export class ImportService {
public async getDividends({
dataSource,
symbol,
userCurrency
}: AssetProfileIdentifier & { userCurrency: string }): Promise<Activity[]> {
userId
}: AssetProfileIdentifier & { userId: string }): Promise<Activity[]> {
try {
const { firstBuyDate, historicalData, orders } =
await this.portfolioService.getPosition(dataSource, undefined, symbol);
const { activities, firstBuyDate, historicalData } =
await this.portfolioService.getHolding({
dataSource,
symbol,
userId,
impersonationId: undefined
});
const [[assetProfile], dividends] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([
@ -72,15 +79,15 @@ export class ImportService {
})
]);
const accounts = orders
.filter(({ Account }) => {
return !!Account;
const accounts = activities
.filter(({ account }) => {
return !!account;
})
.map(({ Account }) => {
return Account;
.map(({ account }) => {
return account;
});
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
const account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
return await Promise.all(
Object.entries(dividends).map(async ([dateString, { marketPrice }]) => {
@ -92,9 +99,9 @@ export class ImportService {
const value = new Big(quantity).mul(marketPrice).toNumber();
const date = parseDate(dateString);
const isDuplicate = orders.some((activity) => {
const isDuplicate = activities.some((activity) => {
return (
activity.accountId === Account?.id &&
activity.accountId === account?.id &&
activity.SymbolProfile.currency === assetProfile.currency &&
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
isSameSecond(activity.date, date) &&
@ -110,17 +117,18 @@ export class ImportService {
: undefined;
return {
Account,
account,
date,
error,
quantity,
value,
accountId: Account?.id,
accountId: account?.id,
accountUserId: undefined,
comment: undefined,
currency: undefined,
createdAt: undefined,
fee: 0,
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
id: assetProfile.id,
isDraft: false,
@ -128,15 +136,10 @@ export class ImportService {
symbolProfileId: assetProfile.id,
type: 'DIVIDEND',
unitPrice: marketPrice,
unitPriceInAssetProfileCurrency: marketPrice,
updatedAt: undefined,
userId: Account?.userId,
valueInBaseCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
value,
assetProfile.currency,
userCurrency,
date
)
userId: account?.userId,
valueInBaseCurrency: value
};
})
);
@ -146,27 +149,30 @@ export class ImportService {
}
public async import({
accountsDto,
accountsWithBalancesDto,
activitiesDto,
assetProfilesWithMarketDataDto,
isDryRun = false,
maxActivitiesToImport,
user
}: {
accountsDto: Partial<CreateAccountDto>[];
activitiesDto: Partial<CreateOrderDto>[];
accountsWithBalancesDto: ImportDataDto['accounts'];
activitiesDto: ImportDataDto['activities'];
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
isDryRun?: boolean;
maxActivitiesToImport: number;
user: UserWithSettings;
}): Promise<Activity[]> {
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([
this.accountService.accounts({
where: {
id: {
in: accountsDto.map(({ id }) => {
in: accountsWithBalancesDto.map(({ id }) => {
return id;
})
}
@ -175,14 +181,19 @@ export class ImportService {
this.platformService.getPlatforms()
]);
for (const account of accountsDto) {
for (const accountWithBalances of accountsWithBalancesDto) {
// Check if there is any existing account with the same ID
const accountWithSameId = existingAccounts.find(
(existingAccount) => existingAccount.id === account.id
);
const accountWithSameId = existingAccounts.find((existingAccount) => {
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 (!accountWithSameId || accountWithSameId.userId !== user.id) {
const account: CreateAccountDto = omit(
accountWithBalances,
'balances'
);
let oldAccountId: string;
const platformId = account.platformId;
@ -195,7 +206,10 @@ export class ImportService {
let accountObject: Prisma.AccountCreateInput = {
...account,
User: { connect: { id: user.id } }
balances: {
create: accountWithBalances.balances ?? []
},
user: { connect: { id: user.id } }
};
if (
@ -205,7 +219,7 @@ export class ImportService {
) {
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) {
if (!activity.dataSource) {
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(activity.type)) {
if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) {
activity.dataSource = DataSource.MANUAL;
} else {
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 (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];
}
// 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) {
accountsDto.forEach(({ id, name }) => {
accountsWithBalancesDto.forEach(({ id, name }) => {
accounts.push({ id, name });
});
}
const activities: Activity[] = [];
for (const [index, activity] of activitiesExtendedWithErrors.entries()) {
for (const activity of activitiesExtendedWithErrors) {
const accountId = activity.accountId;
const comment = activity.comment;
const currency = activity.currency;
const date = activity.date;
const error = activity.error;
let fee = activity.fee;
const fee = activity.fee;
const quantity = activity.quantity;
const SymbolProfile = activity.SymbolProfile;
const type = activity.type;
let unitPrice = activity.unitPrice;
const unitPrice = activity.unitPrice;
const assetProfile = assetProfiles[
getAssetProfileIdentifier({
@ -284,7 +360,6 @@ export class ImportService {
symbol: SymbolProfile.symbol
})
] ?? {
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
@ -293,12 +368,14 @@ export class ImportService {
assetSubClass,
countries,
createdAt,
cusip,
dataSource,
figi,
figiComposite,
figiShareClass,
holdings,
id,
isActive,
isin,
name,
scraperConfiguration,
@ -318,35 +395,6 @@ export class ImportService {
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) {
order = {
comment,
@ -367,12 +415,14 @@ export class ImportService {
assetSubClass,
countries,
createdAt,
cusip,
dataSource,
figi,
figiComposite,
figiShareClass,
holdings,
id,
isActive,
isin,
name,
scraperConfiguration,
@ -396,6 +446,7 @@ export class ImportService {
order = await this.orderService.createOrder({
comment,
currency,
date,
fee,
quantity,
@ -419,7 +470,7 @@ export class ImportService {
}
},
updateAccountBalance: false,
User: { connect: { id: user.id } },
user: { connect: { id: user.id } },
userId: user.id
});
@ -435,21 +486,8 @@ export class ImportService {
...order,
error,
value,
feeInBaseCurrency: await this.exchangeRateDataService.toCurrencyAtDate(
fee,
assetProfile.currency,
userCurrency,
date
),
// @ts-ignore
SymbolProfile: assetProfile,
valueInBaseCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
value,
assetProfile.currency,
userCurrency,
date
)
SymbolProfile: assetProfile
});
}
@ -515,7 +553,9 @@ export class ImportService {
const isDuplicate = existingActivities.some((activity) => {
return (
activity.accountId === accountId &&
activity.SymbolProfile.currency === currency &&
activity.comment === comment &&
(activity.currency === currency ||
activity.SymbolProfile.currency === currency) &&
activity.SymbolProfile.dataSource === dataSource &&
isSameSecond(activity.date, date) &&
activity.fee === fee &&
@ -533,6 +573,7 @@ export class ImportService {
return {
accountId,
comment,
currency,
date,
error,
fee,
@ -540,7 +581,6 @@ export class ImportService {
type,
unitPrice,
SymbolProfile: {
currency,
dataSource,
symbol,
activitiesCount: undefined,
@ -548,8 +588,10 @@ export class ImportService {
assetSubClass: undefined,
countries: undefined,
createdAt: undefined,
currency: undefined,
holdings: undefined,
id: undefined,
isActive: true,
sectors: undefined,
updatedAt: undefined
}
@ -584,12 +626,18 @@ export class ImportService {
const assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {};
const dataSources = await this.dataProviderService.getDataSources();
const dataSources = await this.dataProviderService.getDataSources({ user });
for (const [
index,
{ currency, dataSource, symbol, type }
] of activitiesDto.entries()) {
if (type === 'ITEM') {
throw new Error(
`activities.${index}.type ("${type}") is deprecated, please use "BUY" instead`
);
}
if (!dataSources.includes(dataSource)) {
throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid`
@ -621,18 +669,16 @@ export class ImportService {
)?.[symbol]
};
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') {
if (
(dataSource !== 'MANUAL' && type === 'BUY') ||
type === 'DIVIDEND' ||
type === 'SELL'
) {
if (!assetProfile?.name) {
throw new Error(
`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 })] =

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

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

@ -26,12 +26,13 @@ export class LogoController {
@Res() response: Response
) {
try {
const buffer = await this.logoService.getLogoByDataSourceAndSymbol({
dataSource,
symbol
});
const { buffer, type } =
await this.logoService.getLogoByDataSourceAndSymbol({
dataSource,
symbol
});
response.contentType('image/png');
response.contentType(type);
response.send(buffer);
} catch {
response.status(HttpStatus.NOT_FOUND).send();
@ -44,9 +45,9 @@ export class LogoController {
@Res() response: Response
) {
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);
} catch {
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 { DataSource } from '@prisma/client';
import got from 'got';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable()
@ -29,7 +28,7 @@ export class LogoService {
{ dataSource, symbol }
]);
if (!assetProfile) {
if (!assetProfile?.url) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
@ -39,20 +38,26 @@ export class LogoService {
return this.getBuffer(assetProfile.url);
}
public async getLogoByUrl(aUrl: string) {
public getLogoByUrl(aUrl: string) {
return this.getBuffer(aUrl);
}
private getBuffer(aUrl: string) {
return got(
private async getBuffer(aUrl: string) {
const blob = await fetch(
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
{
headers: { 'User-Agent': 'request' },
// @ts-ignore
signal: AbortSignal.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()
accountId?: string;
@IsOptional()
@IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass;
@IsOptional()
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass;
@IsOptional()
@ -49,8 +49,8 @@ export class CreateOrderDto {
@IsOptional()
customCurrency?: string;
@IsEnum(DataSource)
@IsOptional()
@IsEnum(DataSource, { each: true })
dataSource?: DataSource;
@IsISO8601()

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

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

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

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

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

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

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

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

@ -4,12 +4,17 @@ import {
SymbolMetrics
} from '@ghostfolio/common/interfaces';
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 {
throw new Error('Method not implemented.');
}
protected getPerformanceCalculationType() {
return PerformanceCalculationType.MWR;
}
protected getSymbolMetrics({}: {
end: Date;
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,
createdAt: new Date(),
currency: undefined,
fee: undefined,
feeInAssetProfileCurrency: undefined,
feeInBaseCurrency: undefined,
id: undefined,
isDraft: false,
symbolProfileId: undefined,
unitPrice: undefined,
unitPriceInAssetProfileCurrency: undefined,
updatedAt: new Date(),
userId: undefined,
value: undefined,
@ -24,6 +28,7 @@ export const symbolProfileDummyData = {
createdAt: undefined,
holdings: [],
id: undefined,
isActive: true,
sectors: [],
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 { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Injectable } from '@nestjs/common';
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator';
import { MwrPortfolioCalculator } from './mwr/portfolio-calculator';
import { PortfolioCalculator } from './portfolio-calculator';
import { TWRPortfolioCalculator } from './twr/portfolio-calculator';
export enum PerformanceCalculationType {
MWR = 'MWR', // Money-Weighted Rate of Return
TWR = 'TWR' // Time-Weighted Rate of Return
}
import { RoaiPortfolioCalculator } from './roai/portfolio-calculator';
import { RoiPortfolioCalculator } from './roi/portfolio-calculator';
import { TwrPortfolioCalculator } from './twr/portfolio-calculator';
@Injectable()
export class PortfolioCalculatorFactory {
@ -44,7 +42,7 @@ export class PortfolioCalculatorFactory {
}): PortfolioCalculator {
switch (calculationType) {
case PerformanceCalculationType.MWR:
return new MWRPortfolioCalculator({
return new MwrPortfolioCalculator({
accountBalanceItems,
activities,
currency,
@ -56,19 +54,49 @@ export class PortfolioCalculatorFactory {
portfolioSnapshotService: this.portfolioSnapshotService,
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,
activities,
currency,
filters,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.TWR:
return new TwrPortfolioCalculator({
accountBalanceItems,
activities,
currency,
filters,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
});
default:
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 { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
INVESTMENT_ACTIVITY_TYPES,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH,
@ -35,6 +36,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { GroupBy } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
@ -49,7 +51,7 @@ import {
min,
subDays
} from 'date-fns';
import { first, isNumber, last, sortBy, sum, uniq, uniqBy } from 'lodash';
import { isNumber, sortBy, sum, uniqBy } from 'lodash';
export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false;
@ -112,12 +114,12 @@ export abstract class PortfolioCalculator {
.map(
({
date,
fee,
feeInAssetProfileCurrency,
quantity,
SymbolProfile,
tags = [],
type,
unitPrice
unitPriceInAssetProfileCurrency
}) => {
if (isBefore(date, dateOfFirstActivity)) {
dateOfFirstActivity = date;
@ -134,9 +136,9 @@ export abstract class PortfolioCalculator {
tags,
type,
date: format(date, DATE_FORMAT),
fee: new Big(fee),
fee: new Big(feeInAssetProfileCurrency),
quantity: new Big(quantity),
unitPrice: new Big(unitPrice)
unitPrice: new Big(unitPriceInAssetProfileCurrency)
};
}
)
@ -167,7 +169,7 @@ export abstract class PortfolioCalculator {
@LogPerformance
public async computeSnapshot(): Promise<PortfolioSnapshot> {
const lastTransactionPoint = last(this.transactionPoints);
const lastTransactionPoint = this.transactionPoints.at(-1);
const transactionPoints = this.transactionPoints?.filter(({ date }) => {
return isBefore(parseDate(date), this.endDate);
@ -175,6 +177,8 @@ export abstract class PortfolioCalculator {
if (!transactionPoints.length) {
return {
activitiesCount: 0,
createdAt: new Date(),
currentValueInBaseCurrency: new Big(0),
errors: [],
hasErrors: false,
@ -184,8 +188,7 @@ export abstract class PortfolioCalculator {
totalInterestWithCurrencyEffect: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilitiesWithCurrencyEffect: new Big(0),
totalValuablesWithCurrencyEffect: new Big(0)
totalLiabilitiesWithCurrencyEffect: new Big(0)
};
}
@ -195,7 +198,6 @@ export abstract class PortfolioCalculator {
let firstTransactionPoint: TransactionPoint = null;
let totalInterestWithCurrencyEffect = new Big(0);
let totalLiabilitiesWithCurrencyEffect = new Big(0);
let totalValuablesWithCurrencyEffect = new Big(0);
for (const { currency, dataSource, symbol } of transactionPoints[
firstIndex - 1
@ -220,7 +222,7 @@ export abstract class PortfolioCalculator {
const exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: uniq(Object.values(currencies)),
currencies: Array.from(new Set(Object.values(currencies))),
endDate: endOfDay(this.endDate),
startDate: this.startDate,
targetCurrency: this.currency
@ -286,10 +288,12 @@ export abstract class PortfolioCalculator {
firstIndex--;
}
const positions: TimelinePosition[] = [];
const errors: ResponseError['errors'] = [];
let hasAnySymbolMetricsErrors = false;
const errors: ResponseError['errors'] = [];
const positions: (TimelinePosition & {
includeInHoldings: boolean;
})[] = [];
const accumulatedValuesByDate: {
[date: string]: {
@ -361,8 +365,7 @@ export abstract class PortfolioCalculator {
totalInterestInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect,
totalLiabilitiesInBaseCurrency,
totalValuablesInBaseCurrency
totalLiabilitiesInBaseCurrency
} = this.getSymbolMetrics({
chartDateMap,
marketSymbolMap,
@ -409,6 +412,7 @@ export abstract class PortfolioCalculator {
grossPerformanceWithCurrencyEffect: !hasErrors
? (grossPerformanceWithCurrencyEffect ?? null)
: null,
includeInHoldings: item.includeInHoldings,
investment: totalInvestment,
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
marketPrice:
@ -441,16 +445,13 @@ export abstract class PortfolioCalculator {
totalLiabilitiesWithCurrencyEffect =
totalLiabilitiesWithCurrencyEffect.plus(totalLiabilitiesInBaseCurrency);
totalValuablesWithCurrencyEffect = totalValuablesWithCurrencyEffect.plus(
totalValuablesInBaseCurrency
);
if (
(hasErrors ||
currentRateErrors.find(({ dataSource, 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 });
}
@ -594,7 +595,6 @@ export abstract class PortfolioCalculator {
netPerformance: totalNetPerformanceValue.toNumber(),
netPerformanceWithCurrencyEffect:
totalNetPerformanceValueWithCurrencyEffect.toNumber(),
// TODO: Add valuables
netWorth: totalCurrentValueWithCurrencyEffect
.plus(totalAccountBalanceWithCurrencyEffect)
.toNumber(),
@ -609,18 +609,28 @@ export abstract class PortfolioCalculator {
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 {
...overall,
errors,
historicalData,
positions,
totalInterestWithCurrencyEffect,
totalLiabilitiesWithCurrencyEffect,
totalValuablesWithCurrencyEffect,
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors,
positions: positionsIncludedInHoldings
};
}
protected abstract getPerformanceCalculationType(): PerformanceCalculationType;
public getDataProviderInfos() {
return this.dataProviderInfos;
}
@ -749,7 +759,7 @@ export abstract class PortfolioCalculator {
? 0
: netPerformanceWithCurrencyEffectSinceStartDate /
timeWeightedInvestmentValue
// TODO: Add net worth with valuables
// TODO: Add net worth
// netWorth: totalCurrentValueWithCurrencyEffect
// .plus(totalAccountBalanceWithCurrencyEffect)
// .toNumber()
@ -772,9 +782,7 @@ export abstract class PortfolioCalculator {
let firstActivityDate: Date;
try {
const firstAccountBalanceDateString = first(
this.accountBalanceItems
)?.date;
const firstAccountBalanceDateString = this.accountBalanceItems[0]?.date;
firstAccountBalanceDate = firstAccountBalanceDateString
? parseDate(firstAccountBalanceDateString)
: new Date();
@ -816,12 +824,6 @@ export abstract class PortfolioCalculator {
return this.transactionPoints;
}
public async getValuablesInBaseCurrency() {
await this.snapshotPromise;
return this.snapshot.totalValuablesWithCurrencyEffect;
}
private getChartDateMap({
endDate,
startDate,
@ -899,8 +901,8 @@ export abstract class PortfolioCalculator {
let lastTransactionPoint: TransactionPoint = null;
for (const {
fee,
date,
fee,
quantity,
SymbolProfile,
tags,
@ -908,9 +910,14 @@ export abstract class PortfolioCalculator {
unitPrice
} of this.activities) {
let currentTransactionPointItem: TransactionPointSymbol;
const oldAccumulatedSymbol = symbols[SymbolProfile.symbol];
const currency = SymbolProfile.currency;
const dataSource = SymbolProfile.dataSource;
const factor = getFactor(type);
const skipErrors = !!SymbolProfile.userId; // Skip errors for custom asset profiles
const symbol = SymbolProfile.symbol;
const oldAccumulatedSymbol = symbols[symbol];
if (oldAccumulatedSymbol) {
let investment = oldAccumulatedSymbol.investment;
@ -930,32 +937,36 @@ export abstract class PortfolioCalculator {
}
currentTransactionPointItem = {
currency,
dataSource,
investment,
skipErrors,
symbol,
averagePrice: newQuantity.gt(0)
? investment.div(newQuantity)
: new Big(0),
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
dividend: new Big(0),
fee: oldAccumulatedSymbol.fee.plus(fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
includeInHoldings: oldAccumulatedSymbol.includeInHoldings,
quantity: newQuantity,
symbol: SymbolProfile.symbol,
tags: oldAccumulatedSymbol.tags.concat(tags),
transactionCount: oldAccumulatedSymbol.transactionCount + 1
};
} else {
currentTransactionPointItem = {
currency,
dataSource,
fee,
skipErrors,
symbol,
tags,
averagePrice: unitPrice,
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
dividend: new Big(0),
firstBuyDate: date,
includeInHoldings: INVESTMENT_ACTIVITY_TYPES.includes(type),
investment: unitPrice.mul(quantity).mul(factor),
quantity: quantity.mul(factor),
symbol: SymbolProfile.symbol,
transactionCount: 1
};
}
@ -997,19 +1008,12 @@ export abstract class PortfolioCalculator {
liabilities = quantity.mul(unitPrice);
}
let valuables = new Big(0);
if (type === 'ITEM') {
valuables = quantity.mul(unitPrice);
}
if (lastDate !== date || lastTransactionPoint === null) {
lastTransactionPoint = {
date,
fees,
interest,
liabilities,
valuables,
items: newItems
};
@ -1021,8 +1025,6 @@ export abstract class PortfolioCalculator {
lastTransactionPoint.items = newItems;
lastTransactionPoint.liabilities =
lastTransactionPoint.liabilities.plus(liabilities);
lastTransactionPoint.valuables =
lastTransactionPoint.valuables.plus(valuables);
}
lastDate = date;
@ -1073,6 +1075,7 @@ export abstract class PortfolioCalculator {
// Compute in the background
this.portfolioSnapshotService.addJobToQueue({
data: {
calculationType: this.getPerformanceCalculationType(),
filters: this.filters,
userCurrency: this.currency,
userId: this.userId
@ -1089,6 +1092,7 @@ export abstract class PortfolioCalculator {
// Wait for computation
await this.portfolioSnapshotService.addJobToQueue({
data: {
calculationType: this.getPerformanceCalculationType(),
filters: this.filters,
userCurrency: this.currency,
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,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -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 { 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 { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -92,7 +89,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2021-11-22'),
fee: 1.55,
feeInAssetProfileCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -102,12 +99,12 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: 142.9
unitPriceInAssetProfileCurrency: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.65,
feeInAssetProfileCurrency: 1.65,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -117,12 +114,12 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: 136.6
unitPriceInAssetProfileCurrency: 136.6
},
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 0,
feeInAssetProfileCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -132,13 +129,13 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: 136.6
unitPriceInAssetProfileCurrency: 136.6
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
@ -197,11 +194,10 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: -15.8,
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,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -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 { 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 { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -92,7 +89,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2021-11-22'),
fee: 1.55,
feeInAssetProfileCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -102,12 +99,12 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: 142.9
unitPriceInAssetProfileCurrency: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.65,
feeInAssetProfileCurrency: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -117,13 +114,13 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: 136.6
unitPriceInAssetProfileCurrency: 136.6
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
@ -182,11 +179,10 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: -15.8,
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,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -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 { 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 { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -92,7 +89,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.55,
feeInAssetProfileCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -102,13 +99,13 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: 136.6
unitPriceInAssetProfileCurrency: 136.6
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
@ -173,11 +170,10 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('273.2'),
totalInvestmentWithCurrencyEffect: new Big('273.2'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 23.05,
netPerformanceInPercentage: 0.08437042459736457,
@ -196,5 +192,83 @@ describe('PortfolioCalculator', () => {
{ 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,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -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 { 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 { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -106,7 +103,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2015-01-01'),
fee: 0,
feeInAssetProfileCurrency: 0,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -116,12 +113,12 @@ describe('PortfolioCalculator', () => {
symbol: 'BTCUSD'
},
type: 'BUY',
unitPrice: 320.43
unitPriceInAssetProfileCurrency: 320.43
},
{
...activityDummyData,
date: new Date('2017-12-31'),
fee: 0,
feeInAssetProfileCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -131,13 +128,13 @@ describe('PortfolioCalculator', () => {
symbol: 'BTCUSD'
},
type: 'SELL',
unitPrice: 14156.4
unitPriceInAssetProfileCurrency: 14156.4
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
@ -201,11 +198,10 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('320.43').mul(0.97373),
totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: new Big('27172.74').mul(0.97373).toNumber(),
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,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -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 { 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 { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -92,7 +89,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2021-09-01'),
fee: 49,
feeInAssetProfileCurrency: 49,
quantity: 0,
SymbolProfile: {
...symbolProfileDummyData,
@ -102,13 +99,13 @@ describe('PortfolioCalculator', () => {
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141'
},
type: 'FEE',
unitPrice: 0
unitPriceInAssetProfileCurrency: 0
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});
@ -119,46 +116,15 @@ describe('PortfolioCalculator', () => {
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: true,
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')
}
],
positions: [],
totalFeesWithCurrencyEffect: new Big('49'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 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,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -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 { 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 { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -105,7 +102,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2023-01-03'),
fee: 1,
feeInAssetProfileCurrency: 1,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -115,13 +112,13 @@ describe('PortfolioCalculator', () => {
symbol: 'GOOGL'
},
type: 'BUY',
unitPrice: 89.12
unitPriceInAssetProfileCurrency: 89.12
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
@ -180,11 +177,10 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('89.12').mul(0.8854),
totalInvestmentWithCurrencyEffect: new Big('82.329056'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: new Big('26.33').mul(0.8854).toNumber(),
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,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -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 { 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';
@ -91,7 +89,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2023-01-01'), // Date in future
fee: 0,
feeInAssetProfileCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -101,13 +99,13 @@ describe('PortfolioCalculator', () => {
symbol: '55196015-1365-4560-aa60-8751ae6d18f8'
},
type: 'LIABILITY',
unitPrice: 3000
unitPriceInAssetProfileCurrency: 3000
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
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,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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 { 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 { 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 { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
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', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -105,7 +89,7 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2021-09-16'),
fee: 19,
feeInAssetProfileCurrency: 19,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -115,12 +99,12 @@ describe('PortfolioCalculator', () => {
symbol: 'MSFT'
},
type: 'BUY',
unitPrice: 298.58
unitPriceInAssetProfileCurrency: 298.58
},
{
...activityDummyData,
date: new Date('2021-11-16'),
fee: 0,
feeInAssetProfileCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -130,13 +114,13 @@ describe('PortfolioCalculator', () => {
symbol: 'MSFT'
},
type: 'DIVIDEND',
unitPrice: 0.62
unitPriceInAssetProfileCurrency: 0.62
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});
@ -186,11 +170,10 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('298.58'),
totalInvestmentWithCurrencyEffect: new Big('298.58'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
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 {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -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 { 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';
@ -84,7 +82,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities: [],
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
@ -107,8 +105,7 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
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,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -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 { 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 { last } from 'lodash';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
@ -68,7 +65,7 @@ describe('PortfolioCalculator', () => {
activityDtos = loadActivityExportFile(
join(
__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,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
}
},
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
@ -178,11 +177,10 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('75.80'),
totalInvestmentWithCurrencyEffect: new Big('75.80'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 17.68,
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,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -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 { 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 { last } from 'lodash';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
@ -68,7 +65,7 @@ describe('PortfolioCalculator', () => {
activityDtos = loadActivityExportFile(
join(
__dirname,
'../../../../../../../test/import/ok-novn-buy-and-sell.json'
'../../../../../../../test/import/ok/novn-buy-and-sell.json'
)
);
});
@ -106,18 +103,20 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
}
},
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
@ -146,19 +145,23 @@ describe('PortfolioCalculator', () => {
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({
date: '2022-03-07',
investmentValueWithCurrencyEffect: 151.6,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 151.6,
netPerformance: 24, // 2 * (87.8 - 75.8) = 24
netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
netPerformanceWithCurrencyEffect: 24,
netWorth: 175.6, // 2 * 87.8 = 175.6
totalAccountBalance: 0,
totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6,
value: 151.6,
valueWithCurrencyEffect: 151.6
value: 175.6, // 2 * 87.8 = 175.6
valueWithCurrencyEffect: 175.6
});
expect(
@ -225,11 +228,10 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 19.86,
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,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
@ -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 { 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 { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -85,14 +82,14 @@ describe('PortfolioCalculator', () => {
});
describe('compute portfolio snapshot', () => {
it.only('with item activity', async () => {
it.only('with valuable activity', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-31').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2022-01-01'),
fee: 0,
feeInAssetProfileCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -101,14 +98,14 @@ describe('PortfolioCalculator', () => {
name: 'Penthouse Apartment',
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
},
type: 'ITEM',
unitPrice: 500000
type: 'BUY',
unitPriceInAssetProfileCurrency: 500000
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});
@ -116,9 +113,9 @@ describe('PortfolioCalculator', () => {
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
currentValueInBaseCurrency: new Big('500000'),
errors: [],
hasErrors: true,
hasErrors: false,
positions: [
{
averagePrice: new Big('500000'),
@ -129,42 +126,45 @@ describe('PortfolioCalculator', () => {
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-01-01',
grossPerformance: null,
grossPerformancePercentage: null,
grossPerformancePercentageWithCurrencyEffect: null,
grossPerformanceWithCurrencyEffect: null,
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
grossPerformance: new Big('0'),
grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('0'),
investment: new Big('500000'),
investmentWithCurrencyEffect: new Big('500000'),
marketPrice: null,
marketPriceInBaseCurrency: 500000,
netPerformance: null,
netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffectMap: null,
netPerformanceWithCurrencyEffectMap: null,
quantity: new Big('0'),
netPerformance: new Big('0'),
netPerformancePercentage: new Big('0'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('0')
},
quantity: new Big('1'),
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde',
tags: [],
timeWeightedInvestment: new Big('0'),
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
timeWeightedInvestment: new Big('500000'),
timeWeightedInvestmentWithCurrencyEffect: new Big('500000'),
transactionCount: 1,
valueInBaseCurrency: new Big('0')
valueInBaseCurrency: new Big('500000')
}
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
totalInvestment: new Big('500000'),
totalInvestmentWithCurrencyEffect: new Big('500000'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(last(portfolioSnapshot.historicalData)).toMatchObject(
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 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.');
}
}

958
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 { 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 { PortfolioSnapshot } from '@ghostfolio/common/models';
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, 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) {
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;
}
}
export class TwrPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot {
throw new Error('Method not implemented.');
}
return {
currentValueInBaseCurrency,
hasErrors,
positions,
totalFeesWithCurrencyEffect,
totalInterestWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
errors: [],
historicalData: [],
totalLiabilitiesWithCurrencyEffect: new Big(0),
totalValuablesWithCurrencyEffect: new Big(0)
};
protected getPerformanceCalculationType() {
return PerformanceCalculationType.TWR;
}
protected getSymbolMetrics({
chartDateMap,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
}: {
chartDateMap?: { [date: string]: boolean };
protected getSymbolMetrics({}: {
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
step?: number;
} & 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 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
};
throw new Error('Method not implemented.');
}
}

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

Loading…
Cancel
Save