diff --git a/.github/workflows/docker-image-branch.yml b/.github/workflows/docker-image-branch.yml new file mode 100644 index 000000000..988332c3f --- /dev/null +++ b/.github/workflows/docker-image-branch.yml @@ -0,0 +1,47 @@ +name: Docker image CD - Branch + +on: + push: + branches: + - '*' + +jobs: + build_and_push: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v4 + with: + images: dandevaud/ghostfolio + tags: | + type=semver,pattern={{major}} + type=semver,pattern={{version}} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to DockerHub + if: github.event_name != 'pull_request' + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64,linux/arm/v7,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: dandevaud/ghostfolio:${{ github.ref_name }} + labels: ${{ steps.meta.output.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/CHANGELOG.md b/CHANGELOG.md index bf3c80c48..7b3c99d40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,340 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 2.80.0 - 2024-05-08 + +### Added + +- Added the absolute change column to the holdings table on the home page + +### Changed + +- Increased the spacing around the floating action buttons (FAB) +- Set the icon column of the activities table to stick at the beginning +- Set the icon column of the holdings table to stick at the beginning +- Increased the number of attempts of queue jobs from `10` to `12` (fail later) +- Upgraded `ionicons` from version `7.3.0` to `7.4.0` + +### Fixed + +- Fixed the position detail dialog open functionality when searching for a holding in the assistant + +## 2.79.0 - 2024-05-04 + +### Changed + +- Moved the holdings table to the holdings tab of the home page +- Improved the performance labels (with and without currency effects) in the position detail dialog +- Optimized the calculations of the portfolio details endpoint + +### Fixed + +- Fixed an issue with the benchmarks in the markets overview +- Fixed an issue with the _Fear & Greed Index_ (market mood) in the markets overview + +## 2.78.0 - 2024-05-02 + +### Added + +- Added a form validation against the DTO in the create or update access dialog +- Added a form validation against the DTO in the asset profile details dialog of the admin control +- Added a form validation against the DTO in the platform management of the admin control panel +- Added a form validation against the DTO in the tag management of the admin control panel + +### Changed + +- Set the performance column of the holdings table to stick at the end +- Skipped the caching in the portfolio calculator if there are active filters (experimental) +- Improved the `INACTIVE` user role + +### Fixed + +- Fixed an issue in the calculation of the portfolio summary caused by future liabilities +- Fixed a division by zero error in the dividend yield calculation (experimental) + +## 2.77.1 - 2024-04-27 + +### Added + +- Extended the content of the _Self-Hosting_ section by the custom asset instructions on the Frequently Asked Questions (FAQ) page +- Added the caching to the portfolio calculator (experimental) + +### Changed + +- Migrated the `@ghostfolio/ui` components to control flow +- Updated the browserslist database +- Upgraded `prisma` from version `5.12.1` to `5.13.0` + +### Fixed + +- Fixed the form submit in the asset profile details dialog of the admin control due to the `url` validation +- Fixed the historical market data gathering for asset profiles with `MANUAL` data source + +## 2.76.0 - 2024-04-23 + +### Changed + +- Changed `CASH` to `LIQUIDITY` in the asset class enum + +## 2.75.1 - 2024-04-21 + +### Added + +- Added `accountId` and `date` as a unique constraint to the `AccountBalance` database schema + +### Changed + +- Improved the chart in the account detail dialog +- Improved the account balance management + +### Fixed + +- Fixed an issue with `totalValueInBaseCurrency` in the value redaction interceptor for the impersonation mode + +## 2.74.0 - 2024-04-20 + +### Added + +- Added the date range support to the portfolio holdings page +- Added support to create an account balance + +### Changed + +- Removed the date range support in the activities table on the portfolio activities page (experimental) +- Improved the language localization for German (`de`) +- Upgraded `angular` from version `17.3.3` to `17.3.5` +- Upgraded `Nx` from version `18.2.3` to `18.3.3` + +### Fixed + +- Fixed gaps in the portfolio performance charts by considering `BUY` and `SELL` activities + +## 2.73.0 - 2024-04-17 + +### Added + +- Added a form validation against the DTO in the create or update account dialog +- Added a form validation against the DTO in the create or update activity dialog + +### Changed + +- Moved the dividend calculations into the portfolio calculator +- Moved the fee calculations into the portfolio calculator +- Moved the interest calculations into the portfolio calculator +- Moved the liability calculations into the portfolio calculator +- Moved the (wealth) item calculations into the portfolio calculator +- Let queue jobs for asset profile data gathering fail by throwing an error +- Let queue jobs for historical market data gathering fail by throwing an error +- Upgraded `yahoo-finance2` from version `2.11.1` to `2.11.2` + +## 2.72.0 - 2024-04-13 + +### Added + +- Added support to immediately execute a queue job from the admin control panel +- Added a priority column to the queue jobs view in the admin control panel + +### Changed + +- Adapted the priorities of queue jobs +- Upgraded `angular` from version `17.2.4` to `17.3.3` +- Upgraded `Nx` from version `18.1.2` to `18.2.3` +- Upgraded `prisma` from version `5.11.0` to `5.12.1` +- Upgraded `yahoo-finance2` from version `2.11.0` to `2.11.1` + +### Fixed + +- Fixed an issue in the public page + +## 2.71.0 - 2024-04-07 + +### Added + +- Added the dividend yield to the position detail dialog (experimental) +- Added support to override the asset class of an asset profile in the asset profile details dialog of the admin control +- Added support to override the asset sub class of an asset profile in the asset profile details dialog of the admin control +- Added support to override the url of an asset profile in the asset profile details dialog of the admin control +- Added the asset profile icon to the asset profile details dialog of the admin control +- Added the platform icon to the create or update platform dialog of the admin control +- Extended the rules in the _X-ray_ section by a `key` +- Added `currency` to the `Order` database schema as a preparation to set a custom currency +- Extended the content of the _Self-Hosting_ section by the data providers on the Frequently Asked Questions (FAQ) page + +### Changed + +- Optimized the calculation of allocations by market +- Improved the url validation in the create and update platform endpoint +- Improved the language localization for German (`de`) + +### Fixed + +- Fixed the missing tags in the portfolio calculations + +## 2.70.0 - 2024-04-02 + +### Added + +- Set up the language localization for Chinese (`zh`) +- Added `init: true` to the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`) to avoid zombie processes +- Set up _Webpack Bundle Analyzer_ + +### Changed + +- Disabled the option to update the cash balance of an account if date is not today +- Improved the usability of the date range support by specific years (`2023`, `2022`, `2021`, etc.) in the assistant (experimental) +- Introduced a factory for the portfolio calculations to support different algorithms in future + +### Fixed + +- Fixed the duplicated tags in the position detail dialog +- Removed `Tini` from the docker image + +## 2.69.0 - 2024-03-30 + +### Added + +- Added the date range support in the activities table on the portfolio activities page (experimental) +- Extended the date range support by specific years (`2021`, `2022`, `2023`, etc.) in the assistant (experimental) +- Set up `Tini` to avoid zombie processes and perform signal forwarding in docker image + +### Changed + +- Improved the usability to delete an asset profile in the historical market data table and the asset profile details dialog of the admin control + +### Fixed + +- Added missing dates to edit historical market data in the asset profile details dialog of the admin control panel + +## 2.68.0 - 2024-03-29 + +### Added + +- Extended the export functionality by the user account’s currency +- Added support to override the name of an asset profile in the asset profile details dialog of the admin control + +### Changed + +- Optimized the portfolio calculations + +### Fixed + +- Fixed the chart tooltip of the benchmark comparator +- Fixed an issue with names in the activities table on the portfolio activities page while using symbol profile overrides + +## 2.67.0 - 2024-03-26 + +### Added + +- Added support for the cryptocurrency _Toncoin_ (`TON11419-USD`) + +### Changed + +- Replaced `Math.random()` with `crypto.randomBytes()` for generating cryptographically secure random strings +- Upgraded `ionicons` from version `7.1.0` to `7.3.0` +- Upgraded `yahoo-finance2` from version `2.10.0` to `2.11.0` +- Upgraded `zone.js` from version `0.14.3` to `0.14.4` + +## 2.66.3 - 2024-03-23 + +### Added + +- Extended the content of the _SaaS_ and _Self-Hosting_ sections by the backup strategy on the Frequently Asked Questions (FAQ) page +- Added an index for `dataSource` / `symbol` to the market data database table + +### Changed + +- Improved the chart tooltip of the benchmark comparator by adding the benchmark name +- Upgraded `angular` from version `17.1.3` to `17.2.4` +- Upgraded `Nx` from version `18.0.4` to `18.1.2` + +### Fixed + +- Fixed the missing portfolio performance chart in the _Presenter View_ / _Zen Mode_ + +## 2.65.0 - 2024-03-19 + +### Added + +- Added the symbol and ISIN number to the position detail dialog +- Added support to delete an asset profile in the asset profile details dialog of the admin control + +### Changed + +- Moved the support to grant private access with permissions from experimental to general availability +- Set the meta theme color dynamically to respect the appearance (dark mode) +- Improved the usability to edit market data in the admin control panel + +## 2.64.0 - 2024-03-16 + +### Added + +- Added a toggle to switch between active and closed holdings on the portfolio holdings page +- Added support to update the cash balance of an account when adding a fee activity +- Added support to update the cash balance of an account when adding an interest activity +- Extended the content of the _General_ section by the product roadmap on the Frequently Asked Questions (FAQ) page + +### Changed + +- Improved the usability of the platform management in the admin control panel +- Improved the usability of the tag management in the admin control panel +- Improved the exception handling of various rules in the _X-ray_ section +- Increased the timeout to load benchmarks +- Upgraded `prisma` from version `5.10.2` to `5.11.0` + +### Fixed + +- Fixed an issue in the dividend calculation of the portfolio holdings +- Fixed the date conversion of the import of historical market data in the admin control panel + +## 2.63.2 - 2024-03-12 + +### Added + +- Extended the content of the _Self-Hosting_ section by available home server systems on the Frequently Asked Questions (FAQ) page +- Added support for the cryptocurrency _Real Smurf Cat_ (`SMURFCAT-USD`) + +### Changed + +- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `8.3` to `9.0` +- Upgraded `countries-list` from version `2.6.1` to `3.1.0` +- Upgraded `yahoo-finance2` from version `2.9.1` to `2.10.0` + +### Fixed + +- Fixed an issue in the performance calculation caused by multiple `SELL` activities on the same day +- Fixed an issue in the calculation on the allocations page caused by liabilities +- Fixed an issue with the currency in the request to get quotes from _EOD Historical Data_ + +## 2.62.0 - 2024-03-09 + +### Changed + +- Optimized the calculation of the accounts table +- Optimized the calculation of the portfolio holdings +- Integrated dividend into the transaction point concept in the portfolio service +- Removed the environment variable `WEB_AUTH_RP_ID` + +### Fixed + +- Fixed an issue in the calculation of the portfolio summary caused by future liabilities +- Fixed an issue with removing a linked account from a (wealth) item activity + +## 2.61.1 - 2024-03-06 + +### Fixed + +- Fixed an issue in the account value calculation caused by liabilities + +## 2.61.0 - 2024-03-04 ### Changed - Optimized the calculation of the portfolio summary +### Fixed + +- Fixed the activities import (query parameter handling) + ## 2.60.0 - 2024-03-02 ### Added diff --git a/Dockerfile b/Dockerfile index 1a557b8c4..f9396d0e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -57,6 +57,7 @@ RUN apt update && apt install -y \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps +COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh WORKDIR /ghostfolio/apps/api EXPOSE ${PORT:-3333} -CMD [ "yarn", "start:production" ] +CMD [ "/ghostfolio/entrypoint.sh" ] diff --git a/README.md b/README.md index c82ad50c3..eaf641544 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,12 @@ **Open Source Wealth Management Software** [**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) | -[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_) +[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://twitter.com/ghostfolio_) [![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio) [![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing) [![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) -New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2) - **Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation. @@ -87,23 +85,23 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c ### Supported Environment Variables -| Name | Default Value | Description | -| ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens | -| `API_KEY_COINGECKO_DEMO` |   | The _CoinGecko_ Demo API key | -| `API_KEY_COINGECKO_PRO` |   | The _CoinGecko_ Pro API | -| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | -| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on | -| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) | -| `PORT` | `3333` | The port where the Ghostfolio application will run on | -| `POSTGRES_DB` | | The name of the _PostgreSQL_ database | -| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database | -| `POSTGRES_USER` | | The user of the _PostgreSQL_ database | -| `REDIS_DB` | `0` | The database index of _Redis_ | -| `REDIS_HOST` | | The host where _Redis_ is running | -| `REDIS_PASSWORD` | | The password of _Redis_ | -| `REDIS_PORT` | | The port where _Redis_ is running | -| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds | +| Name | Type | Default Value | Description | +| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `ACCESS_TOKEN_SALT` | string | | A random string used as salt for access tokens | +| `API_KEY_COINGECKO_DEMO` | string (`optional`) |   | The _CoinGecko_ Demo API key | +| `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API | +| `DATABASE_URL` | string | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | +| `HOST` | string (`optional`) | `0.0.0.0` | The host where the Ghostfolio application will run on | +| `JWT_SECRET_KEY` | string | | A random string used for _JSON Web Tokens_ (JWT) | +| `PORT` | number (`optional`) | `3333` | The port where the Ghostfolio application will run on | +| `POSTGRES_DB` | string | | The name of the _PostgreSQL_ database | +| `POSTGRES_PASSWORD` | string | | The password of the _PostgreSQL_ database | +| `POSTGRES_USER` | string | | The user of the _PostgreSQL_ database | +| `REDIS_DB` | number (`optional`) | `0` | The database index of _Redis_ | +| `REDIS_HOST` | string | | The host where _Redis_ is running | +| `REDIS_PASSWORD` | string | | The password of _Redis_ | +| `REDIS_PORT` | number | | The port where _Redis_ is running | +| `REQUEST_TIMEOUT` | number (`optional`) | `2000` | The timeout of network requests to data providers in milliseconds | ### Run with Docker Compose @@ -144,7 +142,7 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d ### Home Server Systems (Community) -Ghostfolio is available for various home server systems, including [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio). +Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Home Assistant](https://github.com/lildude/ha-addon-ghostfolio), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio). ## Development @@ -154,7 +152,7 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt - [Node.js](https://nodejs.org/en/download) (version 18+) - [Yarn](https://yarnpkg.com/en/docs/install) - Create a local copy of this Git repository (clone) -- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`) +- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`) ### Setup @@ -206,7 +204,7 @@ Set the header for each request as follows: "Authorization": "Bearer eyJh..." ``` -You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ accessToken: }`) +You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ "accessToken": "" }`) Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/` or `curl -s http://localhost:3333/api/v1/auth/anonymous/`. diff --git a/apps/api/jest.config.ts b/apps/api/jest.config.ts index 8152c3f2a..b87f91a79 100644 --- a/apps/api/jest.config.ts +++ b/apps/api/jest.config.ts @@ -13,7 +13,6 @@ export default { }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../coverage/apps/api', - testTimeout: 10000, testEnvironment: 'node', preset: '../../jest.preset.js' }; diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index eb2116e3c..8444a88d1 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -83,7 +83,7 @@ export class AccessController { } try { - return await this.accessService.createAccess({ + return this.accessService.createAccess({ alias: data.alias || undefined, GranteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } diff --git a/apps/api/src/app/account-balance/account-balance.controller.ts b/apps/api/src/app/account-balance/account-balance.controller.ts index 943d0aeb5..bc454c5ab 100644 --- a/apps/api/src/app/account-balance/account-balance.controller.ts +++ b/apps/api/src/app/account-balance/account-balance.controller.ts @@ -1,3 +1,4 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { permissions } from '@ghostfolio/common/permissions'; @@ -5,6 +6,8 @@ import type { RequestWithUser } from '@ghostfolio/common/types'; import { Controller, + Body, + Post, Delete, HttpException, Inject, @@ -17,14 +20,44 @@ import { AccountBalance } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AccountBalanceService } from './account-balance.service'; +import { CreateAccountBalanceDto } from './create-account-balance.dto'; @Controller('account-balance') export class AccountBalanceController { public constructor( private readonly accountBalanceService: AccountBalanceService, + private readonly accountService: AccountService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} + @HasPermission(permissions.createAccountBalance) + @Post() + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async createAccountBalance( + @Body() data: CreateAccountBalanceDto + ): Promise { + const account = await this.accountService.account({ + id_userId: { + id: data.accountId, + userId: this.request.user.id + } + }); + + if (!account) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.accountBalanceService.createOrUpdateAccountBalance({ + accountId: account.id, + balance: data.balance, + date: data.date, + userId: account.userId + }); + } + @HasPermission(permissions.deleteAccountBalance) @Delete(':id') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -32,10 +65,11 @@ export class AccountBalanceController { @Param('id') id: string ): Promise { const accountBalance = await this.accountBalanceService.accountBalance({ - id + id, + userId: this.request.user.id }); - if (!accountBalance || accountBalance.userId !== this.request.user.id) { + if (!accountBalance) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), StatusCodes.FORBIDDEN @@ -43,7 +77,8 @@ export class AccountBalanceController { } return this.accountBalanceService.deleteAccountBalance({ - id + id: accountBalance.id, + userId: accountBalance.userId }); } } diff --git a/apps/api/src/app/account-balance/account-balance.module.ts b/apps/api/src/app/account-balance/account-balance.module.ts index 1fba60fce..02323acc9 100644 --- a/apps/api/src/app/account-balance/account-balance.module.ts +++ b/apps/api/src/app/account-balance/account-balance.module.ts @@ -1,3 +1,4 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; @@ -10,6 +11,6 @@ import { AccountBalanceService } from './account-balance.service'; controllers: [AccountBalanceController], exports: [AccountBalanceService], imports: [ExchangeRateDataModule, PrismaModule], - providers: [AccountBalanceService] + providers: [AccountBalanceService, AccountService] }) export class AccountBalanceModule {} diff --git a/apps/api/src/app/account-balance/account-balance.service.ts b/apps/api/src/app/account-balance/account-balance.service.ts index a75a1cbd1..331263c0c 100644 --- a/apps/api/src/app/account-balance/account-balance.service.ts +++ b/apps/api/src/app/account-balance/account-balance.service.ts @@ -1,15 +1,22 @@ import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor'; +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { resetHours } from '@ghostfolio/common/helper'; import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces'; import { UserWithSettings } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { AccountBalance, Prisma } from '@prisma/client'; +import { parseISO } from 'date-fns'; + +import { CreateAccountBalanceDto } from './create-account-balance.dto'; @Injectable() export class AccountBalanceService { public constructor( + private readonly eventEmitter: EventEmitter2, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly prismaService: PrismaService ) {} @@ -25,20 +32,63 @@ export class AccountBalanceService { }); } - public async createAccountBalance( - data: Prisma.AccountBalanceCreateInput - ): Promise { - return this.prismaService.accountBalance.create({ - data + public async createOrUpdateAccountBalance({ + accountId, + balance, + date, + userId + }: CreateAccountBalanceDto & { + userId: string; + }): Promise { + const accountBalance = await this.prismaService.accountBalance.upsert({ + create: { + Account: { + connect: { + id_userId: { + userId, + id: accountId + } + } + }, + date: resetHours(parseISO(date)), + value: balance + }, + update: { + value: balance + }, + where: { + accountId_date: { + accountId, + date: resetHours(parseISO(date)) + } + } }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId + }) + ); + + return accountBalance; } public async deleteAccountBalance( where: Prisma.AccountBalanceWhereUniqueInput ): Promise { - return this.prismaService.accountBalance.delete({ + const accountBalance = await this.prismaService.accountBalance.delete({ where }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: where.userId + }) + ); + + return accountBalance; } @LogPerformance diff --git a/apps/api/src/app/account-balance/create-account-balance.dto.ts b/apps/api/src/app/account-balance/create-account-balance.dto.ts new file mode 100644 index 000000000..28e939b82 --- /dev/null +++ b/apps/api/src/app/account-balance/create-account-balance.dto.ts @@ -0,0 +1,12 @@ +import { IsISO8601, IsNumber, IsUUID } from 'class-validator'; + +export class CreateAccountBalanceDto { + @IsUUID() + accountId: string; + + @IsNumber() + balance: number; + + @IsISO8601() + date: string; +} diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index cb8467c18..1564fa5b3 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -1,11 +1,15 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { Filter } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { Account, Order, Platform, Prisma } from '@prisma/client'; -import Big from 'big.js'; +import { Big } from 'big.js'; +import { format } from 'date-fns'; import { groupBy } from 'lodash'; import { CashDetails } from './interfaces/cash-details.interface'; @@ -14,6 +18,7 @@ import { CashDetails } from './interfaces/cash-details.interface'; export class AccountService { public constructor( private readonly accountBalanceService: AccountBalanceService, + private readonly eventEmitter: EventEmitter2, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly prismaService: PrismaService ) {} @@ -85,17 +90,20 @@ export class AccountService { data }); - await this.prismaService.accountBalance.create({ - data: { - Account: { - connect: { - id_userId: { id: account.id, userId: aUserId } - } - }, - value: data.balance - } + await this.accountBalanceService.createOrUpdateAccountBalance({ + accountId: account.id, + balance: data.balance, + date: format(new Date(), DATE_FORMAT), + userId: aUserId }); + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: account.userId + }) + ); + return account; } @@ -103,9 +111,18 @@ export class AccountService { where: Prisma.AccountWhereUniqueInput, aUserId: string ): Promise { - return this.prismaService.account.delete({ + const account = await this.prismaService.account.delete({ where }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: account.userId + }) + ); + + return account; } public async getAccounts(aUserId: string): Promise { @@ -196,21 +213,26 @@ export class AccountService { ): Promise { const { data, where } = params; - await this.prismaService.accountBalance.create({ - data: { - Account: { - connect: { - id_userId: where.id_userId - } - }, - value: data.balance - } + await this.accountBalanceService.createOrUpdateAccountBalance({ + accountId: data.id, + balance: data.balance, + date: format(new Date(), DATE_FORMAT), + userId: aUserId }); - return this.prismaService.account.update({ + const account = await this.prismaService.account.update({ data, where }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: account.userId + }) + ); + + return account; } public async updateAccountBalance({ @@ -242,17 +264,11 @@ export class AccountService { ); if (amountInCurrencyOfAccount) { - await this.accountBalanceService.createAccountBalance({ - date, - Account: { - connect: { - id_userId: { - userId, - id: accountId - } - } - }, - value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber() + await this.accountBalanceService.createOrUpdateAccountBalance({ + accountId, + userId, + balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber(), + date: date.toISOString() }); } } diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 007cdf388..610a0171f 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -7,13 +7,12 @@ import { ManualService } from '@ghostfolio/api/services/data-provider/manual/man import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { + DATA_GATHERING_QUEUE_PRIORITY_HIGH, + DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS_OPTIONS } from '@ghostfolio/common/config'; -import { - getAssetProfileIdentifier, - resetHours -} from '@ghostfolio/common/helper'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { AdminData, AdminMarketData, @@ -94,7 +93,8 @@ export class AdminController { name: GATHER_ASSET_PROFILE_PROCESS, opts: { ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, - jobId: getAssetProfileIdentifier({ dataSource, symbol }) + jobId: getAssetProfileIdentifier({ dataSource, symbol }), + priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM } }; }) @@ -119,7 +119,8 @@ export class AdminController { name: GATHER_ASSET_PROFILE_PROCESS, opts: { ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, - jobId: getAssetProfileIdentifier({ dataSource, symbol }) + jobId: getAssetProfileIdentifier({ dataSource, symbol }), + priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM } }; }) @@ -141,7 +142,8 @@ export class AdminController { name: GATHER_ASSET_PROFILE_PROCESS, opts: { ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, - jobId: getAssetProfileIdentifier({ dataSource, symbol }) + jobId: getAssetProfileIdentifier({ dataSource, symbol }), + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH } }); } @@ -325,45 +327,16 @@ export class AdminController { @Param('dataSource') dataSource: DataSource, @Param('symbol') symbol: string ): Promise { - if (dataSource === 'MANUAL') { - await this.adminService.patchAssetProfileData({ - dataSource, - symbol, - tags: { - set: [] - } - }); - - return this.adminService.patchAssetProfileData({ - ...assetProfileData, - dataSource, - symbol, - tags: { - connect: assetProfileData.tags?.map(({ id }) => { - return { id }; - }) - } - }); - } else { - await this.adminService.patchAssetProfileData({ - dataSource, - symbol, - tags: { - set: [] - } - }); - - return this.adminService.patchAssetProfileData({ - ...assetProfileData, - dataSource, - symbol, - tags: { - connect: assetProfileData.tags?.map(({ id }) => { - return { id }; - }) - } - }); - } + return this.adminService.patchAssetProfileData({ + ...assetProfileData, + dataSource, + symbol, + tags: { + connect: assetProfileData.tags?.map(({ id }) => { + return { id }; + }) + } + }); } @HasPermission(permissions.accessAdminControl) @@ -373,6 +346,6 @@ export class AdminController { @Param('key') key: string, @Body() data: PropertyDto ) { - return await this.adminService.putSetting(key, data.value); + return this.adminService.putSetting(key, data.value); } } diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index eca88143b..10fb27469 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -26,6 +26,7 @@ import { MarketDataPreset } from '@ghostfolio/common/types'; import { BadRequestException, Injectable } from '@nestjs/common'; import { + AssetClass, AssetSubClass, Prisma, Property, @@ -75,7 +76,7 @@ export class AdminService { ); } - return await this.symbolProfileService.add( + return this.symbolProfileService.add( assetProfiles[symbol] as Prisma.SymbolProfileCreateInput ); } catch (error) { @@ -216,6 +217,7 @@ export class AdminService { countries: true, currency: true, dataSource: true, + id: true, name: true, Order: { orderBy: [{ date: 'asc' }], @@ -240,6 +242,7 @@ export class AdminService { countries, currency, dataSource, + id, name, Order, sectors, @@ -263,6 +266,7 @@ export class AdminService { currency, countriesCount, dataSource, + id, name, symbol, marketDataItemCount, @@ -337,7 +341,8 @@ export class AdminService { scraperConfiguration, sectors, symbol, - symbolMapping + symbolMapping, + url }: Prisma.SymbolProfileUpdateInput & UniqueAsset) { if (dataSource === 'MANUAL') { await this.symbolProfileService.updateSymbolProfile({ @@ -453,9 +458,10 @@ export class AdminService { dataSource, marketDataItemCount, symbol, - assetClass: 'CASH', + assetClass: AssetClass.LIQUIDITY, countriesCount: 0, currency: symbol.replace(DEFAULT_CURRENCY, ''), + id: undefined, name: symbol, sectorsCount: 0, tags: [] @@ -500,13 +506,14 @@ export class AdminService { }, createdAt: true, id: true, + role: true, Subscription: true }, take: 30 }); return usersWithAnalytics.map( - ({ _count, Analytics, createdAt, id, Subscription }) => { + ({ _count, Analytics, createdAt, id, role, Subscription }) => { const daysSinceRegistration = differenceInDays(new Date(), createdAt) + 1; const engagement = Analytics @@ -526,6 +533,7 @@ export class AdminService { createdAt, engagement, id, + role, subscription, accountCount: _count.Account || 0, country: Analytics?.country, diff --git a/apps/api/src/app/admin/queue/queue.controller.ts b/apps/api/src/app/admin/queue/queue.controller.ts index 89bd851bc..978cb9721 100644 --- a/apps/api/src/app/admin/queue/queue.controller.ts +++ b/apps/api/src/app/admin/queue/queue.controller.ts @@ -46,4 +46,11 @@ export class QueueController { public async deleteJob(@Param('id') id: string): Promise { return this.queueService.deleteJob(id); } + + @Get('job/:id/execute') + @HasPermission(permissions.accessAdminControl) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async executeJob(@Param('id') id: string): Promise { + return this.queueService.executeJob(id); + } } diff --git a/apps/api/src/app/admin/queue/queue.service.ts b/apps/api/src/app/admin/queue/queue.service.ts index c5143e870..abae3cad1 100644 --- a/apps/api/src/app/admin/queue/queue.service.ts +++ b/apps/api/src/app/admin/queue/queue.service.ts @@ -32,6 +32,10 @@ export class QueueService { } } + public async executeJob(aId: string) { + return (await this.dataGatheringQueue.getJob(aId))?.promote(); + } + public async getJobs({ limit = 1000, status = QUEUE_JOB_STATUS_LIST @@ -54,6 +58,7 @@ export class QueueService { finishedOn: job.finishedOn, id: job.id, name: job.name, + opts: job.opts, stacktrace: job.stacktrace, state: await job.getState(), timestamp: job.timestamp diff --git a/apps/api/src/app/admin/update-asset-profile.dto.ts b/apps/api/src/app/admin/update-asset-profile.dto.ts index c082dac81..8fe756421 100644 --- a/apps/api/src/app/admin/update-asset-profile.dto.ts +++ b/apps/api/src/app/admin/update-asset-profile.dto.ts @@ -5,7 +5,8 @@ import { IsISO4217CurrencyCode, IsObject, IsOptional, - IsString + IsString, + IsUrl } from 'class-validator'; export class UpdateAssetProfileDto { @@ -50,4 +51,11 @@ export class UpdateAssetProfileDto { symbolMapping?: { [dataProvider: string]: string; }; + + @IsOptional() + @IsUrl({ + protocols: ['https'], + require_protocol: true + }) + url?: string; } diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 5f2be5c8e..67bb9e03c 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,3 +1,4 @@ +import { EventsModule } from '@ghostfolio/api/events/events.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { CronService } from '@ghostfolio/api/services/cron.service'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; @@ -14,6 +15,7 @@ import { import { BullModule } from '@nestjs/bull'; import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; import { StatusCodes } from 'http-status-codes'; @@ -44,6 +46,7 @@ import { TagModule } from './tag/tag.module'; import { UserModule } from './user/user.module'; @Module({ + controllers: [AppController], imports: [ AdminModule, AccessModule, @@ -64,6 +67,8 @@ import { UserModule } from './user/user.module'; ConfigurationModule, DataGatheringModule, DataProviderModule, + EventEmitterModule.forRoot(), + EventsModule, ExchangeRateModule, ExchangeRateDataModule, ExportModule, @@ -109,7 +114,6 @@ import { UserModule } from './user/user.module'; TwitterBotModule, UserModule ], - controllers: [AppController], providers: [CronService] }) export class AppModule {} diff --git a/apps/api/src/app/auth/jwt.strategy.ts b/apps/api/src/app/auth/jwt.strategy.ts index c7ce38986..a8ad8fd08 100644 --- a/apps/api/src/app/auth/jwt.strategy.ts +++ b/apps/api/src/app/auth/jwt.strategy.ts @@ -2,10 +2,12 @@ import { UserService } from '@ghostfolio/api/app/user/user.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config'; +import { hasRole } from '@ghostfolio/common/permissions'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { HttpException, Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import * as countriesAndTimezones from 'countries-and-timezones'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { ExtractJwt, Strategy } from 'passport-jwt'; @Injectable() @@ -29,6 +31,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { if (user) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + if (hasRole(user, 'INACTIVE')) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + const country = countriesAndTimezones.getCountryForTimezone(timezone)?.id; @@ -45,10 +54,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { return user; } else { - throw ''; + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + } catch (error) { + if (error?.getStatus() === StatusCodes.TOO_MANY_REQUESTS) { + throw error; + } else { + throw new HttpException( + getReasonPhrase(StatusCodes.UNAUTHORIZED), + StatusCodes.UNAUTHORIZED + ); } - } catch (err) { - throw new UnauthorizedException('unauthorized', err.message); } } } diff --git a/apps/api/src/app/auth/web-auth.service.ts b/apps/api/src/app/auth/web-auth.service.ts index a6e76ffbb..961bbe9a7 100644 --- a/apps/api/src/app/auth/web-auth.service.ts +++ b/apps/api/src/app/auth/web-auth.service.ts @@ -41,7 +41,7 @@ export class WebAuthService { ) {} get rpID() { - return this.configurationService.get('WEB_AUTH_RP_ID'); + return new URL(this.configurationService.get('ROOT_URL')).hostname; } get expectedOrigin() { diff --git a/apps/api/src/app/benchmark/benchmark.controller.ts b/apps/api/src/app/benchmark/benchmark.controller.ts index d3b91c6df..184214384 100644 --- a/apps/api/src/app/benchmark/benchmark.controller.ts +++ b/apps/api/src/app/benchmark/benchmark.controller.ts @@ -1,5 +1,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { getInterval } from '@ghostfolio/api/helper/portfolio.helper'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import type { @@ -8,7 +9,7 @@ import type { UniqueAsset } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import { Body, @@ -19,6 +20,7 @@ import { Inject, Param, Post, + Query, UseGuards, UseInterceptors } from '@nestjs/common'; @@ -106,13 +108,18 @@ export class BenchmarkController { public async getBenchmarkMarketDataBySymbol( @Param('dataSource') dataSource: DataSource, @Param('startDateString') startDateString: string, - @Param('symbol') symbol: string + @Param('symbol') symbol: string, + @Query('range') dateRange: DateRange = 'max' ): Promise { - const startDate = new Date(startDateString); + const { endDate, startDate } = getInterval( + dateRange, + new Date(startDateString) + ); const userCurrency = this.request.user.Settings.settings.baseCurrency; return this.benchmarkService.getMarketDataBySymbol({ dataSource, + endDate, startDate, symbol, userCurrency diff --git a/apps/api/src/app/benchmark/benchmark.service.ts b/apps/api/src/app/benchmark/benchmark.service.ts index b820430f6..6f2047210 100644 --- a/apps/api/src/app/benchmark/benchmark.service.ts +++ b/apps/api/src/app/benchmark/benchmark.service.ts @@ -13,7 +13,8 @@ import { import { DATE_FORMAT, calculateBenchmarkTrend, - parseDate + parseDate, + resetHours } from '@ghostfolio/common/helper'; import { Benchmark, @@ -26,8 +27,14 @@ import { BenchmarkTrend } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { SymbolProfile } from '@prisma/client'; -import Big from 'big.js'; -import { format, isSameDay, subDays } from 'date-fns'; +import { Big } from 'big.js'; +import { + differenceInDays, + eachDayOfInterval, + format, + isSameDay, + subDays +} from 'date-fns'; import { isNumber, last, uniqBy } from 'lodash'; import ms from 'ms'; @@ -110,7 +117,9 @@ export class BenchmarkService { const quotes = await this.dataProviderService.getQuotes({ items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => { return { dataSource, symbol }; - }) + }), + requestTimeout: ms('30 seconds'), + useCache: false }); for (const { dataSource, symbol } of benchmarkAssetProfiles) { @@ -163,7 +172,7 @@ export class BenchmarkService { await this.redisCacheService.set( this.CACHE_KEY_BENCHMARKS, JSON.stringify(benchmarks), - ms('4 hours') / 1000 + ms('2 hours') / 1000 ); } @@ -206,15 +215,28 @@ export class BenchmarkService { public async getMarketDataBySymbol({ dataSource, + endDate = new Date(), startDate, symbol, userCurrency }: { + endDate?: Date; startDate: Date; userCurrency: string; } & UniqueAsset): Promise { const marketData: { date: string; value: number }[] = []; + const days = differenceInDays(endDate, startDate) + 1; + const dates = eachDayOfInterval( + { + start: startDate, + end: endDate + }, + { step: Math.round(days / Math.min(days, MAX_CHART_ITEMS)) } + ).map((date) => { + return resetHours(date); + }); + const [currentSymbolItem, marketDataItems] = await Promise.all([ this.symbolService.get({ dataGatheringItem: { @@ -230,7 +252,7 @@ export class BenchmarkService { dataSource, symbol, date: { - gte: startDate + in: dates } } }) @@ -264,17 +286,7 @@ export class BenchmarkService { return { marketData }; } - const step = Math.round( - marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS) - ); - - let i = 0; - for (let marketDataItem of marketDataItems) { - if (i % step !== 0) { - continue; - } - const exchangeRate = exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ format(marketDataItem.date, DATE_FORMAT) @@ -297,15 +309,15 @@ export class BenchmarkService { }); } - const includesToday = isSameDay( + const includesEndDate = isSameDay( parseDate(last(marketData).date), - new Date() + endDate ); - if (currentSymbolItem?.marketPrice && !includesToday) { + if (currentSymbolItem?.marketPrice && !includesEndDate) { const exchangeRate = exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ - format(new Date(), DATE_FORMAT) + format(endDate, DATE_FORMAT) ]; const exchangeRateFactor = @@ -314,7 +326,7 @@ export class BenchmarkService { : 1; marketData.push({ - date: format(new Date(), DATE_FORMAT), + date: format(endDate, DATE_FORMAT), value: this.calculateChangeInPercentage( marketPriceAtStartDate, diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index 90090119e..1ff18ce9c 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -95,7 +95,10 @@ export class ExportService { : SymbolProfile.symbol }; } - ) + ), + user: { + settings: { currency: userCurrency } + } }; } } diff --git a/apps/api/src/app/import/import.controller.ts b/apps/api/src/app/import/import.controller.ts index b7aff8634..29a06fc9f 100644 --- a/apps/api/src/app/import/import.controller.ts +++ b/apps/api/src/app/import/import.controller.ts @@ -43,8 +43,10 @@ export class ImportController { @UseInterceptors(TransformDataSourceInResponseInterceptor) public async import( @Body() importData: ImportDataDto, - @Query('dryRun') isDryRun = false + @Query('dryRun') isDryRunParam = 'false' ): Promise { + const isDryRun = isDryRunParam === 'true'; + if ( !hasPermission(this.request.user.permissions, permissions.createAccount) ) { diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index a4895cf73..691b0fdea 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -13,6 +13,10 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/da import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { + DATA_GATHERING_QUEUE_PRIORITY_HIGH, + DATA_GATHERING_QUEUE_PRIORITY_MEDIUM +} from '@ghostfolio/common/config'; import { DATE_FORMAT, getAssetProfileIdentifier, @@ -27,7 +31,7 @@ import { import { Injectable } from '@nestjs/common'; import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; -import Big from 'big.js'; +import { Big } from 'big.js'; import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns'; import { uniqBy } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; @@ -112,12 +116,13 @@ export class ImportService { accountId: Account?.id, accountUserId: undefined, comment: undefined, + currency: undefined, createdAt: undefined, fee: 0, feeInBaseCurrency: 0, id: assetProfile.id, isDraft: false, - SymbolProfile: (assetProfile), + SymbolProfile: assetProfile, symbolProfileId: assetProfile.id, type: 'DIVIDEND', unitPrice: marketPrice, @@ -261,6 +266,7 @@ export class ImportService { { accountId, comment, + currency, date, error, fee, @@ -285,7 +291,6 @@ export class ImportService { assetSubClass, countries, createdAt, - currency, dataSource, figi, figiComposite, @@ -342,6 +347,7 @@ export class ImportService { if (isDryRun) { order = { comment, + currency, date, fee, quantity, @@ -357,7 +363,6 @@ export class ImportService { assetSubClass, countries, createdAt, - currency, dataSource, figi, figiComposite, @@ -371,6 +376,7 @@ export class ImportService { symbolMapping, updatedAt, url, + currency: assetProfile.currency, comment: assetProfile.comment }, Account: validatedAccount, @@ -394,9 +400,9 @@ export class ImportService { SymbolProfile: { connectOrCreate: { create: { - currency, dataSource, - symbol + symbol, + currency: assetProfile.currency }, where: { dataSource_symbol: { @@ -410,6 +416,11 @@ export class ImportService { User: { connect: { id: user.id } }, userId: user.id }); + + if (order.SymbolProfile?.symbol) { + // Update symbol that may have been assigned in createOrder() + assetProfile.symbol = order.SymbolProfile.symbol; + } } const value = new Big(quantity).mul(unitPrice).toNumber(); @@ -420,14 +431,14 @@ export class ImportService { value, feeInBaseCurrency: this.exchangeRateDataService.toCurrency( fee, - currency, + assetProfile.currency, userCurrency ), // @ts-ignore SymbolProfile: assetProfile, valueInBaseCurrency: this.exchangeRateDataService.toCurrency( value, - currency, + assetProfile.currency, userCurrency ) }); @@ -446,15 +457,16 @@ export class ImportService { }); }); - this.dataGatheringService.gatherSymbols( - uniqueActivities.map(({ date, SymbolProfile }) => { + this.dataGatheringService.gatherSymbols({ + dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => { return { date, dataSource: SymbolProfile.dataSource, symbol: SymbolProfile.symbol }; - }) - ); + }), + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + }); } return activities; @@ -521,22 +533,14 @@ export class ImportService { currency, dataSource, symbol, - assetClass: null, - assetSubClass: null, - comment: null, - countries: null, + activitiesCount: undefined, + assetClass: undefined, + assetSubClass: undefined, + countries: undefined, createdAt: undefined, - figi: null, - figiComposite: null, - figiShareClass: null, id: undefined, - isin: null, - name: null, - scraperConfiguration: null, - sectors: null, - symbolMapping: null, - updatedAt: undefined, - url: null + sectors: undefined, + updatedAt: undefined } }; } @@ -605,7 +609,12 @@ export class ImportService { )?.[symbol] }; - if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') { + if ( + type === 'BUY' || + type === 'DIVIDEND' || + type === 'SELL' || + type === 'STAKE' + ) { if (!assetProfile?.name) { throw new Error( `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` diff --git a/apps/api/src/app/order/create-order.dto.ts b/apps/api/src/app/order/create-order.dto.ts index aecec842a..6d36f036a 100644 --- a/apps/api/src/app/order/create-order.dto.ts +++ b/apps/api/src/app/order/create-order.dto.ts @@ -42,6 +42,10 @@ export class CreateOrderDto { @IsISO4217CurrencyCode() currency: string; + @IsISO4217CurrencyCode() + @IsOptional() + customCurrency?: string; + @IsOptional() @IsEnum(DataSource, { each: true }) dataSource?: DataSource; diff --git a/apps/api/src/app/order/interfaces/activities.interface.ts b/apps/api/src/app/order/interfaces/activities.interface.ts index 7c612d464..b16d10b7d 100644 --- a/apps/api/src/app/order/interfaces/activities.interface.ts +++ b/apps/api/src/app/order/interfaces/activities.interface.ts @@ -1,13 +1,19 @@ -import { OrderWithAccount } from '@ghostfolio/common/types'; +import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces'; +import { AccountWithPlatform } from '@ghostfolio/common/types'; + +import { Order, Tag } from '@prisma/client'; export interface Activities { activities: Activity[]; count: number; } -export interface Activity extends OrderWithAccount { +export interface Activity extends Order { + Account?: AccountWithPlatform; error?: ActivityError; feeInBaseCurrency: number; + SymbolProfile?: EnhancedSymbolProfile; + tags?: Tag[]; updateAccountBalance?: boolean; value: number; valueInBaseCurrency: number; diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts index dbcf6dedb..bf4920463 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/order/order.controller.ts @@ -1,14 +1,18 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { getInterval } from '@ghostfolio/api/helper/portfolio.helper'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; -import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; +import { + DATA_GATHERING_QUEUE_PRIORITY_HIGH, + HEADER_KEY_IMPERSONATION +} from '@ghostfolio/common/config'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import { Body, @@ -56,15 +60,15 @@ export class OrderController { } @Delete(':id') + @HasPermission(permissions.deleteOrder) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async deleteOrder(@Param('id') id: string): Promise { - const order = await this.orderService.order({ id }); + const order = await this.orderService.order({ + id, + userId: this.request.user.id + }); - if ( - !hasPermission(this.request.user.permissions, permissions.deleteOrder) || - !order || - order.userId !== this.request.user.id - ) { + if (!order) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), StatusCodes.FORBIDDEN @@ -84,12 +88,20 @@ export class OrderController { @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, + @Query('range') dateRange?: DateRange, @Query('skip') skip?: number, @Query('sortColumn') sortColumn?: string, @Query('sortDirection') sortDirection?: Prisma.SortOrder, @Query('tags') filterByTags?: string, @Query('take') take?: number ): Promise { + let endDate: Date; + let startDate: Date; + + if (dateRange) { + ({ endDate, startDate } = getInterval(dateRange)); + } + const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, @@ -101,9 +113,11 @@ export class OrderController { const userCurrency = this.request.user.Settings.settings.baseCurrency; const { activities, count } = await this.orderService.getOrders({ + endDate, filters, sortColumn, sortDirection, + startDate, userCurrency, includeDrafts: true, skip: isNaN(skip) ? undefined : skip, @@ -120,13 +134,22 @@ export class OrderController { @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async createOrder(@Body() data: CreateOrderDto): Promise { + const currency = data.currency; + const customCurrency = data.customCurrency; + + if (customCurrency) { + data.currency = customCurrency; + + delete data.customCurrency; + } + const order = await this.orderService.createOrder({ ...data, date: parseISO(data.date), SymbolProfile: { connectOrCreate: { create: { - currency: data.currency, + currency, dataSource: data.dataSource, symbol: data.symbol }, @@ -145,13 +168,16 @@ export class OrderController { if (data.dataSource && !order.isDraft) { // Gather symbol data in the background, if data source is set // (not MANUAL) and not draft - this.dataGatheringService.gatherSymbols([ - { - dataSource: data.dataSource, - date: order.date, - symbol: data.symbol - } - ]); + this.dataGatheringService.gatherSymbols({ + dataGatheringItems: [ + { + dataSource: data.dataSource, + date: order.date, + symbol: data.symbol + } + ], + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + }); } return order; @@ -176,8 +202,16 @@ export class OrderController { const date = parseISO(data.date); const accountId = data.accountId; + const customCurrency = data.customCurrency; + delete data.accountId; + if (customCurrency) { + data.currency = customCurrency; + + delete data.customCurrency; + } + return this.orderService.updateOrder({ data: { ...data, diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 3845a4f1e..4b660be86 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -1,9 +1,11 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { + DATA_GATHERING_QUEUE_PRIORITY_HIGH, GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS_OPTIONS } from '@ghostfolio/common/config'; @@ -12,6 +14,7 @@ import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces'; import { OrderWithAccount } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { AssetClass, AssetSubClass, @@ -19,11 +22,11 @@ import { Order, Prisma, Tag, - Type as TypeOfOrder + Type as ActivityType } from '@prisma/client'; -import Big from 'big.js'; +import { Big } from 'big.js'; import { endOfToday, isAfter } from 'date-fns'; -import { groupBy } from 'lodash'; +import { groupBy, uniqBy } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; import { Activities } from './interfaces/activities.interface'; @@ -33,6 +36,7 @@ export class OrderService { public constructor( private readonly accountService: AccountService, private readonly dataGatheringService: DataGatheringService, + private readonly eventEmitter: EventEmitter2, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly prismaService: PrismaService, private readonly symbolProfileService: SymbolProfileService @@ -65,20 +69,13 @@ export class OrderService { } const accountId = data.accountId; - let currency = data.currency; const tags = data.tags ?? []; const updateAccountBalance = data.updateAccountBalance ?? false; const userId = data.userId; - if ( - data.type === 'FEE' || - data.type === 'INTEREST' || - data.type === 'ITEM' || - data.type === 'LIABILITY' - ) { + if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) { const assetClass = data.assetClass; const assetSubClass = data.assetSubClass; - currency = data.SymbolProfile.connectOrCreate.create.currency; const dataSource: DataSource = 'MANUAL'; const id = uuidv4(); const name = data.SymbolProfile.connectOrCreate.create.symbol; @@ -86,7 +83,6 @@ export class OrderService { data.id = id; data.SymbolProfile.connectOrCreate.create.assetClass = assetClass; data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass; - data.SymbolProfile.connectOrCreate.create.currency = currency; data.SymbolProfile.connectOrCreate.create.dataSource = dataSource; data.SymbolProfile.connectOrCreate.create.name = name; data.SymbolProfile.connectOrCreate.create.symbol = id; @@ -108,7 +104,8 @@ export class OrderService { jobId: getAssetProfileIdentifier({ dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, symbol: data.SymbolProfile.connectOrCreate.create.symbol - }) + }), + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH } }); } @@ -121,7 +118,6 @@ export class OrderService { delete data.comment; } - delete data.currency; delete data.dataSource; delete data.symbol; delete data.tags; @@ -130,13 +126,9 @@ export class OrderService { const orderData: Prisma.OrderCreateInput = data; - const isDraft = - data.type === 'FEE' || - data.type === 'INTEREST' || - data.type === 'ITEM' || - data.type === 'LIABILITY' - ? false - : isAfter(data.date as Date, endOfToday()); + const isDraft = ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type) + ? false + : isAfter(data.date as Date, endOfToday()); const order = await this.prismaService.order.create({ data: { @@ -148,7 +140,8 @@ export class OrderService { return { id }; }) } - } + }, + include: { SymbolProfile: true } }); if (updateAccountBalance === true) { @@ -157,19 +150,26 @@ export class OrderService { .plus(data.fee) .toNumber(); - if (data.type === 'BUY') { + if (['BUY', 'FEE'].includes(data.type)) { amount = new Big(amount).mul(-1).toNumber(); } await this.accountService.updateAccountBalance({ accountId, amount, - currency, userId, + currency: data.SymbolProfile.connectOrCreate.create.currency, date: data.date as Date }); } + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: order.userId + }) + ); + return order; } @@ -180,15 +180,17 @@ export class OrderService { where }); - if ( - order.type === 'FEE' || - order.type === 'INTEREST' || - order.type === 'ITEM' || - order.type === 'LIABILITY' - ) { + if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type)) { await this.symbolProfileService.deleteById(order.symbolProfileId); } + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: order.userId + }) + ); + return order; } @@ -197,6 +199,13 @@ export class OrderService { where }); + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: where.userId + }) + ); + return count; } @@ -212,24 +221,28 @@ export class OrderService { } public async getOrders({ + endDate, filters, includeDrafts = false, skip, sortColumn, sortDirection, + startDate, take = Number.MAX_SAFE_INTEGER, types, userCurrency, userId, withExcludedAccounts = false }: { + endDate?: Date; filters?: Filter[]; includeDrafts?: boolean; skip?: number; sortColumn?: string; sortDirection?: Prisma.SortOrder; + startDate?: Date; take?: number; - types?: TypeOfOrder[]; + types?: ActivityType[]; userCurrency: string; userId: string; withExcludedAccounts?: boolean; @@ -239,6 +252,18 @@ export class OrderService { ]; const where: Prisma.OrderWhereInput = { userId }; + if (endDate || startDate) { + where.AND = []; + + if (endDate) { + where.AND.push({ date: { lte: endDate } }); + } + + if (startDate) { + where.AND.push({ date: { gt: startDate } }); + } + } + const { ACCOUNT: filtersByAccount, ASSET_CLASS: filtersByAssetClass, @@ -359,17 +384,45 @@ export class OrderService { this.prismaService.order.count({ where }) ]); + const uniqueAssets = uniqBy( + orders.map(({ SymbolProfile }) => { + return { + dataSource: SymbolProfile.dataSource, + symbol: SymbolProfile.symbol + }; + }), + ({ dataSource, symbol }) => { + return getAssetProfileIdentifier({ + dataSource, + symbol + }); + } + ); + + const assetProfiles = + await this.symbolProfileService.getSymbolProfiles(uniqueAssets); + const activities = orders.map((order) => { + const assetProfile = assetProfiles.find(({ dataSource, symbol }) => { + return ( + dataSource === order.SymbolProfile.dataSource && + symbol === order.SymbolProfile.symbol + ); + }); + const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); return { ...order, value, + // TODO: Use exchange rate of date feeInBaseCurrency: this.exchangeRateDataService.toCurrency( order.fee, order.SymbolProfile.currency, userCurrency ), + SymbolProfile: assetProfile, + // TODO: Use exchange rate of date valueInBaseCurrency: this.exchangeRateDataService.toCurrency( value, order.SymbolProfile.currency, @@ -400,13 +453,10 @@ export class OrderService { dataSource?: DataSource; symbol?: string; tags?: Tag[]; + type?: ActivityType; }; where: Prisma.OrderWhereUniqueInput; }): Promise { - if (data.Account.connect.id_userId.id === null) { - delete data.Account; - } - if (!data.comment) { data.comment = null; } @@ -415,13 +465,12 @@ export class OrderService { let isDraft = false; - if ( - data.type === 'FEE' || - data.type === 'INTEREST' || - data.type === 'ITEM' || - data.type === 'LIABILITY' - ) { + if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) { delete data.SymbolProfile.connect; + + if (data.Account?.connect?.id_userId?.id === null) { + data.Account = { disconnect: true }; + } } else { delete data.SymbolProfile.update; @@ -429,19 +478,22 @@ export class OrderService { if (!isDraft) { // Gather symbol data of order in the background, if not draft - this.dataGatheringService.gatherSymbols([ - { - dataSource: data.SymbolProfile.connect.dataSource_symbol.dataSource, - date: data.date, - symbol: data.SymbolProfile.connect.dataSource_symbol.symbol - } - ]); + this.dataGatheringService.gatherSymbols({ + dataGatheringItems: [ + { + dataSource: + data.SymbolProfile.connect.dataSource_symbol.dataSource, + date: data.date, + symbol: data.SymbolProfile.connect.dataSource_symbol.symbol + } + ], + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + }); } } delete data.assetClass; delete data.assetSubClass; - delete data.currency; delete data.dataSource; delete data.symbol; delete data.tags; @@ -452,7 +504,7 @@ export class OrderService { where }); - return this.prismaService.order.update({ + const order = await this.prismaService.order.update({ data: { ...data, isDraft, @@ -464,6 +516,15 @@ export class OrderService { }, where }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: order.userId + }) + ); + + return order; } private async orders(params: { diff --git a/apps/api/src/app/order/update-order.dto.ts b/apps/api/src/app/order/update-order.dto.ts index c0a400c57..be3c2b6e5 100644 --- a/apps/api/src/app/order/update-order.dto.ts +++ b/apps/api/src/app/order/update-order.dto.ts @@ -41,6 +41,10 @@ export class UpdateOrderDto { @IsISO4217CurrencyCode() currency: string; + @IsISO4217CurrencyCode() + @IsOptional() + customCurrency?: string; + @IsString() dataSource: DataSource; diff --git a/apps/api/src/app/platform/create-platform.dto.ts b/apps/api/src/app/platform/create-platform.dto.ts index a61f21743..941354c11 100644 --- a/apps/api/src/app/platform/create-platform.dto.ts +++ b/apps/api/src/app/platform/create-platform.dto.ts @@ -1,9 +1,12 @@ -import { IsString } from 'class-validator'; +import { IsString, IsUrl } from 'class-validator'; export class CreatePlatformDto { @IsString() name: string; - @IsString() + @IsUrl({ + protocols: ['https'], + require_protocol: true + }) url: string; } diff --git a/apps/api/src/app/platform/update-platform.dto.ts b/apps/api/src/app/platform/update-platform.dto.ts index ec6f2687c..4c4f907af 100644 --- a/apps/api/src/app/platform/update-platform.dto.ts +++ b/apps/api/src/app/platform/update-platform.dto.ts @@ -1,4 +1,4 @@ -import { IsString } from 'class-validator'; +import { IsString, IsUrl } from 'class-validator'; export class UpdatePlatformDto { @IsString() @@ -7,6 +7,9 @@ export class UpdatePlatformDto { @IsString() name: string; - @IsString() + @IsUrl({ + protocols: ['https'], + require_protocol: true + }) url: string; } diff --git a/apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts new file mode 100644 index 000000000..49faa0b28 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts @@ -0,0 +1,302 @@ +import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; +import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; +import { + HistoricalDataItem, + SymbolMetrics, + UniqueAsset +} from '@ghostfolio/common/interfaces'; +import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; + +import { Big } from 'big.js'; +import { addDays, eachDayOfInterval, format } from 'date-fns'; + +import { PortfolioOrder } from '../../interfaces/portfolio-order.interface'; +import { TWRPortfolioCalculator } from '../twr/portfolio-calculator'; + +export class CPRPortfolioCalculator extends TWRPortfolioCalculator { + private holdings: { [date: string]: { [symbol: string]: Big } } = {}; + private holdingCurrencies: { [symbol: string]: string } = {}; + + protected calculateOverallPerformance( + positions: TimelinePosition[] + ): PortfolioSnapshot { + return super.calculateOverallPerformance(positions); + } + + protected getSymbolMetrics({ + dataSource, + end, + exchangeRates, + isChartMode = false, + marketSymbolMap, + start, + step = 1, + symbol + }: { + end: Date; + exchangeRates: { [dateString: string]: number }; + isChartMode?: boolean; + marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + }; + start: Date; + step?: number; + } & UniqueAsset): SymbolMetrics { + return super.getSymbolMetrics({ + dataSource, + end, + exchangeRates, + isChartMode, + marketSymbolMap, + start, + step, + symbol + }); + } + + public override async getChartData({ + end = new Date(Date.now()), + start, + step = 1 + }: { + end?: Date; + start: Date; + step?: number; + }): Promise { + const timelineHoldings = this.getHoldings(start, end); + const calculationDates = Object.keys(timelineHoldings) + .filter((date) => { + let parsed = parseDate(date); + parsed >= start && parsed <= end; + }) + .sort(); + let data: HistoricalDataItem[] = []; + const startString = format(start, DATE_FORMAT); + + data.push({ + date: startString, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + let totalInvestment = Object.keys(timelineHoldings[startString]).reduce( + (sum, holding) => { + return sum.plus( + timelineHoldings[startString][holding].mul( + this.marketMap[startString][holding] + ) + ); + }, + new Big(0) + ); + + let previousNetPerformanceInPercentage = new Big(0); + let previousNetPerformanceInPercentageWithCurrencyEffect = new Big(0); + + for (let i = 1; i < calculationDates.length; i++) { + const date = calculationDates[i]; + const previousDate = calculationDates[i - 1]; + const holdings = timelineHoldings[previousDate]; + let newTotalInvestment = new Big(0); + let netPerformanceInPercentage = new Big(0); + let netPerformanceInPercentageWithCurrencyEffect = new Big(0); + + for (const holding of Object.keys(holdings)) { + ({ + netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, + newTotalInvestment + } = await this.handleSingleHolding( + previousDate, + holding, + date, + totalInvestment, + timelineHoldings, + netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, + newTotalInvestment + )); + totalInvestment = newTotalInvestment; + } + + previousNetPerformanceInPercentage = + previousNetPerformanceInPercentage.mul( + netPerformanceInPercentage.plus(1) + ); + previousNetPerformanceInPercentageWithCurrencyEffect = + previousNetPerformanceInPercentageWithCurrencyEffect.mul( + netPerformanceInPercentageWithCurrencyEffect.plus(1) + ); + + data.push({ + date, + netPerformanceInPercentage: + previousNetPerformanceInPercentage.toNumber(), + netPerformanceInPercentageWithCurrencyEffect: + previousNetPerformanceInPercentageWithCurrencyEffect.toNumber() + }); + } + + return data; + } + + private async handleSingleHolding( + previousDate: string, + holding: string, + date: string, + totalInvestment, + timelineHoldings: { [date: string]: { [symbol: string]: Big } }, + netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, + newTotalInvestment + ) { + const previousPrice = this.marketMap[previousDate][holding]; + const currentPrice = this.marketMap[date][holding]; + const previousPriceInBaseCurrency = + await this.exchangeRateDataService.toCurrencyAtDate( + previousPrice.toNumber(), + this.getCurrency(holding), + this.currency, + parseDate(previousDate) + ); + const portfolioWeight = totalInvestment + ? timelineHoldings[previousDate][holding] + .mul(previousPriceInBaseCurrency) + .div(totalInvestment) + : 0; + + netPerformanceInPercentage = netPerformanceInPercentage.plus( + currentPrice.div(previousPrice).minus(1).mul(portfolioWeight) + ); + + const priceInBaseCurrency = + await this.exchangeRateDataService.toCurrencyAtDate( + currentPrice.toNumber(), + this.getCurrency(holding), + this.currency, + parseDate(date) + ); + netPerformanceInPercentageWithCurrencyEffect = + netPerformanceInPercentageWithCurrencyEffect.plus( + new Big(priceInBaseCurrency) + .div(new Big(previousPriceInBaseCurrency)) + .minus(1) + .mul(portfolioWeight) + ); + + newTotalInvestment = newTotalInvestment.plus( + timelineHoldings[date][holding].mul(priceInBaseCurrency) + ); + return { + netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, + newTotalInvestment + }; + } + + private getCurrency(symbol: string) { + if (!this.holdingCurrencies[symbol]) { + this.holdingCurrencies[symbol] = this.activities.find( + (a) => a.SymbolProfile.symbol === symbol + ).SymbolProfile.currency; + } + + return this.holdingCurrencies[symbol]; + } + + private getHoldings(start: Date, end: Date) { + if ( + this.holdings && + Object.keys(this.holdings).some((h) => parseDate(h) >= end) && + Object.keys(this.holdings).some((h) => parseDate(h) <= start) + ) { + return this.holdings; + } + + this.computeHoldings(start, end); + return this.holdings; + } + + private computeHoldings(start: Date, end: Date) { + const investmentByDate = this.getInvestmentByDate(); + const transactionDates = Object.keys(investmentByDate).sort(); + let dates = eachDayOfInterval({ start, end }, { step: 1 }) + .map((date) => { + return resetHours(date); + }) + .sort((a, b) => a.getTime() - b.getTime()); + let currentHoldings: { [date: string]: { [symbol: string]: Big } } = {}; + + this.calculateInitialHoldings(investmentByDate, start, currentHoldings); + + for (let i = 1; i < dates.length; i++) { + const dateString = format(dates[i], DATE_FORMAT); + const previousDateString = format(dates[i - 1], DATE_FORMAT); + if (transactionDates.some((d) => d === dateString)) { + let holdings = { ...currentHoldings[previousDateString] }; + investmentByDate[dateString].forEach((trade) => { + holdings[trade.SymbolProfile.symbol] ??= new Big(0); + holdings[trade.SymbolProfile.symbol] = holdings[ + trade.SymbolProfile.symbol + ].plus(trade.quantity.mul(getFactor(trade.type))); + }); + currentHoldings[dateString] = holdings; + } else { + currentHoldings[dateString] = currentHoldings[previousDateString]; + } + } + + this.holdings = currentHoldings; + } + + private calculateInitialHoldings( + investmentByDate: { [date: string]: PortfolioOrder[] }, + start: Date, + currentHoldings: { [date: string]: { [symbol: string]: Big } } + ) { + const preRangeTrades = Object.keys(investmentByDate) + .filter((date) => resetHours(new Date(date)) <= start) + .map((date) => investmentByDate[date]) + .reduce((a, b) => a.concat(b), []) + .reduce((groupBySymbol, trade) => { + if (!groupBySymbol[trade.SymbolProfile.symbol]) { + groupBySymbol[trade.SymbolProfile.symbol] = []; + } + + groupBySymbol[trade.SymbolProfile.symbol].push(trade); + + return groupBySymbol; + }, {}); + + currentHoldings[format(start, DATE_FORMAT)] = {}; + + for (const symbol of Object.keys(preRangeTrades)) { + const trades: PortfolioOrder[] = preRangeTrades[symbol]; + let startQuantity = trades.reduce((sum, trade) => { + return sum.plus(trade.quantity.mul(getFactor(trade.type))); + }, new Big(0)); + currentHoldings[format(start, DATE_FORMAT)][symbol] = startQuantity; + } + } + + private getInvestmentByDate(): { [date: string]: PortfolioOrder[] } { + return this.activities.reduce((groupedByDate, order) => { + if (!groupedByDate[order.date]) { + groupedByDate[order.date] = []; + } + + groupedByDate[order.date].push(order); + + return groupedByDate; + }, {}); + } +} diff --git a/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts new file mode 100644 index 000000000..5d168b619 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts @@ -0,0 +1,33 @@ +import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces'; +import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; + +export class MWRPortfolioCalculator extends PortfolioCalculator { + protected calculateOverallPerformance( + positions: TimelinePosition[] + ): PortfolioSnapshot { + throw new Error('Method not implemented.'); + } + + protected getSymbolMetrics({ + dataSource, + end, + exchangeRates, + isChartMode = false, + marketSymbolMap, + start, + step = 1, + symbol + }: { + end: Date; + exchangeRates: { [dateString: string]: number }; + isChartMode?: boolean; + marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + }; + start: Date; + step?: number; + } & UniqueAsset): SymbolMetrics { + throw new Error('Method not implemented.'); + } +} diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts new file mode 100644 index 000000000..51ad40c31 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts @@ -0,0 +1,30 @@ +export const activityDummyData = { + accountId: undefined, + accountUserId: undefined, + comment: undefined, + createdAt: new Date(), + currency: undefined, + feeInBaseCurrency: undefined, + id: undefined, + isDraft: false, + symbolProfileId: undefined, + updatedAt: new Date(), + userId: undefined, + value: undefined, + valueInBaseCurrency: undefined +}; + +export const symbolProfileDummyData = { + activitiesCount: undefined, + assetClass: undefined, + assetSubClass: undefined, + countries: [], + createdAt: undefined, + id: undefined, + sectors: [], + updatedAt: undefined +}; + +export const userDummyData = { + id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' +}; diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts new file mode 100644 index 000000000..89181ea12 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts @@ -0,0 +1,96 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; +import { DateRange, UserWithSettings } from '@ghostfolio/common/types'; + +import { Injectable } from '@nestjs/common'; + +import { CPRPortfolioCalculator } from './constantPortfolioReturn/portfolio-calculator'; +import { MWRPortfolioCalculator } from './mwr/portfolio-calculator'; +import { PortfolioCalculator } from './portfolio-calculator'; +import { TWRPortfolioCalculator } from './twr/portfolio-calculator'; + +export enum PerformanceCalculationType { + MWR = 'MWR', // Money-Weighted Rate of Return + TWR = 'TWR', // Time-Weighted Rate of Return + CPR = 'CPR' // Constant Portfolio Rate of Return +} + +@Injectable() +export class PortfolioCalculatorFactory { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly currentRateService: CurrentRateService, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly redisCacheService: RedisCacheService + ) {} + + public createCalculator({ + accountBalanceItems = [], + activities, + calculationType, + currency, + dateRange = 'max', + hasFilters, + isExperimentalFeatures = false, + userId + }: { + accountBalanceItems?: HistoricalDataItem[]; + activities: Activity[]; + calculationType: PerformanceCalculationType; + currency: string; + dateRange?: DateRange; + hasFilters: boolean; + isExperimentalFeatures?: boolean; + userId: string; + }): PortfolioCalculator { + const useCache = !hasFilters && isExperimentalFeatures; + + switch (calculationType) { + case PerformanceCalculationType.MWR: + return new MWRPortfolioCalculator({ + accountBalanceItems, + activities, + currency, + dateRange, + useCache, + userId, + configurationService: this.configurationService, + currentRateService: this.currentRateService, + exchangeRateDataService: this.exchangeRateDataService, + redisCacheService: this.redisCacheService + }); + case PerformanceCalculationType.TWR: + return new TWRPortfolioCalculator({ + accountBalanceItems, + activities, + currency, + currentRateService: this.currentRateService, + dateRange, + useCache, + userId, + configurationService: this.configurationService, + exchangeRateDataService: this.exchangeRateDataService, + redisCacheService: this.redisCacheService + }); + case PerformanceCalculationType.CPR: + return new CPRPortfolioCalculator({ + accountBalanceItems, + activities, + currency, + currentRateService: this.currentRateService, + dateRange, + useCache, + userId, + configurationService: this.configurationService, + exchangeRateDataService: this.exchangeRateDataService, + redisCacheService: this.redisCacheService + }); + default: + throw new Error('Invalid calculation type'); + } + } +} diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts new file mode 100644 index 000000000..274f4cffa --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -0,0 +1,1108 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; +import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface'; +import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { + getFactor, + getInterval +} from '@ghostfolio/api/helper/portfolio.helper'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { MAX_CHART_ITEMS } from '@ghostfolio/common/config'; +import { + DATE_FORMAT, + getSum, + parseDate, + resetHours +} from '@ghostfolio/common/helper'; +import { + DataProviderInfo, + HistoricalDataItem, + InvestmentItem, + ResponseError, + SymbolMetrics, + UniqueAsset +} from '@ghostfolio/common/interfaces'; +import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; +import { DateRange, GroupBy } from '@ghostfolio/common/types'; + +import { Logger } from '@nestjs/common'; +import { Big } from 'big.js'; +import { plainToClass } from 'class-transformer'; +import { + differenceInDays, + eachDayOfInterval, + endOfDay, + format, + isAfter, + isBefore, + isSameDay, + max, + min, + subDays +} from 'date-fns'; +import { first, last, uniq, uniqBy } from 'lodash'; + +export abstract class PortfolioCalculator { + protected static readonly ENABLE_LOGGING = false; + + protected accountBalanceItems: HistoricalDataItem[]; + protected activities: PortfolioOrder[]; + + private configurationService: ConfigurationService; + protected currency: string; + private currentRateService: CurrentRateService; + private dataProviderInfos: DataProviderInfo[]; + private dateRange: DateRange; + private endDate: Date; + protected exchangeRateDataService: ExchangeRateDataService; + private redisCacheService: RedisCacheService; + private snapshot: PortfolioSnapshot; + private snapshotPromise: Promise; + private startDate: Date; + private transactionPoints: TransactionPoint[]; + private useCache: boolean; + private userId: string; + protected marketMap: { [date: string]: { [symbol: string]: Big } } = {}; + + public constructor({ + accountBalanceItems, + activities, + configurationService, + currency, + currentRateService, + dateRange, + exchangeRateDataService, + redisCacheService, + useCache, + userId + }: { + accountBalanceItems: HistoricalDataItem[]; + activities: Activity[]; + configurationService: ConfigurationService; + currency: string; + currentRateService: CurrentRateService; + dateRange: DateRange; + exchangeRateDataService: ExchangeRateDataService; + redisCacheService: RedisCacheService; + useCache: boolean; + userId: string; + }) { + this.accountBalanceItems = accountBalanceItems; + this.configurationService = configurationService; + this.currency = currency; + this.currentRateService = currentRateService; + this.dateRange = dateRange; + this.exchangeRateDataService = exchangeRateDataService; + + this.activities = activities + .map( + ({ + date, + fee, + quantity, + SymbolProfile, + tags = [], + type, + unitPrice + }) => { + if (isAfter(date, new Date(Date.now()))) { + // Adapt date to today if activity is in future (e.g. liability) + // to include it in the interval + date = endOfDay(new Date(Date.now())); + } + + return { + SymbolProfile, + tags, + type, + date: format(date, DATE_FORMAT), + fee: new Big(fee), + quantity: new Big(quantity), + unitPrice: new Big(unitPrice) + }; + } + ) + .sort((a, b) => { + return a.date?.localeCompare(b.date); + }); + + this.redisCacheService = redisCacheService; + this.useCache = useCache; + this.userId = userId; + + const { endDate, startDate } = getInterval(dateRange); + + this.endDate = endDate; + this.startDate = startDate; + + this.computeTransactionPoints(); + + this.snapshotPromise = this.initialize(); + } + + protected abstract calculateOverallPerformance( + positions: TimelinePosition[] + ): PortfolioSnapshot; + + public async computeSnapshot( + start: Date, + end?: Date + ): Promise { + const lastTransactionPoint = last(this.transactionPoints); + + let endDate = end; + + if (!endDate) { + endDate = new Date(Date.now()); + + if (lastTransactionPoint) { + endDate = max([endDate, parseDate(lastTransactionPoint.date)]); + } + } + + const transactionPoints = this.transactionPoints?.filter(({ date }) => { + return isBefore(parseDate(date), endDate); + }); + + if (!transactionPoints.length) { + return { + currentValueInBaseCurrency: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + hasErrors: false, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffect: new Big(0), + netPerformanceWithCurrencyEffect: new Big(0), + positions: [], + totalFeesWithCurrencyEffect: new Big(0), + totalInterestWithCurrencyEffect: new Big(0), + totalInvestment: new Big(0), + totalInvestmentWithCurrencyEffect: new Big(0), + totalLiabilitiesWithCurrencyEffect: new Big(0), + totalValuablesWithCurrencyEffect: new Big(0) + }; + } + + const currencies: { [symbol: string]: string } = {}; + const dataGatheringItems: IDataGatheringItem[] = []; + let dates: Date[] = []; + let firstIndex = transactionPoints.length; + let firstTransactionPoint: TransactionPoint = null; + let totalInterestWithCurrencyEffect = new Big(0); + let totalLiabilitiesWithCurrencyEffect = new Big(0); + let totalValuablesWithCurrencyEffect = new Big(0); + + dates.push(resetHours(start)); + + for (const { currency, dataSource, symbol } of transactionPoints[ + firstIndex - 1 + ].items) { + dataGatheringItems.push({ + dataSource, + symbol + }); + + currencies[symbol] = currency; + } + + for (let i = 0; i < transactionPoints.length; i++) { + if ( + !isBefore(parseDate(transactionPoints[i].date), start) && + firstTransactionPoint === null + ) { + firstTransactionPoint = transactionPoints[i]; + firstIndex = i; + } + + if (firstTransactionPoint !== null) { + dates.push(resetHours(parseDate(transactionPoints[i].date))); + } + } + + dates.push(resetHours(endDate)); + + // Add dates of last week for fallback + dates.push(subDays(resetHours(new Date()), 7)); + dates.push(subDays(resetHours(new Date()), 6)); + dates.push(subDays(resetHours(new Date()), 5)); + dates.push(subDays(resetHours(new Date()), 4)); + dates.push(subDays(resetHours(new Date()), 3)); + dates.push(subDays(resetHours(new Date()), 2)); + dates.push(subDays(resetHours(new Date()), 1)); + dates.push(resetHours(new Date())); + + dates = uniq( + dates.map((date) => { + return date.getTime(); + }) + ) + .map((timestamp) => { + return new Date(timestamp); + }) + .sort((a, b) => { + return a.getTime() - b.getTime(); + }); + + let exchangeRatesByCurrency = + await this.exchangeRateDataService.getExchangeRatesByCurrency({ + currencies: uniq(Object.values(currencies)), + endDate: endOfDay(endDate), + startDate: this.getStartDate(), + targetCurrency: this.currency + }); + + const { + dataProviderInfos, + errors: currentRateErrors, + values: marketSymbols + } = await this.currentRateService.getValues({ + dataGatheringItems, + dateQuery: { + in: dates + } + }); + + this.dataProviderInfos = dataProviderInfos; + + const marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + } = {}; + + for (const marketSymbol of marketSymbols) { + const date = format(marketSymbol.date, DATE_FORMAT); + + if (!marketSymbolMap[date]) { + marketSymbolMap[date] = {}; + } + + if (marketSymbol.marketPrice) { + marketSymbolMap[date][marketSymbol.symbol] = new Big( + marketSymbol.marketPrice + ); + } + } + + this.marketMap = marketSymbolMap; + + const endDateString = format(endDate, DATE_FORMAT); + + if (firstIndex > 0) { + firstIndex--; + } + + const positions: TimelinePosition[] = []; + let hasAnySymbolMetricsErrors = false; + + const errors: ResponseError['errors'] = []; + + for (const item of lastTransactionPoint.items) { + const marketPriceInBaseCurrency = ( + marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice + ).mul( + exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[ + endDateString + ] + ); + + const { + grossPerformance, + grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, + grossPerformanceWithCurrencyEffect, + hasErrors, + netPerformance, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffect, + netPerformanceWithCurrencyEffect, + timeWeightedInvestment, + timeWeightedInvestmentWithCurrencyEffect, + totalDividend, + totalDividendInBaseCurrency, + totalInterestInBaseCurrency, + totalInvestment, + totalInvestmentWithCurrencyEffect, + totalLiabilitiesInBaseCurrency, + totalValuablesInBaseCurrency + } = this.getSymbolMetrics({ + marketSymbolMap, + start, + dataSource: item.dataSource, + end: endDate, + exchangeRates: + exchangeRatesByCurrency[`${item.currency}${this.currency}`], + symbol: item.symbol + }); + + hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; + + positions.push({ + dividend: totalDividend, + dividendInBaseCurrency: totalDividendInBaseCurrency, + timeWeightedInvestment, + timeWeightedInvestmentWithCurrencyEffect, + averagePrice: item.averagePrice, + currency: item.currency, + dataSource: item.dataSource, + fee: item.fee, + firstBuyDate: item.firstBuyDate, + grossPerformance: !hasErrors ? grossPerformance ?? null : null, + grossPerformancePercentage: !hasErrors + ? grossPerformancePercentage ?? null + : null, + grossPerformancePercentageWithCurrencyEffect: !hasErrors + ? grossPerformancePercentageWithCurrencyEffect ?? null + : null, + grossPerformanceWithCurrencyEffect: !hasErrors + ? grossPerformanceWithCurrencyEffect ?? null + : null, + investment: totalInvestment, + investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, + marketPrice: + marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null, + marketPriceInBaseCurrency: + marketPriceInBaseCurrency?.toNumber() ?? null, + netPerformance: !hasErrors ? netPerformance ?? null : null, + netPerformancePercentage: !hasErrors + ? netPerformancePercentage ?? null + : null, + netPerformancePercentageWithCurrencyEffect: !hasErrors + ? netPerformancePercentageWithCurrencyEffect ?? null + : null, + netPerformanceWithCurrencyEffect: !hasErrors + ? netPerformanceWithCurrencyEffect ?? null + : null, + quantity: item.quantity, + symbol: item.symbol, + tags: item.tags, + transactionCount: item.transactionCount, + valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul( + item.quantity + ) + }); + + totalInterestWithCurrencyEffect = totalInterestWithCurrencyEffect.plus( + totalInterestInBaseCurrency + ); + + 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) + ) { + errors.push({ dataSource: item.dataSource, symbol: item.symbol }); + } + } + + const overall = this.calculateOverallPerformance(positions); + + return { + ...overall, + errors, + positions, + totalInterestWithCurrencyEffect, + totalLiabilitiesWithCurrencyEffect, + totalValuablesWithCurrencyEffect, + hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors + }; + } + + public async getChart({ + dateRange = 'max', + withDataDecimation = true + }: { + dateRange?: DateRange; + withDataDecimation?: boolean; + }): Promise { + const { endDate, startDate } = getInterval(dateRange, this.getStartDate()); + + const daysInMarket = differenceInDays(endDate, startDate) + 1; + const step = withDataDecimation + ? Math.round(daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS)) + : 1; + + return this.getChartData({ + step, + end: endDate, + start: startDate + }); + } + + public async getChartData({ + end = new Date(Date.now()), + start, + step = 1 + }: { + end?: Date; + start: Date; + step?: number; + }): Promise { + const symbols: { [symbol: string]: boolean } = {}; + + const transactionPointsBeforeEndDate = + this.transactionPoints?.filter((transactionPoint) => { + return isBefore(parseDate(transactionPoint.date), end); + }) ?? []; + + const currencies: { [symbol: string]: string } = {}; + const dataGatheringItems: IDataGatheringItem[] = []; + const firstIndex = transactionPointsBeforeEndDate.length; + + let dates = eachDayOfInterval({ start, end }, { step }).map((date) => { + return resetHours(date); + }); + + const includesEndDate = isSameDay(last(dates), end); + + if (!includesEndDate) { + dates.push(resetHours(end)); + } + + if (transactionPointsBeforeEndDate.length > 0) { + for (const { + currency, + dataSource, + symbol + } of transactionPointsBeforeEndDate[firstIndex - 1].items) { + dataGatheringItems.push({ + dataSource, + symbol + }); + currencies[symbol] = currency; + symbols[symbol] = true; + } + } + + const { dataProviderInfos, values: marketSymbols } = + await this.currentRateService.getValues({ + dataGatheringItems, + dateQuery: { + in: [ + ...dates, + ...this.transactionPoints.map(({ date }) => + resetHours(parseDate(date)) + ) + ] + } + }); + + this.dataProviderInfos = dataProviderInfos; + + const marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + } = {}; + + let exchangeRatesByCurrency = + await this.exchangeRateDataService.getExchangeRatesByCurrency({ + currencies: uniq(Object.values(currencies)), + endDate: endOfDay(end), + startDate: this.getStartDate(), + targetCurrency: this.currency + }); + + for (const marketSymbol of marketSymbols) { + const dateString = format(marketSymbol.date, DATE_FORMAT); + if (!marketSymbolMap[dateString]) { + marketSymbolMap[dateString] = {}; + } + if (marketSymbol.marketPrice) { + marketSymbolMap[dateString][marketSymbol.symbol] = new Big( + marketSymbol.marketPrice + ); + } + } + + const accumulatedValuesByDate: { + [date: string]: { + investmentValueWithCurrencyEffect: Big; + totalCurrentValue: Big; + totalCurrentValueWithCurrencyEffect: Big; + totalAccountBalanceWithCurrencyEffect: Big; + totalInvestmentValue: Big; + totalInvestmentValueWithCurrencyEffect: Big; + totalNetPerformanceValue: Big; + totalNetPerformanceValueWithCurrencyEffect: Big; + totalTimeWeightedInvestmentValue: Big; + totalTimeWeightedInvestmentValueWithCurrencyEffect: Big; + }; + } = {}; + + const valuesBySymbol: { + [symbol: string]: { + currentValues: { [date: string]: Big }; + currentValuesWithCurrencyEffect: { [date: string]: Big }; + investmentValuesAccumulated: { [date: string]: Big }; + investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big }; + investmentValuesWithCurrencyEffect: { [date: string]: Big }; + netPerformanceValues: { [date: string]: Big }; + netPerformanceValuesWithCurrencyEffect: { [date: string]: Big }; + timeWeightedInvestmentValues: { [date: string]: Big }; + timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big }; + }; + } = {}; + + for (const symbol of Object.keys(symbols)) { + const { + currentValues, + currentValuesWithCurrencyEffect, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect + } = this.getSymbolMetrics({ + end, + marketSymbolMap, + start, + step, + symbol, + dataSource: null, + exchangeRates: + exchangeRatesByCurrency[`${currencies[symbol]}${this.currency}`], + isChartMode: true + }); + + valuesBySymbol[symbol] = { + currentValues, + currentValuesWithCurrencyEffect, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect + }; + } + + let lastDate = format(this.startDate, DATE_FORMAT); + + for (const currentDate of dates) { + const dateString = format(currentDate, DATE_FORMAT); + + accumulatedValuesByDate[dateString] = { + investmentValueWithCurrencyEffect: new Big(0), + totalAccountBalanceWithCurrencyEffect: new Big(0), + totalCurrentValue: new Big(0), + totalCurrentValueWithCurrencyEffect: new Big(0), + totalInvestmentValue: new Big(0), + totalInvestmentValueWithCurrencyEffect: new Big(0), + totalNetPerformanceValue: new Big(0), + totalNetPerformanceValueWithCurrencyEffect: new Big(0), + totalTimeWeightedInvestmentValue: new Big(0), + totalTimeWeightedInvestmentValueWithCurrencyEffect: new Big(0) + }; + + for (const symbol of Object.keys(valuesBySymbol)) { + const symbolValues = valuesBySymbol[symbol]; + + const currentValue = + symbolValues.currentValues?.[dateString] ?? new Big(0); + + const currentValueWithCurrencyEffect = + symbolValues.currentValuesWithCurrencyEffect?.[dateString] ?? + new Big(0); + + const investmentValueAccumulated = + symbolValues.investmentValuesAccumulated?.[dateString] ?? new Big(0); + + const investmentValueAccumulatedWithCurrencyEffect = + symbolValues.investmentValuesAccumulatedWithCurrencyEffect?.[ + dateString + ] ?? new Big(0); + + const investmentValueWithCurrencyEffect = + symbolValues.investmentValuesWithCurrencyEffect?.[dateString] ?? + new Big(0); + + const netPerformanceValue = + symbolValues.netPerformanceValues?.[dateString] ?? new Big(0); + + const netPerformanceValueWithCurrencyEffect = + symbolValues.netPerformanceValuesWithCurrencyEffect?.[dateString] ?? + new Big(0); + + const timeWeightedInvestmentValue = + symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0); + + const timeWeightedInvestmentValueWithCurrencyEffect = + symbolValues.timeWeightedInvestmentValuesWithCurrencyEffect?.[ + dateString + ] ?? new Big(0); + + accumulatedValuesByDate[dateString].investmentValueWithCurrencyEffect = + accumulatedValuesByDate[ + dateString + ].investmentValueWithCurrencyEffect.add( + investmentValueWithCurrencyEffect + ); + + accumulatedValuesByDate[dateString].totalCurrentValue = + accumulatedValuesByDate[dateString].totalCurrentValue.add( + currentValue + ); + + accumulatedValuesByDate[ + dateString + ].totalCurrentValueWithCurrencyEffect = accumulatedValuesByDate[ + dateString + ].totalCurrentValueWithCurrencyEffect.add( + currentValueWithCurrencyEffect + ); + + accumulatedValuesByDate[dateString].totalInvestmentValue = + accumulatedValuesByDate[dateString].totalInvestmentValue.add( + investmentValueAccumulated + ); + + accumulatedValuesByDate[ + dateString + ].totalInvestmentValueWithCurrencyEffect = accumulatedValuesByDate[ + dateString + ].totalInvestmentValueWithCurrencyEffect.add( + investmentValueAccumulatedWithCurrencyEffect + ); + + accumulatedValuesByDate[dateString].totalNetPerformanceValue = + accumulatedValuesByDate[dateString].totalNetPerformanceValue.add( + netPerformanceValue + ); + + accumulatedValuesByDate[ + dateString + ].totalNetPerformanceValueWithCurrencyEffect = accumulatedValuesByDate[ + dateString + ].totalNetPerformanceValueWithCurrencyEffect.add( + netPerformanceValueWithCurrencyEffect + ); + + accumulatedValuesByDate[dateString].totalTimeWeightedInvestmentValue = + accumulatedValuesByDate[ + dateString + ].totalTimeWeightedInvestmentValue.add(timeWeightedInvestmentValue); + + accumulatedValuesByDate[ + dateString + ].totalTimeWeightedInvestmentValueWithCurrencyEffect = + accumulatedValuesByDate[ + dateString + ].totalTimeWeightedInvestmentValueWithCurrencyEffect.add( + timeWeightedInvestmentValueWithCurrencyEffect + ); + } + + if ( + this.accountBalanceItems.some(({ date }) => { + return date === dateString; + }) + ) { + accumulatedValuesByDate[ + dateString + ].totalAccountBalanceWithCurrencyEffect = new Big( + this.accountBalanceItems.find(({ date }) => { + return date === dateString; + }).value + ); + } else { + accumulatedValuesByDate[ + dateString + ].totalAccountBalanceWithCurrencyEffect = + accumulatedValuesByDate[lastDate] + ?.totalAccountBalanceWithCurrencyEffect ?? new Big(0); + } + + lastDate = dateString; + } + + return Object.entries(accumulatedValuesByDate).map(([date, values]) => { + const { + investmentValueWithCurrencyEffect, + totalAccountBalanceWithCurrencyEffect, + totalCurrentValue, + totalCurrentValueWithCurrencyEffect, + totalInvestmentValue, + totalInvestmentValueWithCurrencyEffect, + totalNetPerformanceValue, + totalNetPerformanceValueWithCurrencyEffect, + totalTimeWeightedInvestmentValue, + totalTimeWeightedInvestmentValueWithCurrencyEffect + } = values; + + const netPerformanceInPercentage = totalTimeWeightedInvestmentValue.eq(0) + ? 0 + : totalNetPerformanceValue + .div(totalTimeWeightedInvestmentValue) + .mul(100) + .toNumber(); + + const netPerformanceInPercentageWithCurrencyEffect = + totalTimeWeightedInvestmentValueWithCurrencyEffect.eq(0) + ? 0 + : totalNetPerformanceValueWithCurrencyEffect + .div(totalTimeWeightedInvestmentValueWithCurrencyEffect) + .mul(100) + .toNumber(); + + return { + date, + netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, + investmentValueWithCurrencyEffect: + investmentValueWithCurrencyEffect.toNumber(), + netPerformance: totalNetPerformanceValue.toNumber(), + netPerformanceWithCurrencyEffect: + totalNetPerformanceValueWithCurrencyEffect.toNumber(), + // TODO: Add valuables + netWorth: totalCurrentValueWithCurrencyEffect + .plus(totalAccountBalanceWithCurrencyEffect) + .toNumber(), + totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(), + totalInvestment: totalInvestmentValue.toNumber(), + totalInvestmentValueWithCurrencyEffect: + totalInvestmentValueWithCurrencyEffect.toNumber(), + value: totalCurrentValue.toNumber(), + valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber() + }; + }); + } + + public getDataProviderInfos() { + return this.dataProviderInfos; + } + + public async getDividendInBaseCurrency() { + await this.snapshotPromise; + + return getSum( + this.snapshot.positions.map(({ dividendInBaseCurrency }) => { + return dividendInBaseCurrency; + }) + ); + } + + public async getFeesInBaseCurrency() { + await this.snapshotPromise; + + return this.snapshot.totalFeesWithCurrencyEffect; + } + + public async getInterestInBaseCurrency() { + await this.snapshotPromise; + + return this.snapshot.totalInterestWithCurrencyEffect; + } + + public getInvestments(): { date: string; investment: Big }[] { + if (this.transactionPoints.length === 0) { + return []; + } + + return this.transactionPoints.map((transactionPoint) => { + return { + date: transactionPoint.date, + investment: transactionPoint.items.reduce( + (investment, transactionPointSymbol) => + investment.plus(transactionPointSymbol.investment), + new Big(0) + ) + }; + }); + } + + public getInvestmentsByGroup({ + data, + groupBy + }: { + data: HistoricalDataItem[]; + groupBy: GroupBy; + }): InvestmentItem[] { + const groupedData: { [dateGroup: string]: Big } = {}; + + for (const { date, investmentValueWithCurrencyEffect } of data) { + const dateGroup = + groupBy === 'month' ? date.substring(0, 7) : date.substring(0, 4); + groupedData[dateGroup] = (groupedData[dateGroup] ?? new Big(0)).plus( + investmentValueWithCurrencyEffect + ); + } + + return Object.keys(groupedData).map((dateGroup) => ({ + date: groupBy === 'month' ? `${dateGroup}-01` : `${dateGroup}-01-01`, + investment: groupedData[dateGroup].toNumber() + })); + } + + public async getLiabilitiesInBaseCurrency() { + await this.snapshotPromise; + + return this.snapshot.totalLiabilitiesWithCurrencyEffect; + } + + public async getSnapshot() { + await this.snapshotPromise; + + return this.snapshot; + } + + public getStartDate() { + let firstAccountBalanceDate: Date; + let firstActivityDate: Date; + + try { + const firstAccountBalanceDateString = first( + this.accountBalanceItems + )?.date; + firstAccountBalanceDate = firstAccountBalanceDateString + ? parseDate(firstAccountBalanceDateString) + : new Date(); + } catch (error) { + firstAccountBalanceDate = new Date(); + } + + try { + const firstActivityDateString = this.transactionPoints[0].date; + firstActivityDate = firstActivityDateString + ? parseDate(firstActivityDateString) + : new Date(); + } catch (error) { + firstActivityDate = new Date(); + } + + return min([firstAccountBalanceDate, firstActivityDate]); + } + + protected abstract getSymbolMetrics({ + dataSource, + end, + exchangeRates, + isChartMode, + marketSymbolMap, + start, + step, + symbol + }: { + end: Date; + exchangeRates: { [dateString: string]: number }; + isChartMode?: boolean; + marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + }; + start: Date; + step?: number; + } & UniqueAsset): SymbolMetrics; + + public getTransactionPoints() { + return this.transactionPoints; + } + + public async getValuablesInBaseCurrency() { + await this.snapshotPromise; + + return this.snapshot.totalValuablesWithCurrencyEffect; + } + + private computeTransactionPoints() { + this.transactionPoints = []; + const symbols: { [symbol: string]: TransactionPointSymbol } = {}; + + let lastDate: string = null; + let lastTransactionPoint: TransactionPoint = null; + + for (const { + fee, + date, + quantity, + SymbolProfile, + tags, + type, + unitPrice + } of this.activities) { + let currentTransactionPointItem: TransactionPointSymbol; + const oldAccumulatedSymbol = symbols[SymbolProfile.symbol]; + + const factor = getFactor(type); + + if (oldAccumulatedSymbol) { + let investment = oldAccumulatedSymbol.investment; + + const newQuantity = quantity + .mul(factor) + .plus(oldAccumulatedSymbol.quantity); + + if (type === 'BUY') { + investment = oldAccumulatedSymbol.investment.plus( + quantity.mul(unitPrice) + ); + } else if (type === 'SELL') { + investment = oldAccumulatedSymbol.investment.minus( + quantity.mul(oldAccumulatedSymbol.averagePrice) + ); + } + + currentTransactionPointItem = { + investment, + 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, + quantity: newQuantity, + symbol: SymbolProfile.symbol, + tags: oldAccumulatedSymbol.tags.concat(tags), + transactionCount: oldAccumulatedSymbol.transactionCount + 1 + }; + } else { + currentTransactionPointItem = { + fee, + tags, + averagePrice: unitPrice, + currency: SymbolProfile.currency, + dataSource: SymbolProfile.dataSource, + dividend: new Big(0), + firstBuyDate: date, + investment: unitPrice.mul(quantity).mul(factor), + quantity: quantity.mul(factor), + symbol: SymbolProfile.symbol, + transactionCount: 1 + }; + } + + currentTransactionPointItem.tags = uniqBy( + currentTransactionPointItem.tags, + 'id' + ); + + symbols[SymbolProfile.symbol] = currentTransactionPointItem; + + const items = lastTransactionPoint?.items ?? []; + + const newItems = items.filter(({ symbol }) => { + return symbol !== SymbolProfile.symbol; + }); + + newItems.push(currentTransactionPointItem); + + newItems.sort((a, b) => { + return a.symbol?.localeCompare(b.symbol); + }); + + let fees = new Big(0); + + if (type === 'FEE') { + fees = fee; + } + + let interest = new Big(0); + + if (type === 'INTEREST') { + interest = quantity.mul(unitPrice); + } + + let liabilities = new Big(0); + + if (type === 'LIABILITY') { + 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 + }; + + this.transactionPoints.push(lastTransactionPoint); + } else { + lastTransactionPoint.fees = lastTransactionPoint.fees.plus(fees); + lastTransactionPoint.interest = + lastTransactionPoint.interest.plus(interest); + lastTransactionPoint.items = newItems; + lastTransactionPoint.liabilities = + lastTransactionPoint.liabilities.plus(liabilities); + lastTransactionPoint.valuables = + lastTransactionPoint.valuables.plus(valuables); + } + + lastDate = date; + } + } + + private async initialize() { + if (this.useCache) { + const startTimeTotal = performance.now(); + + const cachedSnapshot = await this.redisCacheService.get( + this.redisCacheService.getPortfolioSnapshotKey({ + userId: this.userId + }) + ); + + if (cachedSnapshot) { + this.snapshot = plainToClass( + PortfolioSnapshot, + JSON.parse(cachedSnapshot) + ); + + Logger.debug( + `Fetched portfolio snapshot from cache in ${( + (performance.now() - startTimeTotal) / + 1000 + ).toFixed(3)} seconds`, + 'PortfolioCalculator' + ); + } else { + this.snapshot = await this.computeSnapshot( + this.startDate, + this.endDate + ); + + this.redisCacheService.set( + this.redisCacheService.getPortfolioSnapshotKey({ + userId: this.userId + }), + JSON.stringify(this.snapshot), + this.configurationService.get('CACHE_QUOTES_TTL') + ); + + Logger.debug( + `Computed portfolio snapshot in ${( + (performance.now() - startTimeTotal) / + 1000 + ).toFixed(3)} seconds`, + 'PortfolioCalculator' + ); + } + } else { + this.snapshot = await this.computeSnapshot(this.startDate, this.endDate); + } + } +} diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts new file mode 100644 index 000000000..340f16b87 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts @@ -0,0 +1,218 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PortfolioCalculatorFactory, + PerformanceCalculationType +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BALN.SW buy and sell in two activities', async () => { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-11-22'), + fee: 1.55, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'BUY', + unitPrice: 142.9 + }, + { + ...activityDummyData, + date: new Date('2021-11-30'), + fee: 1.65, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'SELL', + unitPrice: 136.6 + }, + { + ...activityDummyData, + date: new Date('2021-11-30'), + fee: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'SELL', + unitPrice: 136.6 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'CHF', + hasFilters: false, + userId: userDummyData.id + }); + + const chartData = await portfolioCalculator.getChartData({ + start: parseDate('2021-11-22') + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot( + parseDate('2021-11-22') + ); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: chartData, + groupBy: 'month' + }); + + spy.mockRestore(); + + expect(portfolioSnapshot).toEqual({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + grossPerformance: new Big('-12.6'), + grossPerformancePercentage: new Big('-0.04408677396780965649'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '-0.04408677396780965649' + ), + grossPerformanceWithCurrencyEffect: new Big('-12.6'), + hasErrors: false, + netPerformance: new Big('-15.8'), + netPerformancePercentage: new Big('-0.05528341497550734703'), + netPerformancePercentageWithCurrencyEffect: new Big( + '-0.05528341497550734703' + ), + netPerformanceWithCurrencyEffect: new Big('-15.8'), + positions: [ + { + averagePrice: new Big('0'), + currency: 'CHF', + dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('3.2'), + firstBuyDate: '2021-11-22', + grossPerformance: new Big('-12.6'), + grossPerformancePercentage: new Big('-0.04408677396780965649'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '-0.04408677396780965649' + ), + grossPerformanceWithCurrencyEffect: new Big('-12.6'), + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + netPerformance: new Big('-15.8'), + netPerformancePercentage: new Big('-0.05528341497550734703'), + netPerformancePercentageWithCurrencyEffect: new Big( + '-0.05528341497550734703' + ), + netPerformanceWithCurrencyEffect: new Big('-15.8'), + marketPrice: 148.9, + marketPriceInBaseCurrency: 148.9, + quantity: new Big('0'), + symbol: 'BALN.SW', + tags: [], + timeWeightedInvestment: new Big('285.80000000000000396627'), + timeWeightedInvestmentWithCurrencyEffect: new Big( + '285.80000000000000396627' + ), + transactionCount: 3, + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('3.2'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(investments).toEqual([ + { date: '2021-11-22', investment: new Big('285.8') }, + { date: '2021-11-30', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2021-11-01', investment: 0 }, + { date: '2021-12-01', investment: 0 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts similarity index 57% rename from apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts rename to apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts index bd6cede88..53ebdf19f 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -1,11 +1,22 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PerformanceCalculationType, + PortfolioCalculatorFactory +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { parseDate } from '@ghostfolio/common/helper'; -import Big from 'big.js'; - -import { CurrentRateServiceMock } from './current-rate.service.mock'; -import { PortfolioCalculator } from './portfolio-calculator'; +import { Big } from 'big.js'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; beforeEach(() => { + configurationService = new ConfigurationService(); + currentRateService = new CurrentRateService(null, null, null, null); exchangeRateDataService = new ExchangeRateDataService( @@ -29,51 +54,69 @@ describe('PortfolioCalculator', () => { null, null ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); }); describe('get current positions', () => { it.only('with BALN.SW buy and sell', async () => { - const portfolioCalculator = new PortfolioCalculator({ - currentRateService, - exchangeRateDataService, - currency: 'CHF', - orders: [ - { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-11-22'), + fee: 1.55, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, currency: 'CHF', - date: '2021-11-22', dataSource: 'YAHOO', - fee: new Big(1.55), name: 'Bâloise Holding AG', - quantity: new Big(2), - symbol: 'BALN.SW', - type: 'BUY', - unitPrice: new Big(142.9) + symbol: 'BALN.SW' }, - { + type: 'BUY', + unitPrice: 142.9 + }, + { + ...activityDummyData, + date: new Date('2021-11-30'), + fee: 1.65, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, currency: 'CHF', - date: '2021-11-30', dataSource: 'YAHOO', - fee: new Big(1.65), name: 'Bâloise Holding AG', - quantity: new Big(2), - symbol: 'BALN.SW', - type: 'SELL', - unitPrice: new Big(136.6) - } - ] + symbol: 'BALN.SW' + }, + type: 'SELL', + unitPrice: 136.6 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'CHF', + hasFilters: false, + userId: userDummyData.id }); - portfolioCalculator.computeTransactionPoints(); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2021-12-18').getTime()); - const chartData = await portfolioCalculator.getChartData({ start: parseDate('2021-11-22') }); - const currentPositions = await portfolioCalculator.getCurrentPositions( + const portfolioSnapshot = await portfolioCalculator.computeSnapshot( parseDate('2021-11-22') ); @@ -86,8 +129,8 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); - expect(currentPositions).toEqual({ - currentValue: new Big('0'), + expect(portfolioSnapshot).toEqual({ + currentValueInBaseCurrency: new Big('0'), errors: [], grossPerformance: new Big('-12.6'), grossPerformancePercentage: new Big('-0.0440867739678096571'), @@ -107,6 +150,8 @@ describe('PortfolioCalculator', () => { averagePrice: new Big('0'), currency: 'CHF', dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), fee: new Big('3.2'), firstBuyDate: '2021-11-22', grossPerformance: new Big('-12.6'), @@ -127,14 +172,19 @@ describe('PortfolioCalculator', () => { marketPriceInBaseCurrency: 148.9, quantity: new Big('0'), symbol: 'BALN.SW', + tags: [], timeWeightedInvestment: new Big('285.8'), - tags: undefined, timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'), - transactionCount: 2 + transactionCount: 2, + valueInBaseCurrency: new Big('0') } ], + totalFeesWithCurrencyEffect: new Big('3.2'), + totalInterestWithCurrencyEffect: new Big('0'), totalInvestment: new Big('0'), - totalInvestmentWithCurrencyEffect: new Big('0') + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') }); expect(investments).toEqual([ diff --git a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts similarity index 58% rename from apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts rename to apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts index e77335ab8..bab265887 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts @@ -1,11 +1,22 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PortfolioCalculatorFactory, + PerformanceCalculationType +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { parseDate } from '@ghostfolio/common/helper'; -import Big from 'big.js'; - -import { CurrentRateServiceMock } from './current-rate.service.mock'; -import { PortfolioCalculator } from './portfolio-calculator'; +import { Big } from 'big.js'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; beforeEach(() => { + configurationService = new ConfigurationService(); + currentRateService = new CurrentRateService(null, null, null, null); exchangeRateDataService = new ExchangeRateDataService( @@ -29,40 +54,54 @@ describe('PortfolioCalculator', () => { null, null ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); }); describe('get current positions', () => { it.only('with BALN.SW buy', async () => { - const portfolioCalculator = new PortfolioCalculator({ - currentRateService, - exchangeRateDataService, - currency: 'CHF', - orders: [ - { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-11-30'), + fee: 1.55, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, currency: 'CHF', - date: '2021-11-30', dataSource: 'YAHOO', - fee: new Big(1.55), name: 'Bâloise Holding AG', - quantity: new Big(2), - symbol: 'BALN.SW', - type: 'BUY', - unitPrice: new Big(136.6) - } - ] + symbol: 'BALN.SW' + }, + type: 'BUY', + unitPrice: 136.6 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'CHF', + hasFilters: false, + userId: userDummyData.id }); - portfolioCalculator.computeTransactionPoints(); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2021-12-18').getTime()); - const chartData = await portfolioCalculator.getChartData({ start: parseDate('2021-11-30') }); - const currentPositions = await portfolioCalculator.getCurrentPositions( + const portfolioSnapshot = await portfolioCalculator.computeSnapshot( parseDate('2021-11-30') ); @@ -75,8 +114,8 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); - expect(currentPositions).toEqual({ - currentValue: new Big('297.8'), + expect(portfolioSnapshot).toEqual({ + currentValueInBaseCurrency: new Big('297.8'), errors: [], grossPerformance: new Big('24.6'), grossPerformancePercentage: new Big('0.09004392386530014641'), @@ -96,6 +135,8 @@ describe('PortfolioCalculator', () => { averagePrice: new Big('136.6'), currency: 'CHF', dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), fee: new Big('1.55'), firstBuyDate: '2021-11-30', grossPerformance: new Big('24.6'), @@ -116,13 +157,19 @@ describe('PortfolioCalculator', () => { marketPriceInBaseCurrency: 148.9, quantity: new Big('2'), symbol: 'BALN.SW', + tags: [], timeWeightedInvestment: new Big('273.2'), timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'), - transactionCount: 1 + transactionCount: 1, + valueInBaseCurrency: new Big('297.8') } ], + totalFeesWithCurrencyEffect: new Big('1.55'), + totalInterestWithCurrencyEffect: new Big('0'), totalInvestment: new Big('273.2'), - totalInvestmentWithCurrencyEffect: new Big('273.2') + totalInvestmentWithCurrencyEffect: new Big('273.2'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') }); expect(investments).toEqual([ diff --git a/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts similarity index 68% rename from apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts rename to apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index 8f5573928..eba5d4674 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -1,12 +1,23 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PortfolioCalculatorFactory, + PerformanceCalculationType +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; import { parseDate } from '@ghostfolio/common/helper'; -import Big from 'big.js'; - -import { CurrentRateServiceMock } from './current-rate.service.mock'; -import { PortfolioCalculator } from './portfolio-calculator'; +import { Big } from 'big.js'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -17,6 +28,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + jest.mock( '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', () => { @@ -30,10 +50,15 @@ jest.mock( ); describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; beforeEach(() => { + configurationService = new ConfigurationService(); + currentRateService = new CurrentRateService(null, null, null, null); exchangeRateDataService = new ExchangeRateDataService( @@ -42,51 +67,69 @@ describe('PortfolioCalculator', () => { null, null ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); }); describe('get current positions', () => { it.only('with BTCUSD buy and sell partially', async () => { - const portfolioCalculator = new PortfolioCalculator({ - currentRateService, - exchangeRateDataService, - currency: 'CHF', - orders: [ - { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2018-01-01').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2015-01-01'), + fee: 0, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, currency: 'USD', - date: '2015-01-01', dataSource: 'YAHOO', - fee: new Big(0), name: 'Bitcoin USD', - quantity: new Big(2), - symbol: 'BTCUSD', - type: 'BUY', - unitPrice: new Big(320.43) + symbol: 'BTCUSD' }, - { + type: 'BUY', + unitPrice: 320.43 + }, + { + ...activityDummyData, + date: new Date('2017-12-31'), + fee: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, currency: 'USD', - date: '2017-12-31', dataSource: 'YAHOO', - fee: new Big(0), name: 'Bitcoin USD', - quantity: new Big(1), - symbol: 'BTCUSD', - type: 'SELL', - unitPrice: new Big(14156.4) - } - ] + symbol: 'BTCUSD' + }, + type: 'SELL', + unitPrice: 14156.4 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'CHF', + hasFilters: false, + userId: userDummyData.id }); - portfolioCalculator.computeTransactionPoints(); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2018-01-01').getTime()); - const chartData = await portfolioCalculator.getChartData({ start: parseDate('2015-01-01') }); - const currentPositions = await portfolioCalculator.getCurrentPositions( + const portfolioSnapshot = await portfolioCalculator.computeSnapshot( parseDate('2015-01-01') ); @@ -99,8 +142,8 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); - expect(currentPositions).toEqual({ - currentValue: new Big('13298.425356'), + expect(portfolioSnapshot).toEqual({ + currentValueInBaseCurrency: new Big('13298.425356'), errors: [], grossPerformance: new Big('27172.74'), grossPerformancePercentage: new Big('42.41978276196153750666'), @@ -120,6 +163,8 @@ describe('PortfolioCalculator', () => { averagePrice: new Big('320.43'), currency: 'USD', dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), fee: new Big('0'), firstBuyDate: '2015-01-01', grossPerformance: new Big('27172.74'), @@ -144,16 +189,21 @@ describe('PortfolioCalculator', () => { ), quantity: new Big('1'), symbol: 'BTCUSD', - tags: undefined, + tags: [], timeWeightedInvestment: new Big('640.56763686131386861314'), timeWeightedInvestmentWithCurrencyEffect: new Big( '636.79469348020066587024' ), - transactionCount: 2 + transactionCount: 2, + valueInBaseCurrency: new Big('13298.425356') } ], + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), totalInvestment: new Big('320.43'), - totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957') + totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') }); expect(investments).toEqual([ diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts new file mode 100644 index 000000000..88d7adb71 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts @@ -0,0 +1,157 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PortfolioCalculatorFactory, + PerformanceCalculationType +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); + }); + + describe('compute portfolio snapshot', () => { + it.only('with fee activity', async () => { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-09-01'), + fee: 49, + quantity: 0, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'MANUAL', + name: 'Account Opening Fee', + symbol: '2c463fb3-af07-486e-adb0-8301b3d72141' + }, + type: 'FEE', + unitPrice: 0 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + hasFilters: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot( + parseDate('2021-11-30') + ); + + spy.mockRestore(); + + expect(portfolioSnapshot).toEqual({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + grossPerformance: new Big('0'), + grossPerformancePercentage: new Big('0'), + grossPerformancePercentageWithCurrencyEffect: new Big('0'), + grossPerformanceWithCurrencyEffect: new Big('0'), + hasErrors: true, + netPerformance: new Big('0'), + netPerformancePercentage: new Big('0'), + netPerformancePercentageWithCurrencyEffect: new Big('0'), + netPerformanceWithCurrencyEffect: new Big('0'), + positions: [ + { + averagePrice: new Big('0'), + currency: 'USD', + dataSource: 'MANUAL', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('49'), + firstBuyDate: '2021-09-01', + grossPerformance: null, + grossPerformancePercentage: null, + grossPerformancePercentageWithCurrencyEffect: null, + grossPerformanceWithCurrencyEffect: null, + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + marketPrice: null, + marketPriceInBaseCurrency: 0, + netPerformance: null, + netPerformancePercentage: null, + netPerformancePercentageWithCurrencyEffect: null, + netPerformanceWithCurrencyEffect: null, + quantity: new Big('0'), + symbol: '2c463fb3-af07-486e-adb0-8301b3d72141', + tags: [], + timeWeightedInvestment: new Big('0'), + timeWeightedInvestmentWithCurrencyEffect: new Big('0'), + transactionCount: 1, + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('49'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-googl-buy.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts similarity index 63% rename from apps/api/src/app/portfolio/portfolio-calculator-googl-buy.spec.ts rename to apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts index 502248388..690f1eb51 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-googl-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts @@ -1,12 +1,23 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PortfolioCalculatorFactory, + PerformanceCalculationType +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; import { parseDate } from '@ghostfolio/common/helper'; -import Big from 'big.js'; - -import { CurrentRateServiceMock } from './current-rate.service.mock'; -import { PortfolioCalculator } from './portfolio-calculator'; +import { Big } from 'big.js'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -17,6 +28,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + jest.mock( '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', () => { @@ -30,10 +50,15 @@ jest.mock( ); describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; beforeEach(() => { + configurationService = new ConfigurationService(); + currentRateService = new CurrentRateService(null, null, null, null); exchangeRateDataService = new ExchangeRateDataService( @@ -42,40 +67,54 @@ describe('PortfolioCalculator', () => { null, null ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); }); describe('get current positions', () => { it.only('with GOOGL buy', async () => { - const portfolioCalculator = new PortfolioCalculator({ - currentRateService, - exchangeRateDataService, - currency: 'CHF', - orders: [ - { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2023-07-10').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2023-01-03'), + fee: 1, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, currency: 'USD', - date: '2023-01-03', dataSource: 'YAHOO', - fee: new Big(1), name: 'Alphabet Inc.', - quantity: new Big(1), - symbol: 'GOOGL', - type: 'BUY', - unitPrice: new Big(89.12) - } - ] - }); - - portfolioCalculator.computeTransactionPoints(); + symbol: 'GOOGL' + }, + type: 'BUY', + unitPrice: 89.12 + } + ]; - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2023-07-10').getTime()); + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'CHF', + hasFilters: false, + userId: userDummyData.id + }); const chartData = await portfolioCalculator.getChartData({ start: parseDate('2023-01-03') }); - const currentPositions = await portfolioCalculator.getCurrentPositions( + const portfolioSnapshot = await portfolioCalculator.computeSnapshot( parseDate('2023-01-03') ); @@ -88,8 +127,8 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); - expect(currentPositions).toEqual({ - currentValue: new Big('103.10483'), + expect(portfolioSnapshot).toEqual({ + currentValueInBaseCurrency: new Big('103.10483'), errors: [], grossPerformance: new Big('27.33'), grossPerformancePercentage: new Big('0.3066651705565529623'), @@ -109,6 +148,8 @@ describe('PortfolioCalculator', () => { averagePrice: new Big('89.12'), currency: 'USD', dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), fee: new Big('1'), firstBuyDate: '2023-01-03', grossPerformance: new Big('27.33'), @@ -129,14 +170,19 @@ describe('PortfolioCalculator', () => { marketPriceInBaseCurrency: 103.10483, quantity: new Big('1'), symbol: 'GOOGL', - tags: undefined, + tags: [], timeWeightedInvestment: new Big('89.12'), timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'), - transactionCount: 1 + transactionCount: 1, + valueInBaseCurrency: new Big('103.10483') } ], + totalFeesWithCurrencyEffect: new Big('1'), + totalInterestWithCurrencyEffect: new Big('0'), totalInvestment: new Big('89.12'), - totalInvestmentWithCurrencyEffect: new Big('82.329056') + totalInvestmentWithCurrencyEffect: new Big('82.329056'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') }); expect(investments).toEqual([ diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts new file mode 100644 index 000000000..422d119b2 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts @@ -0,0 +1,157 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PortfolioCalculatorFactory, + PerformanceCalculationType +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); + }); + + describe('compute portfolio snapshot', () => { + it.only('with item activity', async () => { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2022-01-31').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2022-01-01'), + fee: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'MANUAL', + name: 'Penthouse Apartment', + symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde' + }, + type: 'ITEM', + unitPrice: 500000 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + hasFilters: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot( + parseDate('2022-01-01') + ); + + spy.mockRestore(); + + expect(portfolioSnapshot).toEqual({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + grossPerformance: new Big('0'), + grossPerformancePercentage: new Big('0'), + grossPerformancePercentageWithCurrencyEffect: new Big('0'), + grossPerformanceWithCurrencyEffect: new Big('0'), + hasErrors: true, + netPerformance: new Big('0'), + netPerformancePercentage: new Big('0'), + netPerformancePercentageWithCurrencyEffect: new Big('0'), + netPerformanceWithCurrencyEffect: new Big('0'), + positions: [ + { + averagePrice: new Big('500000'), + currency: 'USD', + dataSource: 'MANUAL', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('0'), + firstBuyDate: '2022-01-01', + grossPerformance: null, + grossPerformancePercentage: null, + grossPerformancePercentageWithCurrencyEffect: null, + grossPerformanceWithCurrencyEffect: null, + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + marketPrice: null, + marketPriceInBaseCurrency: 500000, + netPerformance: null, + netPerformancePercentage: null, + netPerformancePercentageWithCurrencyEffect: null, + netPerformanceWithCurrencyEffect: null, + quantity: new Big('0'), + symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde', + tags: [], + timeWeightedInvestment: new Big('0'), + timeWeightedInvestmentWithCurrencyEffect: new Big('0'), + transactionCount: 1, + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts new file mode 100644 index 000000000..d468e8e00 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts @@ -0,0 +1,108 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PortfolioCalculatorFactory, + PerformanceCalculationType +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); + }); + + describe('compute portfolio snapshot', () => { + it.only('with liability activity', async () => { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2022-01-31').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2023-01-01'), // Date in future + fee: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'MANUAL', + name: 'Loan', + symbol: '55196015-1365-4560-aa60-8751ae6d18f8' + }, + type: 'LIABILITY', + unitPrice: 3000 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + hasFilters: false, + userId: userDummyData.id + }); + + spy.mockRestore(); + + const liabilitiesInBaseCurrency = + await portfolioCalculator.getLiabilitiesInBaseCurrency(); + + expect(liabilitiesInBaseCurrency).toEqual(new Big(3000)); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts new file mode 100644 index 000000000..094c6cc2e --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -0,0 +1,165 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PerformanceCalculationType, + PortfolioCalculatorFactory +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return ExchangeRateDataServiceMock; + }) + }; + } +); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with MSFT buy', async () => { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2023-07-10').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-09-16'), + fee: 19, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'BUY', + unitPrice: 298.58 + }, + { + ...activityDummyData, + date: new Date('2021-11-16'), + fee: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'DIVIDEND', + unitPrice: 0.62 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'USD', + hasFilters: false, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot( + parseDate('2023-07-10') + ); + + spy.mockRestore(); + + expect(portfolioSnapshot).toMatchObject({ + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('298.58'), + currency: 'USD', + dataSource: 'YAHOO', + dividend: new Big('0.62'), + dividendInBaseCurrency: new Big('0.62'), + fee: new Big('19'), + firstBuyDate: '2021-09-16', + investment: new Big('298.58'), + investmentWithCurrencyEffect: new Big('298.58'), + marketPrice: 331.83, + marketPriceInBaseCurrency: 331.83, + quantity: new Big('1'), + symbol: 'MSFT', + tags: [], + transactionCount: 2 + } + ], + totalFeesWithCurrencyEffect: new Big('19'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('298.58'), + totalInvestmentWithCurrencyEffect: new Big('298.58'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts new file mode 100644 index 000000000..6bb432bfc --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts @@ -0,0 +1,124 @@ +import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PerformanceCalculationType, + PortfolioCalculatorFactory +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Big } from 'big.js'; +import { subDays } from 'date-fns'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it('with no orders', async () => { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2021-12-18').getTime()); + + const portfolioCalculator = factory.createCalculator({ + activities: [], + calculationType: PerformanceCalculationType.TWR, + currency: 'CHF', + hasFilters: false, + userId: userDummyData.id + }); + + const start = subDays(new Date(Date.now()), 10); + + const chartData = await portfolioCalculator.getChartData({ start }); + + const portfolioSnapshot = + await portfolioCalculator.computeSnapshot(start); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: chartData, + groupBy: 'month' + }); + + spy.mockRestore(); + + expect(portfolioSnapshot).toEqual({ + currentValueInBaseCurrency: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + hasErrors: false, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffect: new Big(0), + netPerformanceWithCurrencyEffect: new Big(0), + positions: [], + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big(0), + totalInvestmentWithCurrencyEffect: new Big(0), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(investments).toEqual([]); + + expect(investmentsByMonth).toEqual([ + { + date: '2021-12-01', + investment: 0 + } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts similarity index 57% rename from apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts rename to apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts index 0c618805a..f65d2ba61 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -1,11 +1,22 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PerformanceCalculationType, + PortfolioCalculatorFactory +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { parseDate } from '@ghostfolio/common/helper'; -import Big from 'big.js'; - -import { CurrentRateServiceMock } from './current-rate.service.mock'; -import { PortfolioCalculator } from './portfolio-calculator'; +import { Big } from 'big.js'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; beforeEach(() => { + configurationService = new ConfigurationService(); + currentRateService = new CurrentRateService(null, null, null, null); exchangeRateDataService = new ExchangeRateDataService( @@ -29,51 +54,69 @@ describe('PortfolioCalculator', () => { null, null ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); }); describe('get current positions', () => { it.only('with NOVN.SW buy and sell partially', async () => { - const portfolioCalculator = new PortfolioCalculator({ - currentRateService, - exchangeRateDataService, - currency: 'CHF', - orders: [ - { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2022-04-11').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2022-03-07'), + fee: 1.3, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, currency: 'CHF', - date: '2022-03-07', dataSource: 'YAHOO', - fee: new Big(1.3), name: 'Novartis AG', - quantity: new Big(2), - symbol: 'NOVN.SW', - type: 'BUY', - unitPrice: new Big(75.8) + symbol: 'NOVN.SW' }, - { + type: 'BUY', + unitPrice: 75.8 + }, + { + ...activityDummyData, + date: new Date('2022-04-08'), + fee: 2.95, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, currency: 'CHF', - date: '2022-04-08', dataSource: 'YAHOO', - fee: new Big(2.95), name: 'Novartis AG', - quantity: new Big(1), - symbol: 'NOVN.SW', - type: 'SELL', - unitPrice: new Big(85.73) - } - ] + symbol: 'NOVN.SW' + }, + type: 'SELL', + unitPrice: 85.73 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'CHF', + hasFilters: false, + userId: userDummyData.id }); - portfolioCalculator.computeTransactionPoints(); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2022-04-11').getTime()); - const chartData = await portfolioCalculator.getChartData({ start: parseDate('2022-03-07') }); - const currentPositions = await portfolioCalculator.getCurrentPositions( + const portfolioSnapshot = await portfolioCalculator.computeSnapshot( parseDate('2022-03-07') ); @@ -86,8 +129,8 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); - expect(currentPositions).toEqual({ - currentValue: new Big('87.8'), + expect(portfolioSnapshot).toEqual({ + currentValueInBaseCurrency: new Big('87.8'), errors: [], grossPerformance: new Big('21.93'), grossPerformancePercentage: new Big('0.15113417083448194384'), @@ -107,6 +150,8 @@ describe('PortfolioCalculator', () => { averagePrice: new Big('75.80'), currency: 'CHF', dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), fee: new Big('4.25'), firstBuyDate: '2022-03-07', grossPerformance: new Big('21.93'), @@ -127,16 +172,21 @@ describe('PortfolioCalculator', () => { marketPriceInBaseCurrency: 87.8, quantity: new Big('1'), symbol: 'NOVN.SW', - tags: undefined, + tags: [], timeWeightedInvestment: new Big('145.10285714285714285714'), timeWeightedInvestmentWithCurrencyEffect: new Big( '145.10285714285714285714' ), - transactionCount: 2 + transactionCount: 2, + valueInBaseCurrency: new Big('87.8') } ], + totalFeesWithCurrencyEffect: new Big('4.25'), + totalInterestWithCurrencyEffect: new Big('0'), totalInvestment: new Big('75.80'), - totalInvestmentWithCurrencyEffect: new Big('75.80') + totalInvestmentWithCurrencyEffect: new Big('75.80'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') }); expect(investments).toEqual([ diff --git a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts similarity index 61% rename from apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts rename to apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts index f49807966..902f710ee 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -1,11 +1,22 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PerformanceCalculationType, + PortfolioCalculatorFactory +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { parseDate } from '@ghostfolio/common/helper'; -import Big from 'big.js'; - -import { CurrentRateServiceMock } from './current-rate.service.mock'; -import { PortfolioCalculator } from './portfolio-calculator'; +import { Big } from 'big.js'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; beforeEach(() => { + configurationService = new ConfigurationService(); + currentRateService = new CurrentRateService(null, null, null, null); exchangeRateDataService = new ExchangeRateDataService( @@ -29,51 +54,69 @@ describe('PortfolioCalculator', () => { null, null ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); }); describe('get current positions', () => { it.only('with NOVN.SW buy and sell', async () => { - const portfolioCalculator = new PortfolioCalculator({ - currentRateService, - exchangeRateDataService, - currency: 'CHF', - orders: [ - { + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2022-04-11').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2022-03-07'), + fee: 0, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, currency: 'CHF', - date: '2022-03-07', dataSource: 'YAHOO', - fee: new Big(0), name: 'Novartis AG', - quantity: new Big(2), - symbol: 'NOVN.SW', - type: 'BUY', - unitPrice: new Big(75.8) + symbol: 'NOVN.SW' }, - { + type: 'BUY', + unitPrice: 75.8 + }, + { + ...activityDummyData, + date: new Date('2022-04-08'), + fee: 0, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, currency: 'CHF', - date: '2022-04-08', dataSource: 'YAHOO', - fee: new Big(0), name: 'Novartis AG', - quantity: new Big(2), - symbol: 'NOVN.SW', - type: 'SELL', - unitPrice: new Big(85.73) - } - ] + symbol: 'NOVN.SW' + }, + type: 'SELL', + unitPrice: 85.73 + } + ]; + + const portfolioCalculator = factory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: 'CHF', + hasFilters: false, + userId: userDummyData.id }); - portfolioCalculator.computeTransactionPoints(); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2022-04-11').getTime()); - const chartData = await portfolioCalculator.getChartData({ start: parseDate('2022-03-07') }); - const currentPositions = await portfolioCalculator.getCurrentPositions( + const portfolioSnapshot = await portfolioCalculator.computeSnapshot( parseDate('2022-03-07') ); @@ -93,7 +136,8 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentage: 0, netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0, - timeWeightedPerformance: 0, + netWorth: 151.6, + totalAccountBalance: 0, totalInvestment: 151.6, totalInvestmentValueWithCurrencyEffect: 151.6, value: 151.6, @@ -107,15 +151,16 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentage: 13.100263852242744, netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744, netPerformanceWithCurrencyEffect: 19.86, - timeWeightedPerformance: 0, + netWorth: 0, + totalAccountBalance: 0, totalInvestment: 0, totalInvestmentValueWithCurrencyEffect: 0, value: 0, valueWithCurrencyEffect: 0 }); - expect(currentPositions).toEqual({ - currentValue: new Big('0'), + expect(portfolioSnapshot).toEqual({ + currentValueInBaseCurrency: new Big('0'), errors: [], grossPerformance: new Big('19.86'), grossPerformancePercentage: new Big('0.13100263852242744063'), @@ -135,6 +180,8 @@ describe('PortfolioCalculator', () => { averagePrice: new Big('0'), currency: 'CHF', dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), fee: new Big('0'), firstBuyDate: '2022-03-07', grossPerformance: new Big('19.86'), @@ -155,13 +202,19 @@ describe('PortfolioCalculator', () => { marketPriceInBaseCurrency: 87.8, quantity: new Big('0'), symbol: 'NOVN.SW', + tags: [], timeWeightedInvestment: new Big('151.6'), timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), - transactionCount: 2 + transactionCount: 2, + valueInBaseCurrency: new Big('0') } ], + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), totalInvestment: new Big('0'), - totalInvestmentWithCurrencyEffect: new Big('0') + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') }); expect(investments).toEqual([ diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts new file mode 100644 index 000000000..536581070 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts @@ -0,0 +1,37 @@ +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + redisCacheService = new RedisCacheService(null, null); + + factory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + redisCacheService + ); + }); + + test.skip('Skip empty test', () => 1); +}); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts new file mode 100644 index 000000000..69716e405 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts @@ -0,0 +1,922 @@ +import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface'; +import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces'; +import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; + +import { Logger } from '@nestjs/common'; +import { Big } from 'big.js'; +import { + addDays, + addMilliseconds, + differenceInDays, + format, + isBefore +} from 'date-fns'; +import { cloneDeep, first, last, sortBy } from 'lodash'; + +export class TWRPortfolioCalculator extends PortfolioCalculator { + protected calculateOverallPerformance( + positions: TimelinePosition[] + ): PortfolioSnapshot { + let currentValueInBaseCurrency = new Big(0); + let grossPerformance = new Big(0); + let grossPerformanceWithCurrencyEffect = new Big(0); + let hasErrors = false; + let netPerformance = new Big(0); + let netPerformanceWithCurrencyEffect = new Big(0); + let totalFeesWithCurrencyEffect = new Big(0); + let totalInterestWithCurrencyEffect = new Big(0); + let totalInvestment = new Big(0); + let totalInvestmentWithCurrencyEffect = new Big(0); + let totalTimeWeightedInvestment = new Big(0); + let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); + + for (const currentPosition of positions) { + if (currentPosition.fee) { + totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( + currentPosition.fee + ); + } + + if (currentPosition.valueInBaseCurrency) { + currentValueInBaseCurrency = currentValueInBaseCurrency.plus( + currentPosition.valueInBaseCurrency + ); + } else { + hasErrors = true; + } + + if (currentPosition.investment) { + totalInvestment = totalInvestment.plus(currentPosition.investment); + + totalInvestmentWithCurrencyEffect = + totalInvestmentWithCurrencyEffect.plus( + currentPosition.investmentWithCurrencyEffect + ); + } else { + hasErrors = true; + } + + if (currentPosition.grossPerformance) { + grossPerformance = grossPerformance.plus( + currentPosition.grossPerformance + ); + + grossPerformanceWithCurrencyEffect = + grossPerformanceWithCurrencyEffect.plus( + currentPosition.grossPerformanceWithCurrencyEffect + ); + + netPerformance = netPerformance.plus(currentPosition.netPerformance); + + netPerformanceWithCurrencyEffect = + netPerformanceWithCurrencyEffect.plus( + currentPosition.netPerformanceWithCurrencyEffect + ); + } else if (!currentPosition.quantity.eq(0)) { + hasErrors = true; + } + + if (currentPosition.timeWeightedInvestment) { + totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus( + currentPosition.timeWeightedInvestment + ); + + totalTimeWeightedInvestmentWithCurrencyEffect = + totalTimeWeightedInvestmentWithCurrencyEffect.plus( + currentPosition.timeWeightedInvestmentWithCurrencyEffect + ); + } else if (!currentPosition.quantity.eq(0)) { + Logger.warn( + `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, + 'PortfolioCalculator' + ); + + hasErrors = true; + } + } + + return { + currentValueInBaseCurrency, + grossPerformance, + grossPerformanceWithCurrencyEffect, + hasErrors, + netPerformance, + netPerformanceWithCurrencyEffect, + positions, + totalFeesWithCurrencyEffect, + totalInterestWithCurrencyEffect, + totalInvestment, + totalInvestmentWithCurrencyEffect, + netPerformancePercentage: totalTimeWeightedInvestment.eq(0) + ? new Big(0) + : netPerformance.div(totalTimeWeightedInvestment), + netPerformancePercentageWithCurrencyEffect: + totalTimeWeightedInvestmentWithCurrencyEffect.eq(0) + ? new Big(0) + : netPerformanceWithCurrencyEffect.div( + totalTimeWeightedInvestmentWithCurrencyEffect + ), + grossPerformancePercentage: totalTimeWeightedInvestment.eq(0) + ? new Big(0) + : grossPerformance.div(totalTimeWeightedInvestment), + grossPerformancePercentageWithCurrencyEffect: + totalTimeWeightedInvestmentWithCurrencyEffect.eq(0) + ? new Big(0) + : grossPerformanceWithCurrencyEffect.div( + totalTimeWeightedInvestmentWithCurrencyEffect + ), + totalLiabilitiesWithCurrencyEffect: new Big(0), + totalValuablesWithCurrencyEffect: new Big(0) + }; + } + + protected getSymbolMetrics({ + dataSource, + end, + exchangeRates, + isChartMode = false, + marketSymbolMap, + start, + step = 1, + symbol + }: { + end: Date; + exchangeRates: { [dateString: string]: number }; + isChartMode?: boolean; + marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + }; + start: Date; + step?: number; + } & UniqueAsset): SymbolMetrics { + const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; + const currentValues: { [date: string]: Big } = {}; + const currentValuesWithCurrencyEffect: { [date: string]: Big } = {}; + let fees = new Big(0); + let feesAtStartDate = new Big(0); + let feesAtStartDateWithCurrencyEffect = new Big(0); + let feesWithCurrencyEffect = new Big(0); + let grossPerformance = new Big(0); + let grossPerformanceWithCurrencyEffect = new Big(0); + let grossPerformanceAtStartDate = new Big(0); + let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0); + let grossPerformanceFromSells = new Big(0); + let grossPerformanceFromSellsWithCurrencyEffect = new Big(0); + let initialValue: Big; + let initialValueWithCurrencyEffect: Big; + let investmentAtStartDate: Big; + let investmentAtStartDateWithCurrencyEffect: Big; + const investmentValuesAccumulated: { [date: string]: Big } = {}; + const investmentValuesAccumulatedWithCurrencyEffect: { + [date: string]: Big; + } = {}; + const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {}; + let lastAveragePrice = new Big(0); + let lastAveragePriceWithCurrencyEffect = new Big(0); + const netPerformanceValues: { [date: string]: Big } = {}; + const netPerformanceValuesWithCurrencyEffect: { [date: string]: Big } = {}; + const timeWeightedInvestmentValues: { [date: string]: Big } = {}; + + const timeWeightedInvestmentValuesWithCurrencyEffect: { + [date: string]: Big; + } = {}; + + let totalAccountBalanceInBaseCurrency = new Big(0); + let totalDividend = new Big(0); + let totalStakeRewards = new Big(0); + let totalDividendInBaseCurrency = new Big(0); + let totalInterest = new Big(0); + let totalInterestInBaseCurrency = new Big(0); + let totalInvestment = new Big(0); + let totalInvestmentFromBuyTransactions = new Big(0); + let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0); + let totalInvestmentWithCurrencyEffect = new Big(0); + let totalLiabilities = new Big(0); + let totalLiabilitiesInBaseCurrency = new Big(0); + let totalQuantityFromBuyTransactions = new Big(0); + let totalUnits = new Big(0); + let totalValuables = new Big(0); + let totalValuablesInBaseCurrency = new Big(0); + let valueAtStartDate: Big; + let valueAtStartDateWithCurrencyEffect: Big; + + // Clone orders to keep the original values in this.orders + let orders: PortfolioOrderItem[] = cloneDeep(this.activities).filter( + ({ SymbolProfile }) => { + return SymbolProfile.symbol === symbol; + } + ); + + if (orders.length <= 0) { + return { + currentValues: {}, + currentValuesWithCurrencyEffect: {}, + unitPrices: {}, + feesWithCurrencyEffect: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + hasErrors: false, + initialValue: new Big(0), + initialValueWithCurrencyEffect: new Big(0), + investmentValuesAccumulated: {}, + investmentValuesAccumulatedWithCurrencyEffect: {}, + investmentValuesWithCurrencyEffect: {}, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffect: new Big(0), + netPerformanceValues: {}, + netPerformanceValuesWithCurrencyEffect: {}, + netPerformanceValuesPercentage: {}, + netPerformanceWithCurrencyEffect: new Big(0), + timeWeightedInvestment: new Big(0), + timeWeightedInvestmentValues: {}, + timeWeightedInvestmentValuesWithCurrencyEffect: {}, + timeWeightedInvestmentWithCurrencyEffect: new Big(0), + totalAccountBalanceInBaseCurrency: new Big(0), + totalDividend: new Big(0), + totalDividendInBaseCurrency: new Big(0), + totalInterest: new Big(0), + totalInterestInBaseCurrency: new Big(0), + totalInvestment: new Big(0), + totalInvestmentWithCurrencyEffect: new Big(0), + totalLiabilities: new Big(0), + totalLiabilitiesInBaseCurrency: new Big(0), + totalValuables: new Big(0), + totalValuablesInBaseCurrency: new Big(0) + }; + } + + const dateOfFirstTransaction = new Date(first(orders).date); + + const unitPriceAtStartDate = + marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol]; + + const unitPriceAtEndDate = + marketSymbolMap[format(end, DATE_FORMAT)]?.[symbol]; + + if ( + !unitPriceAtEndDate || + (!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start)) + ) { + return { + currentValues: {}, + currentValuesWithCurrencyEffect: {}, + unitPrices: {}, + feesWithCurrencyEffect: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + hasErrors: true, + initialValue: new Big(0), + initialValueWithCurrencyEffect: new Big(0), + investmentValuesAccumulated: {}, + investmentValuesAccumulatedWithCurrencyEffect: {}, + investmentValuesWithCurrencyEffect: {}, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffect: new Big(0), + netPerformanceValues: {}, + netPerformanceValuesWithCurrencyEffect: {}, + netPerformanceValuesPercentage: {}, + netPerformanceWithCurrencyEffect: new Big(0), + timeWeightedInvestment: new Big(0), + timeWeightedInvestmentValues: {}, + timeWeightedInvestmentValuesWithCurrencyEffect: {}, + timeWeightedInvestmentWithCurrencyEffect: new Big(0), + totalAccountBalanceInBaseCurrency: new Big(0), + totalDividend: new Big(0), + totalDividendInBaseCurrency: new Big(0), + totalInterest: new Big(0), + totalInterestInBaseCurrency: new Big(0), + totalInvestment: new Big(0), + totalInvestmentWithCurrencyEffect: new Big(0), + totalLiabilities: new Big(0), + totalLiabilitiesInBaseCurrency: new Big(0), + totalValuables: new Big(0), + totalValuablesInBaseCurrency: new Big(0) + }; + } + + // Add a synthetic order at the start and the end date + orders.push({ + date: format(start, DATE_FORMAT), + fee: new Big(0), + feeInBaseCurrency: new Big(0), + itemType: 'start', + quantity: new Big(0), + SymbolProfile: { + dataSource, + symbol + }, + type: 'BUY', + unitPrice: unitPriceAtStartDate + }); + + orders.push({ + date: format(end, DATE_FORMAT), + fee: new Big(0), + feeInBaseCurrency: new Big(0), + itemType: 'end', + SymbolProfile: { + dataSource, + symbol + }, + quantity: new Big(0), + type: 'BUY', + unitPrice: unitPriceAtEndDate + }); + + let day = start; + let lastUnitPrice: Big; + + if (isChartMode) { + const datesWithOrders = {}; + + for (const { date, type } of orders) { + if (['BUY', 'SELL', 'STAKE'].includes(type)) { + datesWithOrders[date] = true; + } + } + + while (isBefore(day, end)) { + const hasDate = datesWithOrders[format(day, DATE_FORMAT)]; + + if (!hasDate) { + orders.push({ + date: format(day, DATE_FORMAT), + fee: new Big(0), + feeInBaseCurrency: new Big(0), + quantity: new Big(0), + SymbolProfile: { + dataSource, + symbol + }, + type: 'BUY', + unitPrice: + marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ?? + lastUnitPrice + }); + } + + lastUnitPrice = last(orders).unitPrice; + + day = addDays(day, step); + } + } + + // Sort orders so that the start and end placeholder order are at the correct + // position + orders = sortBy(orders, ({ date, itemType }) => { + let sortIndex = new Date(date); + + if (itemType === 'end') { + sortIndex = addMilliseconds(sortIndex, 1); + } else if (itemType === 'start') { + sortIndex = addMilliseconds(sortIndex, -1); + } + + return sortIndex.getTime(); + }); + + const indexOfStartOrder = orders.findIndex(({ itemType }) => { + return itemType === 'start'; + }); + + const indexOfEndOrder = orders.findIndex(({ itemType }) => { + return itemType === 'end'; + }); + + let totalInvestmentDays = 0; + let sumOfTimeWeightedInvestments = new Big(0); + let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0); + + for (let i = 0; i < orders.length; i += 1) { + const order = orders[i]; + + if (PortfolioCalculator.ENABLE_LOGGING) { + console.log(); + console.log(); + console.log( + i + 1, + order.date, + order.type, + order.itemType ? `(${order.itemType})` : '' + ); + } + + const exchangeRateAtOrderDate = exchangeRates[order.date]; + + if (order.type === 'DIVIDEND') { + const dividend = order.quantity.mul(order.unitPrice); + + totalDividend = totalDividend.plus(dividend); + totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus( + dividend.mul(exchangeRateAtOrderDate ?? 1) + ); + } else if (order.type === 'INTEREST') { + const interest = order.quantity.mul(order.unitPrice); + + totalInterest = totalInterest.plus(interest); + totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus( + interest.mul(exchangeRateAtOrderDate ?? 1) + ); + } else if (order.type === 'ITEM') { + const valuables = order.quantity.mul(order.unitPrice); + + totalValuables = totalValuables.plus(valuables); + totalValuablesInBaseCurrency = totalValuablesInBaseCurrency.plus( + valuables.mul(exchangeRateAtOrderDate ?? 1) + ); + } else if (order.type === 'LIABILITY') { + const liabilities = order.quantity.mul(order.unitPrice); + + totalLiabilities = totalLiabilities.plus(liabilities); + totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus( + liabilities.mul(exchangeRateAtOrderDate ?? 1) + ); + } + + if (order.itemType === 'start') { + // Take the unit price of the order as the market price if there are no + // orders of this symbol before the start date + order.unitPrice = + indexOfStartOrder === 0 + ? orders[i + 1]?.unitPrice + : unitPriceAtStartDate; + } + + if (order.fee) { + order.feeInBaseCurrency = order.fee.mul(currentExchangeRate ?? 1); + order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul( + exchangeRateAtOrderDate ?? 1 + ); + } + + if (order.type === 'STAKE') { + order.unitPrice = marketSymbolMap[order.date]?.[symbol] ?? new Big(0); + } + + if (order.unitPrice) { + order.unitPriceInBaseCurrency = order.unitPrice.mul( + currentExchangeRate ?? 1 + ); + + order.unitPriceInBaseCurrencyWithCurrencyEffect = order.unitPrice.mul( + exchangeRateAtOrderDate ?? 1 + ); + } + + const valueOfInvestmentBeforeTransaction = totalUnits.mul( + order.unitPriceInBaseCurrency + ); + + const valueOfInvestmentBeforeTransactionWithCurrencyEffect = + totalUnits.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect); + + if (!investmentAtStartDate && i >= indexOfStartOrder) { + investmentAtStartDate = totalInvestment ?? new Big(0); + + investmentAtStartDateWithCurrencyEffect = + totalInvestmentWithCurrencyEffect ?? new Big(0); + + valueAtStartDate = valueOfInvestmentBeforeTransaction; + + valueAtStartDateWithCurrencyEffect = + valueOfInvestmentBeforeTransactionWithCurrencyEffect; + } + + let transactionInvestment = new Big(0); + let transactionInvestmentWithCurrencyEffect = new Big(0); + + if (order.type === 'BUY') { + transactionInvestment = order.quantity + .mul(order.unitPriceInBaseCurrency) + .mul(getFactor(order.type)); + + transactionInvestmentWithCurrencyEffect = order.quantity + .mul(order.unitPriceInBaseCurrencyWithCurrencyEffect) + .mul(getFactor(order.type)); + + totalQuantityFromBuyTransactions = + totalQuantityFromBuyTransactions.plus(order.quantity); + + totalInvestmentFromBuyTransactions = + totalInvestmentFromBuyTransactions.plus(transactionInvestment); + + totalInvestmentFromBuyTransactionsWithCurrencyEffect = + totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus( + transactionInvestmentWithCurrencyEffect + ); + } else if (order.type === 'SELL') { + if (totalUnits.gt(0)) { + transactionInvestment = totalInvestment + .div(totalUnits) + .mul(order.quantity) + .mul(getFactor(order.type)); + transactionInvestmentWithCurrencyEffect = + totalInvestmentWithCurrencyEffect + .div(totalUnits) + .mul(order.quantity) + .mul(getFactor(order.type)); + } + } + + if (PortfolioCalculator.ENABLE_LOGGING) { + console.log('order.quantity', order.quantity.toNumber()); + console.log('transactionInvestment', transactionInvestment.toNumber()); + + console.log( + 'transactionInvestmentWithCurrencyEffect', + transactionInvestmentWithCurrencyEffect.toNumber() + ); + } + + const totalInvestmentBeforeTransaction = totalInvestment; + + const totalInvestmentBeforeTransactionWithCurrencyEffect = + totalInvestmentWithCurrencyEffect; + + totalInvestment = totalInvestment.plus(transactionInvestment); + + totalInvestmentWithCurrencyEffect = + totalInvestmentWithCurrencyEffect.plus( + transactionInvestmentWithCurrencyEffect + ); + + if (i >= indexOfStartOrder && !initialValue) { + if ( + i === indexOfStartOrder && + !valueOfInvestmentBeforeTransaction.eq(0) + ) { + initialValue = valueOfInvestmentBeforeTransaction; + + initialValueWithCurrencyEffect = + valueOfInvestmentBeforeTransactionWithCurrencyEffect; + } else if (transactionInvestment.gt(0)) { + initialValue = transactionInvestment; + + initialValueWithCurrencyEffect = + transactionInvestmentWithCurrencyEffect; + } + } + + fees = fees.plus(order.feeInBaseCurrency ?? 0); + + feesWithCurrencyEffect = feesWithCurrencyEffect.plus( + order.feeInBaseCurrencyWithCurrencyEffect ?? 0 + ); + + totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type))); + + const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency); + + const valueOfInvestmentWithCurrencyEffect = totalUnits.mul( + order.unitPriceInBaseCurrencyWithCurrencyEffect + ); + + const grossPerformanceFromSell = + order.type === 'SELL' + ? order.unitPriceInBaseCurrency + .minus(lastAveragePrice) + .mul(order.quantity) + : new Big(0); + + const grossPerformanceFromSellWithCurrencyEffect = + order.type === 'SELL' + ? order.unitPriceInBaseCurrencyWithCurrencyEffect + .minus(lastAveragePriceWithCurrencyEffect) + .mul(order.quantity) + : new Big(0); + + grossPerformanceFromSells = grossPerformanceFromSells.plus( + grossPerformanceFromSell + ); + + grossPerformanceFromSellsWithCurrencyEffect = + grossPerformanceFromSellsWithCurrencyEffect.plus( + grossPerformanceFromSellWithCurrencyEffect + ); + + lastAveragePrice = totalQuantityFromBuyTransactions.eq(0) + ? new Big(0) + : totalInvestmentFromBuyTransactions.div( + totalQuantityFromBuyTransactions + ); + + lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq( + 0 + ) + ? new Big(0) + : totalInvestmentFromBuyTransactionsWithCurrencyEffect.div( + totalQuantityFromBuyTransactions + ); + + if (PortfolioCalculator.ENABLE_LOGGING) { + console.log( + 'grossPerformanceFromSells', + grossPerformanceFromSells.toNumber() + ); + console.log( + 'grossPerformanceFromSellWithCurrencyEffect', + grossPerformanceFromSellWithCurrencyEffect.toNumber() + ); + } + + const newGrossPerformance = valueOfInvestment + .minus(totalInvestment) + .plus(grossPerformanceFromSells); + + const newGrossPerformanceWithCurrencyEffect = + valueOfInvestmentWithCurrencyEffect + .minus(totalInvestmentWithCurrencyEffect) + .plus(grossPerformanceFromSellsWithCurrencyEffect); + + grossPerformance = newGrossPerformance; + + grossPerformanceWithCurrencyEffect = + newGrossPerformanceWithCurrencyEffect; + + if (order.itemType === 'start') { + feesAtStartDate = fees; + feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect; + grossPerformanceAtStartDate = grossPerformance; + + grossPerformanceAtStartDateWithCurrencyEffect = + grossPerformanceWithCurrencyEffect; + } + + if ( + i > indexOfStartOrder && + ['BUY', 'SELL', 'STAKE'].includes(order.type) + ) { + // Only consider periods with an investment for the calculation of + // the time weighted investment + if (valueOfInvestmentBeforeTransaction.gt(0)) { + // Calculate the number of days since the previous order + const orderDate = new Date(order.date); + const previousOrderDate = new Date(orders[i - 1].date); + + let daysSinceLastOrder = differenceInDays( + orderDate, + previousOrderDate + ); + if (daysSinceLastOrder <= 0) { + // The time between two activities on the same day is unknown + // -> Set it to the smallest floating point number greater than 0 + daysSinceLastOrder = Number.EPSILON; + } + + // Sum up the total investment days since the start date to calculate + // the time weighted investment + totalInvestmentDays += daysSinceLastOrder; + + sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add( + valueAtStartDate + .minus(investmentAtStartDate) + .plus(totalInvestmentBeforeTransaction) + .mul(daysSinceLastOrder) + ); + + sumOfTimeWeightedInvestmentsWithCurrencyEffect = + sumOfTimeWeightedInvestmentsWithCurrencyEffect.add( + valueAtStartDateWithCurrencyEffect + .minus(investmentAtStartDateWithCurrencyEffect) + .plus(totalInvestmentBeforeTransactionWithCurrencyEffect) + .mul(daysSinceLastOrder) + ); + } + + if (isChartMode) { + currentValues[order.date] = valueOfInvestment; + + currentValuesWithCurrencyEffect[order.date] = + valueOfInvestmentWithCurrencyEffect; + + netPerformanceValues[order.date] = grossPerformance + .minus(grossPerformanceAtStartDate) + .minus(fees.minus(feesAtStartDate)); + + netPerformanceValuesWithCurrencyEffect[order.date] = + grossPerformanceWithCurrencyEffect + .minus(grossPerformanceAtStartDateWithCurrencyEffect) + .minus( + feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect) + ); + + investmentValuesAccumulated[order.date] = totalInvestment; + + investmentValuesAccumulatedWithCurrencyEffect[order.date] = + totalInvestmentWithCurrencyEffect; + + investmentValuesWithCurrencyEffect[order.date] = ( + investmentValuesWithCurrencyEffect[order.date] ?? new Big(0) + ).add(transactionInvestmentWithCurrencyEffect); + + timeWeightedInvestmentValues[order.date] = + totalInvestmentDays > 0 + ? sumOfTimeWeightedInvestments.div(totalInvestmentDays) + : new Big(0); + + timeWeightedInvestmentValuesWithCurrencyEffect[order.date] = + totalInvestmentDays > 0 + ? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div( + totalInvestmentDays + ) + : new Big(0); + } + } + + if (PortfolioCalculator.ENABLE_LOGGING) { + console.log('totalInvestment', totalInvestment.toNumber()); + + console.log( + 'totalInvestmentWithCurrencyEffect', + totalInvestmentWithCurrencyEffect.toNumber() + ); + + console.log( + 'totalGrossPerformance', + grossPerformance.minus(grossPerformanceAtStartDate).toNumber() + ); + + console.log( + 'totalGrossPerformanceWithCurrencyEffect', + grossPerformanceWithCurrencyEffect + .minus(grossPerformanceAtStartDateWithCurrencyEffect) + .toNumber() + ); + } + + if (i === indexOfEndOrder) { + break; + } + } + + const totalGrossPerformance = grossPerformance.minus( + grossPerformanceAtStartDate + ); + + const totalGrossPerformanceWithCurrencyEffect = + grossPerformanceWithCurrencyEffect.minus( + grossPerformanceAtStartDateWithCurrencyEffect + ); + + const totalNetPerformance = grossPerformance + .minus(grossPerformanceAtStartDate) + .minus(fees.minus(feesAtStartDate)); + + const totalNetPerformanceWithCurrencyEffect = + grossPerformanceWithCurrencyEffect + .minus(grossPerformanceAtStartDateWithCurrencyEffect) + .minus(feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect)); + + const timeWeightedAverageInvestmentBetweenStartAndEndDate = + totalInvestmentDays > 0 + ? sumOfTimeWeightedInvestments.div(totalInvestmentDays) + : new Big(0); + + const timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect = + totalInvestmentDays > 0 + ? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div( + totalInvestmentDays + ) + : new Big(0); + + const grossPerformancePercentage = + timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) + ? totalGrossPerformance.div( + timeWeightedAverageInvestmentBetweenStartAndEndDate + ) + : new Big(0); + + const grossPerformancePercentageWithCurrencyEffect = + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt( + 0 + ) + ? totalGrossPerformanceWithCurrencyEffect.div( + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect + ) + : new Big(0); + + const feesPerUnit = totalUnits.gt(0) + ? fees.minus(feesAtStartDate).div(totalUnits) + : new Big(0); + + const feesPerUnitWithCurrencyEffect = totalUnits.gt(0) + ? feesWithCurrencyEffect + .minus(feesAtStartDateWithCurrencyEffect) + .div(totalUnits) + : new Big(0); + + const netPerformancePercentage = + timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) + ? totalNetPerformance.div( + timeWeightedAverageInvestmentBetweenStartAndEndDate + ) + : new Big(0); + + const netPerformancePercentageWithCurrencyEffect = + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt( + 0 + ) + ? totalNetPerformanceWithCurrencyEffect.div( + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect + ) + : new Big(0); + + if (PortfolioCalculator.ENABLE_LOGGING) { + console.log( + ` + ${symbol} + Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed( + 2 + )} -> ${unitPriceAtEndDate.toFixed(2)} + Total investment: ${totalInvestment.toFixed(2)} + Total investment with currency effect: ${totalInvestmentWithCurrencyEffect.toFixed( + 2 + )} + Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed( + 2 + )} + Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed( + 2 + )} + Total dividend: ${totalDividend.toFixed(2)} + Gross performance: ${totalGrossPerformance.toFixed( + 2 + )} / ${grossPerformancePercentage.mul(100).toFixed(2)}% + Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed( + 2 + )} / ${grossPerformancePercentageWithCurrencyEffect + .mul(100) + .toFixed(2)}% + Fees per unit: ${feesPerUnit.toFixed(2)} + Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed( + 2 + )} + Net performance: ${totalNetPerformance.toFixed( + 2 + )} / ${netPerformancePercentage.mul(100).toFixed(2)}% + Net performance with currency effect: ${totalNetPerformanceWithCurrencyEffect.toFixed( + 2 + )} / ${netPerformancePercentageWithCurrencyEffect.mul(100).toFixed(2)}%` + ); + } + + let unitPrices = Object.keys(marketSymbolMap) + .map((date) => { + return { [date]: marketSymbolMap[date][symbol] }; + }) + .reduce((map, u) => { + return { ...u, ...map }; + }, {}); + + return { + currentValues, + currentValuesWithCurrencyEffect, + unitPrices, + feesWithCurrencyEffect, + grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, + initialValue, + initialValueWithCurrencyEffect, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffect, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + netPerformanceValuesPercentage: {}, + timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect, + totalAccountBalanceInBaseCurrency, + totalDividend, + totalDividendInBaseCurrency, + totalInterest, + totalInterestInBaseCurrency, + totalInvestment, + totalInvestmentWithCurrencyEffect, + totalLiabilities, + totalLiabilitiesInBaseCurrency, + totalValuables, + totalValuablesInBaseCurrency, + grossPerformance: totalGrossPerformance, + grossPerformanceWithCurrencyEffect: + totalGrossPerformanceWithCurrencyEffect, + hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), + netPerformance: totalNetPerformance, + netPerformanceWithCurrencyEffect: totalNetPerformanceWithCurrencyEffect, + timeWeightedInvestment: + timeWeightedAverageInvestmentBetweenStartAndEndDate, + timeWeightedInvestmentWithCurrencyEffect: + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect + }; + } +} diff --git a/apps/api/src/app/portfolio/current-rate.service.mock.ts b/apps/api/src/app/portfolio/current-rate.service.mock.ts index 76e7aae09..8ac1d15bd 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -8,6 +8,13 @@ import { GetValuesParams } from './interfaces/get-values-params.interface'; function mockGetValue(symbol: string, date: Date) { switch (symbol) { + case '55196015-1365-4560-aa60-8751ae6d18f8': + if (isSameDay(parseDate('2022-01-31'), date)) { + return { marketPrice: 3000 }; + } + + return { marketPrice: 0 }; + case 'BALN.SW': if (isSameDay(parseDate('2021-11-12'), date)) { return { marketPrice: 146 }; @@ -43,6 +50,17 @@ function mockGetValue(symbol: string, date: Date) { return { marketPrice: 0 }; + case 'MSFT': + if (isSameDay(parseDate('2021-09-16'), date)) { + return { marketPrice: 89.12 }; + } else if (isSameDay(parseDate('2021-11-16'), date)) { + return { marketPrice: 339.51 }; + } else if (isSameDay(parseDate('2023-07-10'), date)) { + return { marketPrice: 331.83 }; + } + + return { marketPrice: 0 }; + case 'NOVN.SW': if (isSameDay(parseDate('2022-04-11'), date)) { return { marketPrice: 87.8 }; diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts index 8cbac3e43..0d7d6814f 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -37,7 +37,7 @@ export class CurrentRateService { }: GetValuesParams): Promise { const dataProviderInfos: DataProviderInfo[] = []; - const includeToday = + const includesToday = (!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) && (!dateQuery.gte || isBefore(dateQuery.gte, new Date())) && (!dateQuery.in || this.containsToday(dateQuery.in)); @@ -46,7 +46,7 @@ export class CurrentRateService { const quoteErrors: ResponseError['errors'] = []; const today = resetHours(new Date()); - if (includeToday) { + if (includesToday) { promises.push( this.dataProviderService .getQuotes({ items: dataGatheringItems, user: this.request?.user }) diff --git a/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts b/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts deleted file mode 100644 index 5807d6b5e..000000000 --- a/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces'; - -import Big from 'big.js'; - -export interface CurrentPositions extends ResponseError { - positions: TimelinePosition[]; - grossPerformance: Big; - grossPerformanceWithCurrencyEffect: Big; - grossPerformancePercentage: Big; - grossPerformancePercentageWithCurrencyEffect: Big; - netAnnualizedPerformance?: Big; - netAnnualizedPerformanceWithCurrencyEffect?: Big; - netPerformance: Big; - netPerformanceWithCurrencyEffect: Big; - netPerformancePercentage: Big; - netPerformancePercentageWithCurrencyEffect: Big; - currentValue: Big; - totalInvestment: Big; -} diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts similarity index 85% rename from apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts rename to apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts index 4ba5d6dfc..9a3354868 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts @@ -1,11 +1,11 @@ -import Big from 'big.js'; +import { Big } from 'big.js'; import { PortfolioOrder } from './portfolio-order.interface'; export interface PortfolioOrderItem extends PortfolioOrder { feeInBaseCurrency?: Big; feeInBaseCurrencyWithCurrencyEffect?: Big; - itemType?: '' | 'start' | 'end'; + itemType?: 'end' | 'start'; unitPriceInBaseCurrency?: Big; unitPriceInBaseCurrencyWithCurrencyEffect?: Big; } diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts index cc3a97752..63a936c32 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts @@ -1,15 +1,12 @@ -import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client'; -import Big from 'big.js'; +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; -export interface PortfolioOrder { - currency: string; +export interface PortfolioOrder extends Pick { date: string; - dataSource: DataSource; fee: Big; - name: string; quantity: Big; - symbol: string; - tags?: Tag[]; - type: TypeOfOrder; + SymbolProfile: Pick< + Activity['SymbolProfile'], + 'currency' | 'dataSource' | 'name' | 'symbol' + >; unitPrice: Big; } diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts index e5f7a18ef..1effc6d6d 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts @@ -1,9 +1,9 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { DataProviderInfo, EnhancedSymbolProfile, HistoricalDataItem } from '@ghostfolio/common/interfaces'; -import { OrderWithAccount } from '@ghostfolio/common/types'; import { Account, Tag } from '@prisma/client'; @@ -13,6 +13,8 @@ export interface PortfolioPositionDetail { dataProviderInfo: DataProviderInfo; dividendInBaseCurrency: number; stakeRewards: number; + dividendYieldPercent: number; + dividendYieldPercentWithCurrencyEffect: number; feeInBaseCurrency: number; firstBuyDate: string; grossPerformance: number; @@ -28,16 +30,10 @@ export interface PortfolioPositionDetail { netPerformancePercent: number; netPerformancePercentWithCurrencyEffect: number; netPerformanceWithCurrencyEffect: number; - orders: OrderWithAccount[]; + orders: Activity[]; quantity: number; SymbolProfile: EnhancedSymbolProfile; tags: Tag[]; transactionCount: number; value: number; } - -export interface HistoricalDataContainer { - isAllTimeHigh: boolean; - isAllTimeLow: boolean; - items: HistoricalDataItem[]; -} diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-positions.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-positions.interface.ts deleted file mode 100644 index fa6141a7a..000000000 --- a/apps/api/src/app/portfolio/interfaces/portfolio-positions.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Position } from '@ghostfolio/common/interfaces'; - -export interface PortfolioPositions { - positions: Position[]; -} diff --git a/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts b/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts index 5350adccc..d15d02d3a 100644 --- a/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts @@ -1,9 +1,11 @@ import { DataSource, Tag } from '@prisma/client'; -import Big from 'big.js'; +import { Big } from 'big.js'; export interface TransactionPointSymbol { + averagePrice: Big; currency: string; dataSource: DataSource; + dividend: Big; fee: Big; firstBuyDate: string; investment: Big; diff --git a/apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts b/apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts index 178df3456..fcbea81ca 100644 --- a/apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts @@ -1,6 +1,12 @@ +import { Big } from 'big.js'; + import { TransactionPointSymbol } from './transaction-point-symbol.interface'; export interface TransactionPoint { date: string; + fees: Big; + interest: Big; items: TransactionPointSymbol[]; + liabilities: Big; + valuables: Big; } diff --git a/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts deleted file mode 100644 index ab7234822..000000000 --- a/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { parseDate } from '@ghostfolio/common/helper'; - -import Big from 'big.js'; - -import { CurrentRateServiceMock } from './current-rate.service.mock'; -import { PortfolioCalculator } from './portfolio-calculator'; - -jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - CurrentRateService: jest.fn().mockImplementation(() => { - return CurrentRateServiceMock; - }) - }; -}); - -describe('PortfolioCalculator', () => { - let currentRateService: CurrentRateService; - let exchangeRateDataService: ExchangeRateDataService; - - beforeEach(() => { - currentRateService = new CurrentRateService(null, null, null, null); - - exchangeRateDataService = new ExchangeRateDataService( - null, - null, - null, - null - ); - }); - - describe('get current positions', () => { - it('with no orders', async () => { - const portfolioCalculator = new PortfolioCalculator({ - currentRateService, - exchangeRateDataService, - currency: 'CHF', - orders: [] - }); - - portfolioCalculator.computeTransactionPoints(); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2021-12-18').getTime()); - - const chartData = await portfolioCalculator.getChartData({ - start: new Date() - }); - - const currentPositions = await portfolioCalculator.getCurrentPositions( - new Date() - ); - - const investments = portfolioCalculator.getInvestments(); - - const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ - data: chartData, - groupBy: 'month' - }); - - spy.mockRestore(); - - expect(currentPositions).toEqual({ - currentValue: new Big(0), - grossPerformance: new Big(0), - grossPerformancePercentage: new Big(0), - grossPerformancePercentageWithCurrencyEffect: new Big(0), - grossPerformanceWithCurrencyEffect: new Big(0), - hasErrors: false, - netPerformance: new Big(0), - netPerformancePercentage: new Big(0), - netPerformancePercentageWithCurrencyEffect: new Big(0), - netPerformanceWithCurrencyEffect: new Big(0), - positions: [], - totalInvestment: new Big(0) - }); - - expect(investments).toEqual([]); - - expect(investmentsByMonth).toEqual([]); - }); - }); -}); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-novn-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-novn-baln-buy-and-sell.spec.ts deleted file mode 100644 index b56118b0c..000000000 --- a/apps/api/src/app/portfolio/portfolio-calculator-novn-baln-buy-and-sell.spec.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { parseDate } from '@ghostfolio/common/helper'; - -import Big from 'big.js'; - -import { CurrentRateServiceMock } from './current-rate.service.mock'; -import { PortfolioCalculator } from './portfolio-calculator'; - -jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - CurrentRateService: jest.fn().mockImplementation(() => { - return CurrentRateServiceMock; - }) - }; -}); - -describe('PortfolioCalculator', () => { - let currentRateService: CurrentRateService; - let exchangeRateDataService: ExchangeRateDataService; - - beforeEach(() => { - currentRateService = new CurrentRateService(null, null, null, null); - - exchangeRateDataService = new ExchangeRateDataService( - null, - null, - null, - null - ); - }); - - describe('get current positions', () => { - it.only('with NOVN.SW and BALN.SW buy and sell', async () => { - const portfolioCalculator = new PortfolioCalculator({ - currentRateService, - exchangeRateDataService, - currency: 'CHF', - orders: [ - { - currency: 'CHF', - date: '2022-03-07', - dataSource: 'YAHOO', - fee: new Big(0), - name: 'Novartis AG', - quantity: new Big(2), - symbol: 'NOVN.SW', - type: 'BUY', - unitPrice: new Big(75.8) - }, - { - currency: 'CHF', - date: '2022-04-01', - dataSource: 'YAHOO', - fee: new Big(0), - name: 'Novartis AG', - quantity: new Big(0), - symbol: 'NOVN.SW', - type: 'BUY', - unitPrice: new Big(80.0) - }, - { - currency: 'CHF', - date: '2022-04-08', - dataSource: 'YAHOO', - fee: new Big(0), - name: 'Novartis AG', - quantity: new Big(2), - symbol: 'NOVN.SW', - type: 'SELL', - unitPrice: new Big(85.73) - }, - { - currency: 'CHF', - date: '2022-03-22', - dataSource: 'YAHOO', - fee: new Big(1.55), - name: 'Bâloise Holding AG', - quantity: new Big(2), - symbol: 'BALN.SW', - type: 'BUY', - unitPrice: new Big(142.9) - }, - { - currency: 'CHF', - date: '2022-04-01', - dataSource: 'YAHOO', - fee: new Big(0), - name: 'Bâloise Holding AG', - quantity: new Big(0), - symbol: 'BALN.SW', - type: 'BUY', - unitPrice: new Big(138) - }, - { - currency: 'CHF', - date: '2022-04-10', - dataSource: 'YAHOO', - fee: new Big(1.65), - name: 'Bâloise Holding AG', - quantity: new Big(2), - symbol: 'BALN.SW', - type: 'SELL', - unitPrice: new Big(136.6) - } - ] - }); - - portfolioCalculator.computeTransactionPoints(); - - const spy = jest - .spyOn(Date, 'now') - .mockImplementation(() => parseDate('2022-04-11').getTime()); - - const chartData = await portfolioCalculator.getChartData({ - start: parseDate('2022-03-07'), - calculateTimeWeightedPerformance: true - }); - - spy.mockRestore(); - - expect(chartData[0]).toEqual({ - date: '2022-03-07', - investmentValueWithCurrencyEffect: 151.6, - netPerformance: 0, - netPerformanceInPercentage: 0, - netPerformanceInPercentageWithCurrencyEffect: 0, - netPerformanceWithCurrencyEffect: 0, - timeWeightedPerformance: 0, - totalInvestment: 151.6, - totalInvestmentValueWithCurrencyEffect: 151.6, - value: 151.6, - valueWithCurrencyEffect: 151.6 - }); - - expect(chartData[chartData.length - 1]).toEqual({ - date: '2022-04-11', - investmentValueWithCurrencyEffect: 0, - netPerformance: 19.86, - netPerformanceInPercentage: 13.100263852242744, - netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744, - netPerformanceWithCurrencyEffect: 19.86, - timeWeightedPerformance: 0, - totalInvestment: 0, - totalInvestmentValueWithCurrencyEffect: 0, - value: 0, - valueWithCurrencyEffect: 0 - }); - }); - }); -}); diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts deleted file mode 100644 index c625e2b51..000000000 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ /dev/null @@ -1,2772 +0,0 @@ -import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; -import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; -import { - DataProviderInfo, - HistoricalDataItem, - InvestmentItem, - ResponseError, - SymbolMetrics, - TimelinePosition -} from '@ghostfolio/common/interfaces'; -import { GroupBy } from '@ghostfolio/common/types'; - -import { Logger } from '@nestjs/common'; -import { Type as TypeOfOrder } from '@prisma/client'; -import Big from 'big.js'; -import { - addDays, - addMilliseconds, - differenceInDays, - endOfDay, - format, - isBefore, - isAfter, - isSameDay, - subDays -} from 'date-fns'; -import { - cloneDeep, - first, - flowRight, - isNumber, - last, - sortBy, - uniq -} from 'lodash'; - -import { CurrentRateService } from './current-rate.service'; -import { CurrentPositions } from './interfaces/current-positions.interface'; -import { GetValueObject } from './interfaces/get-value-object.interface'; -import { - PortfolioOrderItem, - WithCurrencyEffect -} from './interfaces/portfolio-calculator.interface'; -import { PortfolioOrder } from './interfaces/portfolio-order.interface'; -import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface'; -import { TransactionPoint } from './interfaces/transaction-point.interface'; - -export class PortfolioCalculator { - private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT = - true; - - private static readonly ENABLE_LOGGING = false; - - private currency: string; - private currentRateService: CurrentRateService; - private dataProviderInfos: DataProviderInfo[]; - private exchangeRateDataService: ExchangeRateDataService; - private orders: PortfolioOrder[]; - private transactionPoints: TransactionPoint[]; - - public constructor({ - currency, - currentRateService, - exchangeRateDataService, - orders - }: { - currency: string; - currentRateService: CurrentRateService; - exchangeRateDataService: ExchangeRateDataService; - orders: PortfolioOrder[]; - }) { - this.currency = currency; - this.currentRateService = currentRateService; - this.exchangeRateDataService = exchangeRateDataService; - this.orders = orders; - - this.orders.sort((a, b) => { - return a.date?.localeCompare(b.date); - }); - } - - @LogPerformance - public computeTransactionPoints() { - this.transactionPoints = []; - const symbols: { [symbol: string]: TransactionPointSymbol } = {}; - - let lastDate: string = null; - let lastTransactionPoint: TransactionPoint = null; - for (const order of this.orders) { - const currentDate = order.date; - - let currentTransactionPointItem: TransactionPointSymbol; - const oldAccumulatedSymbol = symbols[order.symbol]; - - const factor = this.getFactor(order.type); - const unitPrice = new Big(order.unitPrice); - currentTransactionPointItem = this.getCurrentTransactionPointItem( - oldAccumulatedSymbol, - order, - factor, - unitPrice, - currentTransactionPointItem - ); - - symbols[order.symbol] = currentTransactionPointItem; - - const items = lastTransactionPoint?.items ?? []; - const newItems = items.filter( - (transactionPointItem) => transactionPointItem.symbol !== order.symbol - ); - newItems.push(currentTransactionPointItem); - newItems.sort((a, b) => a.symbol?.localeCompare(b.symbol)); - if (lastDate !== currentDate || lastTransactionPoint === null) { - lastTransactionPoint = { - date: currentDate, - items: newItems - }; - this.transactionPoints.push(lastTransactionPoint); - } else { - lastTransactionPoint.items = newItems; - } - lastDate = currentDate; - } - } - - @LogPerformance - private getCurrentTransactionPointItem( - oldAccumulatedSymbol: TransactionPointSymbol, - order: PortfolioOrder, - factor: number, - unitPrice: Big, - currentTransactionPointItem: TransactionPointSymbol - ) { - if (oldAccumulatedSymbol) { - currentTransactionPointItem = this.handleSubsequentTransactions( - order, - factor, - oldAccumulatedSymbol, - unitPrice, - currentTransactionPointItem - ); - } else { - currentTransactionPointItem = { - currency: order.currency, - dataSource: order.dataSource, - fee: order.fee, - firstBuyDate: order.date, - investment: unitPrice.mul(order.quantity).mul(factor), - quantity: order.quantity.mul(factor), - symbol: order.symbol, - tags: order.tags, - transactionCount: 1 - }; - } - return currentTransactionPointItem; - } - - @LogPerformance - private handleSubsequentTransactions( - order: PortfolioOrder, - factor: number, - oldAccumulatedSymbol: TransactionPointSymbol, - unitPrice: Big, - currentTransactionPointItem: TransactionPointSymbol - ) { - const newQuantity = order.quantity - .mul(factor) - .plus(oldAccumulatedSymbol.quantity); - - let investment = new Big(0); - - if (newQuantity.gt(0)) { - if (order.type === 'BUY' || order.type === 'STAKE') { - investment = oldAccumulatedSymbol.investment.plus( - order.quantity.mul(unitPrice) - ); - } else if (order.type === 'SELL') { - const averagePrice = oldAccumulatedSymbol.investment.div( - oldAccumulatedSymbol.quantity - ); - investment = oldAccumulatedSymbol.investment.minus( - order.quantity.mul(averagePrice) - ); - } - } - - currentTransactionPointItem = { - investment, - currency: order.currency, - dataSource: order.dataSource, - fee: order.fee.plus(oldAccumulatedSymbol.fee), - firstBuyDate: oldAccumulatedSymbol.firstBuyDate, - quantity: newQuantity, - symbol: order.symbol, - tags: order.tags, - transactionCount: oldAccumulatedSymbol.transactionCount + 1 - }; - return currentTransactionPointItem; - } - - @LogPerformance - public getAnnualizedPerformancePercent({ - daysInMarket, - netPerformancePercent - }: { - daysInMarket: number; - netPerformancePercent: Big; - }): Big { - if (isNumber(daysInMarket) && daysInMarket > 0) { - const exponent = new Big(365).div(daysInMarket).toNumber(); - return new Big( - Math.pow(netPerformancePercent.plus(1).toNumber(), exponent) - ).minus(1); - } - - return new Big(0); - } - - public getTransactionPoints(): TransactionPoint[] { - return this.transactionPoints; - } - - public setTransactionPoints(transactionPoints: TransactionPoint[]) { - this.transactionPoints = transactionPoints; - } - - @LogPerformance - public async getChartData({ - start, - end = new Date(Date.now()), - step = 1, - calculateTimeWeightedPerformance = false - }: { - end?: Date; - start: Date; - step?: number; - calculateTimeWeightedPerformance?: boolean; - }): Promise { - const symbols: { [symbol: string]: boolean } = {}; - - const transactionPointsBeforeEndDate = - this.transactionPoints?.filter((transactionPoint) => { - return isBefore(parseDate(transactionPoint.date), end); - }) ?? []; - - const currencies: { [symbol: string]: string } = {}; - const dates: Date[] = []; - const dataGatheringItems: IDataGatheringItem[] = []; - const firstIndex = transactionPointsBeforeEndDate.length; - - this.pushDataGatheringsSymbols( - transactionPointsBeforeEndDate, - firstIndex, - dataGatheringItems, - currencies, - symbols - ); - this.getRelevantStartAndEndDates(start, end, dates, step); - const dataGartheringDates = [ - ...dates, - ...this.orders - .filter((o) => { - let dateParsed = Date.parse(o.date); - return isBefore(dateParsed, end) && isAfter(dateParsed, start); - }) - .map((o) => { - let dateParsed = Date.parse(o.date); - return new Date(dateParsed); - }) - ]; - - const { dataProviderInfos, values: marketSymbols } = - await this.getInformationFromCurrentRateService( - currencies, - dataGatheringItems, - dataGartheringDates - ); - - this.dataProviderInfos = dataProviderInfos; - - const marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; - } = {}; - - if (!marketSymbols?.length) { - return []; - } - - this.populateMarketSymbolMap(marketSymbols, marketSymbolMap); - - const accumulatedValuesByDate: { - [date: string]: { - investmentValueWithCurrencyEffect: Big; - totalCurrentValue: Big; - totalCurrentValueWithCurrencyEffect: Big; - totalInvestmentValue: Big; - totalInvestmentValueWithCurrencyEffect: Big; - totalNetPerformanceValue: Big; - totalNetPerformanceValueWithCurrencyEffect: Big; - totalTimeWeightedInvestmentValue: Big; - totalTimeWeightedInvestmentValueWithCurrencyEffect: Big; - totalTimeWeightedPerformance: Big; - }; - } = {}; - - const valuesBySymbol: { - [symbol: string]: { - currentValues: { [date: string]: Big }; - currentValuesWithCurrencyEffect: { [date: string]: Big }; - investmentValuesAccumulated: { [date: string]: Big }; - investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big }; - investmentValuesWithCurrencyEffect: { [date: string]: Big }; - netPerformanceValues: { [date: string]: Big }; - netPerformanceValuesWithCurrencyEffect: { [date: string]: Big }; - timeWeightedInvestmentValues: { [date: string]: Big }; - timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big }; - netPerformanceValuesPercentage: { [date: string]: Big }; - unitPrices: { [date: string]: Big }; - }; - } = {}; - - let exchangeRatesByCurrency = - await this.exchangeRateDataService.getExchangeRatesByCurrency({ - currencies: uniq(Object.values(currencies)), - endDate: endOfDay(end), - startDate: parseDate(this.transactionPoints?.[0]?.date), - targetCurrency: this.currency - }); - - this.populateSymbolMetrics( - symbols, - end, - marketSymbolMap, - start, - step, - exchangeRatesByCurrency, - valuesBySymbol, - currencies - ); - - return dates.map((date: Date, index: number, dates: Date[]) => { - let previousDate: Date = index > 0 ? dates[index - 1] : null; - return this.calculatePerformance( - date, - previousDate, - valuesBySymbol, - calculateTimeWeightedPerformance, - accumulatedValuesByDate - ); - }); - } - - @LogPerformance - private calculatePerformance( - date: Date, - previousDate: Date, - valuesBySymbol: { - [symbol: string]: { - currentValues: { [date: string]: Big }; - currentValuesWithCurrencyEffect: { [date: string]: Big }; - investmentValuesAccumulated: { [date: string]: Big }; - investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big }; - investmentValuesWithCurrencyEffect: { [date: string]: Big }; - netPerformanceValues: { [date: string]: Big }; - netPerformanceValuesWithCurrencyEffect: { [date: string]: Big }; - timeWeightedInvestmentValues: { [date: string]: Big }; - timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big }; - netPerformanceValuesPercentage: { [date: string]: Big }; - unitPrices: { [date: string]: Big }; - }; - }, - calculateTimeWeightedPerformance: boolean, - accumulatedValuesByDate: { - [date: string]: { - investmentValueWithCurrencyEffect: Big; - totalCurrentValue: Big; - totalCurrentValueWithCurrencyEffect: Big; - totalInvestmentValue: Big; - totalInvestmentValueWithCurrencyEffect: Big; - totalNetPerformanceValue: Big; - totalNetPerformanceValueWithCurrencyEffect: Big; - totalTimeWeightedInvestmentValue: Big; - totalTimeWeightedInvestmentValueWithCurrencyEffect: Big; - totalTimeWeightedPerformance: Big; - }; - } - ) { - const dateString = format(date, DATE_FORMAT); - const previousDateString = previousDate - ? format(previousDate, DATE_FORMAT) - : null; - let totalCurrentValue = new Big(0); - let previousTotalInvestmentValue = new Big(0); - - if (calculateTimeWeightedPerformance && previousDateString) { - previousTotalInvestmentValue = - accumulatedValuesByDate[previousDateString].totalCurrentValue; - } - - for (const symbol of Object.keys(valuesBySymbol)) { - const symbolValues = valuesBySymbol[symbol]; - const symbolCurrentValues = - symbolValues.currentValues?.[dateString] ?? new Big(0); - - totalCurrentValue = totalCurrentValue.plus(symbolCurrentValues); - - let timeWeightedPerformanceContribution = new Big(0); - - if ( - previousTotalInvestmentValue.toNumber() && - symbolValues.netPerformanceValuesPercentage && - ( - symbolValues.currentValues?.[previousDateString] ?? new Big(0) - ).toNumber() - ) { - const previousValue = - symbolValues.currentValues?.[previousDateString] ?? new Big(0); - const netPerformance = - symbolValues.unitPrices?.[dateString] && - symbolValues.unitPrices?.[previousDateString] - ? symbolValues.unitPrices[dateString] - .div(symbolValues.unitPrices[previousDateString]) - .minus(1) - : new Big(0); - timeWeightedPerformanceContribution = previousValue - .div(previousTotalInvestmentValue) - .mul(netPerformance); - } - accumulatedValuesByDate = this.accumulatedValuesByDate( - valuesBySymbol, - symbol, - dateString, - accumulatedValuesByDate, - timeWeightedPerformanceContribution - ); - } - - const { - investmentValueWithCurrencyEffect, - totalCurrentValueWithCurrencyEffect, - totalInvestmentValueWithCurrencyEffect, - totalNetPerformanceValueWithCurrencyEffect, - totalTimeWeightedInvestmentValue, - totalTimeWeightedInvestmentValueWithCurrencyEffect, - totalInvestmentValue, - totalTimeWeightedPerformance, - totalNetPerformanceValue - } = accumulatedValuesByDate[dateString]; - - let totalNetTimeWeightedPerformance = new Big(0); - - if (previousDateString) { - totalNetTimeWeightedPerformance = ( - accumulatedValuesByDate[previousDateString] - ?.totalTimeWeightedPerformance ?? new Big(0) - ) - .plus(1) - .mul(totalTimeWeightedPerformance.plus(1)) - .minus(1) - .mul(100); - } - - const netPerformanceInPercentage = totalTimeWeightedInvestmentValue.eq(0) - ? 0 - : totalNetPerformanceValue - .div(totalTimeWeightedInvestmentValue) - .mul(100) - .toNumber(); - - const netPerformanceInPercentageWithCurrencyEffect = - totalTimeWeightedInvestmentValueWithCurrencyEffect.eq(0) - ? 0 - : totalNetPerformanceValueWithCurrencyEffect - .div(totalTimeWeightedInvestmentValueWithCurrencyEffect) - .mul(100) - .toNumber(); - - return { - date: dateString, - netPerformanceInPercentage, - netPerformanceInPercentageWithCurrencyEffect, - netPerformance: totalNetPerformanceValue.toNumber(), - totalInvestment: totalInvestmentValue.toNumber(), - value: totalCurrentValue.toNumber(), - valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber(), - timeWeightedPerformance: totalNetTimeWeightedPerformance.toNumber(), - investmentValueWithCurrencyEffect: - investmentValueWithCurrencyEffect.toNumber(), - netPerformanceWithCurrencyEffect: - totalNetPerformanceValueWithCurrencyEffect.toNumber(), - totalInvestmentValueWithCurrencyEffect: - totalInvestmentValueWithCurrencyEffect.toNumber() - }; - } - - @LogPerformance - private accumulatedValuesByDate( - valuesBySymbol: { - [symbol: string]: { - currentValues: { [date: string]: Big }; - currentValuesWithCurrencyEffect: { [date: string]: Big }; - investmentValuesAccumulated: { [date: string]: Big }; - investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big }; - investmentValuesWithCurrencyEffect: { [date: string]: Big }; - netPerformanceValues: { [date: string]: Big }; - netPerformanceValuesWithCurrencyEffect: { [date: string]: Big }; - timeWeightedInvestmentValues: { [date: string]: Big }; - timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big }; - netPerformanceValuesPercentage: { [date: string]: Big }; - }; - }, - symbol: string, - dateString: string, - accumulatedValuesByDate: { - [date: string]: { - investmentValueWithCurrencyEffect: Big; - totalCurrentValue: Big; - totalCurrentValueWithCurrencyEffect: Big; - totalInvestmentValue: Big; - totalInvestmentValueWithCurrencyEffect: Big; - totalNetPerformanceValue: Big; - totalNetPerformanceValueWithCurrencyEffect: Big; - totalTimeWeightedInvestmentValue: Big; - totalTimeWeightedInvestmentValueWithCurrencyEffect: Big; - totalTimeWeightedPerformance: Big; - }; - }, - timeWeightedPerformance: Big - ) { - const symbolValues = valuesBySymbol[symbol]; - - const currentValue = symbolValues.currentValues?.[dateString] ?? new Big(0); - - const currentValueWithCurrencyEffect = - symbolValues.currentValuesWithCurrencyEffect?.[dateString] ?? new Big(0); - - const investmentValueAccumulated = - symbolValues.investmentValuesAccumulated?.[dateString] ?? new Big(0); - - const investmentValueAccumulatedWithCurrencyEffect = - symbolValues.investmentValuesAccumulatedWithCurrencyEffect?.[ - dateString - ] ?? new Big(0); - - const investmentValueWithCurrencyEffect = - symbolValues.investmentValuesWithCurrencyEffect?.[dateString] ?? - new Big(0); - - const netPerformanceValue = - symbolValues.netPerformanceValues?.[dateString] ?? new Big(0); - - const netPerformanceValueWithCurrencyEffect = - symbolValues.netPerformanceValuesWithCurrencyEffect?.[dateString] ?? - new Big(0); - - const timeWeightedInvestmentValue = - symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0); - - const timeWeightedInvestmentValueWithCurrencyEffect = - symbolValues.timeWeightedInvestmentValuesWithCurrencyEffect?.[ - dateString - ] ?? new Big(0); - - accumulatedValuesByDate[dateString] = { - investmentValueWithCurrencyEffect: ( - accumulatedValuesByDate[dateString] - ?.investmentValueWithCurrencyEffect ?? new Big(0) - ).add(investmentValueWithCurrencyEffect), - totalCurrentValue: ( - accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0) - ).add(currentValue), - totalCurrentValueWithCurrencyEffect: ( - accumulatedValuesByDate[dateString] - ?.totalCurrentValueWithCurrencyEffect ?? new Big(0) - ).add(currentValueWithCurrencyEffect), - totalInvestmentValue: ( - accumulatedValuesByDate[dateString]?.totalInvestmentValue ?? new Big(0) - ).add(investmentValueAccumulated), - totalInvestmentValueWithCurrencyEffect: ( - accumulatedValuesByDate[dateString] - ?.totalInvestmentValueWithCurrencyEffect ?? new Big(0) - ).add(investmentValueAccumulatedWithCurrencyEffect), - totalNetPerformanceValue: ( - accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ?? - new Big(0) - ).add(netPerformanceValue), - totalNetPerformanceValueWithCurrencyEffect: ( - accumulatedValuesByDate[dateString] - ?.totalNetPerformanceValueWithCurrencyEffect ?? new Big(0) - ).add(netPerformanceValueWithCurrencyEffect), - totalTimeWeightedInvestmentValue: ( - accumulatedValuesByDate[dateString]?.totalTimeWeightedInvestmentValue ?? - new Big(0) - ).add(timeWeightedInvestmentValue), - totalTimeWeightedInvestmentValueWithCurrencyEffect: ( - accumulatedValuesByDate[dateString] - ?.totalTimeWeightedInvestmentValueWithCurrencyEffect ?? new Big(0) - ).add(timeWeightedInvestmentValueWithCurrencyEffect), - totalTimeWeightedPerformance: ( - accumulatedValuesByDate[dateString]?.totalTimeWeightedPerformance ?? - new Big(0) - ).add(timeWeightedPerformance) - }; - - return accumulatedValuesByDate; - } - - @LogPerformance - private populateSymbolMetrics( - symbols: { [symbol: string]: boolean }, - end: Date, - marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, - start: Date, - step: number, - exchangeRatesByCurrency, - valuesBySymbol: { - [symbol: string]: { - currentValues: { [date: string]: Big }; - currentValuesWithCurrencyEffect: { [date: string]: Big }; - investmentValuesAccumulated: { [date: string]: Big }; - investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big }; - investmentValuesWithCurrencyEffect: { [date: string]: Big }; - netPerformanceValues: { [date: string]: Big }; - netPerformanceValuesWithCurrencyEffect: { [date: string]: Big }; - timeWeightedInvestmentValues: { [date: string]: Big }; - timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big }; - netPerformanceValuesPercentage: { [date: string]: Big }; - unitPrices: { [date: string]: Big }; - }; - }, - currencies: { [symbol: string]: string } - ) { - for (const symbol of Object.keys(symbols)) { - const { - currentValues, - currentValuesWithCurrencyEffect, - investmentValuesAccumulated, - investmentValuesAccumulatedWithCurrencyEffect, - investmentValuesWithCurrencyEffect, - netPerformanceValues, - netPerformanceValuesWithCurrencyEffect, - timeWeightedInvestmentValues, - timeWeightedInvestmentValuesWithCurrencyEffect, - netPerformanceValuesPercentage, - unitPrices - } = this.getSymbolMetrics({ - end, - marketSymbolMap, - start, - step, - symbol, - exchangeRates: - exchangeRatesByCurrency[`${currencies[symbol]}${this.currency}`], - isChartMode: true - }); - - valuesBySymbol[symbol] = { - currentValues, - currentValuesWithCurrencyEffect, - investmentValuesAccumulated, - investmentValuesAccumulatedWithCurrencyEffect, - investmentValuesWithCurrencyEffect, - netPerformanceValues, - netPerformanceValuesWithCurrencyEffect, - timeWeightedInvestmentValues, - timeWeightedInvestmentValuesWithCurrencyEffect, - netPerformanceValuesPercentage, - unitPrices - }; - } - } - - @LogPerformance - private populateMarketSymbolMap( - marketSymbols: GetValueObject[], - marketSymbolMap: { [date: string]: { [symbol: string]: Big } } - ) { - for (const marketSymbol of marketSymbols) { - const dateString = format(marketSymbol.date, DATE_FORMAT); - if (!marketSymbolMap[dateString]) { - marketSymbolMap[dateString] = {}; - } - if (marketSymbol.marketPrice) { - marketSymbolMap[dateString][marketSymbol.symbol] = new Big( - marketSymbol.marketPrice - ); - } - } - } - - @LogPerformance - private async getInformationFromCurrentRateService( - currencies: { [symbol: string]: string }, - dataGatheringItems: IDataGatheringItem[], - dates: Date[] - ): Promise<{ - dataProviderInfos: DataProviderInfo[]; - values: GetValueObject[]; - }> { - return await this.currentRateService.getValues({ - dataGatheringItems, - dateQuery: { - in: dates - } - }); - } - - @LogPerformance - private pushDataGatheringsSymbols( - transactionPointsBeforeEndDate: TransactionPoint[], - firstIndex: number, - dataGatheringItems: IDataGatheringItem[], - currencies: { [symbol: string]: string }, - symbols: { [symbol: string]: boolean } - ) { - if (transactionPointsBeforeEndDate.length > 0) { - for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) { - dataGatheringItems.push({ - dataSource: item.dataSource, - symbol: item.symbol - }); - currencies[item.symbol] = item.currency; - symbols[item.symbol] = true; - } - } - } - - @LogPerformance - private getRelevantStartAndEndDates( - start: Date, - end: Date, - dates: Date[], - step: number - ) { - let day = start; - - while (isBefore(day, end)) { - dates.push(resetHours(day)); - day = addDays(day, step); - } - - if (!isSameDay(last(dates), end)) { - dates.push(resetHours(end)); - } - } - - @LogPerformance - public async getCurrentPositions( - start: Date, - end = new Date(Date.now()), - calculatePerformance = true - ): Promise { - const transactionPointsBeforeEndDate = - this.transactionPoints?.filter((transactionPoint) => { - return isBefore(parseDate(transactionPoint.date), end); - }) ?? []; - - if (!transactionPointsBeforeEndDate.length) { - return { - currentValue: new Big(0), - grossPerformance: new Big(0), - grossPerformancePercentage: new Big(0), - grossPerformancePercentageWithCurrencyEffect: new Big(0), - grossPerformanceWithCurrencyEffect: new Big(0), - hasErrors: false, - netPerformance: new Big(0), - netPerformancePercentage: new Big(0), - netPerformancePercentageWithCurrencyEffect: new Big(0), - netPerformanceWithCurrencyEffect: new Big(0), - positions: [], - totalInvestment: new Big(0) - }; - } - - const lastTransactionPoint = - transactionPointsBeforeEndDate[transactionPointsBeforeEndDate.length - 1]; - - const currencies: { [symbol: string]: string } = {}; - const dataGatheringItems: IDataGatheringItem[] = []; - let dates: Date[] = []; - let firstIndex = transactionPointsBeforeEndDate.length; - let firstTransactionPoint: TransactionPoint = null; - - dates.push(resetHours(start)); - for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) { - dataGatheringItems.push({ - dataSource: item.dataSource, - symbol: item.symbol - }); - - currencies[item.symbol] = item.currency; - } - - for (let i = 0; i < transactionPointsBeforeEndDate.length; i++) { - if ( - !isBefore(parseDate(transactionPointsBeforeEndDate[i].date), start) && - firstTransactionPoint === null - ) { - firstTransactionPoint = transactionPointsBeforeEndDate[i]; - firstIndex = i; - } - if (firstTransactionPoint !== null) { - dates.push( - resetHours(parseDate(transactionPointsBeforeEndDate[i].date)) - ); - } - } - - dates.push(resetHours(end)); - - // Add dates of last week for fallback - dates.push(subDays(resetHours(new Date()), 7)); - dates.push(subDays(resetHours(new Date()), 6)); - dates.push(subDays(resetHours(new Date()), 5)); - dates.push(subDays(resetHours(new Date()), 4)); - dates.push(subDays(resetHours(new Date()), 3)); - dates.push(subDays(resetHours(new Date()), 2)); - dates.push(subDays(resetHours(new Date()), 1)); - dates.push(resetHours(new Date())); - - dates = uniq( - dates.map((date) => { - return date.getTime(); - }) - ) - .map((timestamp) => { - return new Date(timestamp); - }) - .sort((a, b) => { - return a.getTime() - b.getTime(); - }); - - let exchangeRatesByCurrency = - await this.exchangeRateDataService.getExchangeRatesByCurrency({ - currencies: uniq(Object.values(currencies)), - endDate: endOfDay(end), - startDate: parseDate(this.transactionPoints?.[0]?.date), - targetCurrency: this.currency - }); - - const { - dataProviderInfos, - errors: currentRateErrors, - values: marketSymbols - } = await this.currentRateService.getValues({ - dataGatheringItems, - dateQuery: { - in: dates - } - }); - - this.dataProviderInfos = dataProviderInfos; - - const marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; - } = {}; - - for (const marketSymbol of marketSymbols) { - const date = format(marketSymbol.date, DATE_FORMAT); - - if (!marketSymbolMap[date]) { - marketSymbolMap[date] = {}; - } - - if (marketSymbol.marketPrice) { - marketSymbolMap[date][marketSymbol.symbol] = new Big( - marketSymbol.marketPrice - ); - } - } - - const endDateString = format(end, DATE_FORMAT); - - if (firstIndex > 0) { - firstIndex--; - } - const initialValues: { [symbol: string]: Big } = {}; - - const positions: TimelinePosition[] = []; - let hasAnySymbolMetricsErrors = false; - - const errors: ResponseError['errors'] = []; - - for (const item of lastTransactionPoint.items) { - const marketPriceInBaseCurrency = marketSymbolMap[endDateString]?.[ - item.symbol - ]?.mul( - exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[ - endDateString - ] - ); - - const { - grossPerformance, - grossPerformancePercentage, - grossPerformancePercentageWithCurrencyEffect, - grossPerformanceWithCurrencyEffect, - hasErrors, - initialValue, - netPerformance, - netPerformancePercentage, - netPerformancePercentageWithCurrencyEffect, - netPerformanceWithCurrencyEffect, - timeWeightedInvestment, - timeWeightedInvestmentWithCurrencyEffect, - totalInvestment, - totalInvestmentWithCurrencyEffect - } = this.getSymbolMetrics({ - end, - marketSymbolMap, - start, - exchangeRates: - exchangeRatesByCurrency[`${item.currency}${this.currency}`], - symbol: item.symbol, - calculatePerformance - }); - - hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; - initialValues[item.symbol] = initialValue; - - positions.push({ - timeWeightedInvestment, - timeWeightedInvestmentWithCurrencyEffect, - averagePrice: item.quantity.eq(0) - ? new Big(0) - : item.investment.div(item.quantity), - currency: item.currency, - dataSource: item.dataSource, - fee: item.fee, - firstBuyDate: item.firstBuyDate, - grossPerformance: !hasErrors ? grossPerformance ?? null : null, - grossPerformancePercentage: !hasErrors - ? grossPerformancePercentage ?? null - : null, - grossPerformancePercentageWithCurrencyEffect: !hasErrors - ? grossPerformancePercentageWithCurrencyEffect ?? null - : null, - grossPerformanceWithCurrencyEffect: !hasErrors - ? grossPerformanceWithCurrencyEffect ?? null - : null, - investment: totalInvestment, - investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, - marketPrice: - marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null, - marketPriceInBaseCurrency: - marketPriceInBaseCurrency?.toNumber() ?? null, - netPerformance: !hasErrors ? netPerformance ?? null : null, - netPerformancePercentage: !hasErrors - ? netPerformancePercentage ?? null - : null, - netPerformancePercentageWithCurrencyEffect: !hasErrors - ? netPerformancePercentageWithCurrencyEffect ?? null - : null, - netPerformanceWithCurrencyEffect: !hasErrors - ? netPerformanceWithCurrencyEffect ?? null - : null, - quantity: item.quantity, - symbol: item.symbol, - tags: item.tags, - transactionCount: item.transactionCount - }); - - if ( - (hasErrors || - currentRateErrors.find(({ dataSource, symbol }) => { - return dataSource === item.dataSource && symbol === item.symbol; - })) && - item.investment.gt(0) - ) { - errors.push({ dataSource: item.dataSource, symbol: item.symbol }); - } - } - - const overall = this.calculateOverallPerformance(positions); - - return { - ...overall, - errors, - positions, - hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors - }; - } - - public getDataProviderInfos() { - return this.dataProviderInfos; - } - - @LogPerformance - public getInvestments(): { date: string; investment: Big }[] { - if (this.transactionPoints.length === 0) { - return []; - } - - return this.transactionPoints.map((transactionPoint) => { - return { - date: transactionPoint.date, - investment: transactionPoint.items.reduce( - (investment, transactionPointSymbol) => - investment.plus(transactionPointSymbol.investment), - new Big(0) - ) - }; - }); - } - - @LogPerformance - public getInvestmentsByGroup({ - data, - groupBy - }: { - data: HistoricalDataItem[]; - groupBy: GroupBy; - }): InvestmentItem[] { - const groupedData: { [dateGroup: string]: Big } = {}; - - for (const { date, investmentValueWithCurrencyEffect } of data) { - const dateGroup = - groupBy === 'month' ? date.substring(0, 7) : date.substring(0, 4); - groupedData[dateGroup] = (groupedData[dateGroup] ?? new Big(0)).plus( - investmentValueWithCurrencyEffect - ); - } - - return Object.keys(groupedData).map((dateGroup) => ({ - date: groupBy === 'month' ? `${dateGroup}-01` : `${dateGroup}-01-01`, - investment: groupedData[dateGroup].toNumber() - })); - } - - @LogPerformance - private calculateOverallPerformance(positions: TimelinePosition[]) { - let currentValue = new Big(0); - let grossPerformance = new Big(0); - let grossPerformanceWithCurrencyEffect = new Big(0); - let hasErrors = false; - let netPerformance = new Big(0); - let netPerformanceWithCurrencyEffect = new Big(0); - let 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.quantity && - currentPosition.marketPriceInBaseCurrency - ) { - currentValue = currentValue.plus( - new Big(currentPosition.marketPriceInBaseCurrency).mul( - currentPosition.quantity - ) - ); - } else { - hasErrors = true; - } - - if (currentPosition.investment) { - totalInvestment = totalInvestment.plus(currentPosition.investment); - - totalInvestmentWithCurrencyEffect = - totalInvestmentWithCurrencyEffect.plus( - currentPosition.investmentWithCurrencyEffect - ); - } else { - hasErrors = true; - } - - if (currentPosition.grossPerformance) { - grossPerformance = grossPerformance.plus( - currentPosition.grossPerformance - ); - - grossPerformanceWithCurrencyEffect = - grossPerformanceWithCurrencyEffect.plus( - currentPosition.grossPerformanceWithCurrencyEffect - ); - - netPerformance = netPerformance.plus(currentPosition.netPerformance); - - netPerformanceWithCurrencyEffect = - netPerformanceWithCurrencyEffect.plus( - currentPosition.netPerformanceWithCurrencyEffect - ); - } else if (!currentPosition.quantity.eq(0)) { - hasErrors = true; - } - - if (currentPosition.timeWeightedInvestment) { - totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus( - currentPosition.timeWeightedInvestment - ); - - totalTimeWeightedInvestmentWithCurrencyEffect = - totalTimeWeightedInvestmentWithCurrencyEffect.plus( - currentPosition.timeWeightedInvestmentWithCurrencyEffect - ); - } else if (!currentPosition.quantity.eq(0)) { - Logger.warn( - `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, - 'PortfolioCalculator' - ); - - hasErrors = true; - } - } - - return { - currentValue, - grossPerformance, - grossPerformanceWithCurrencyEffect, - hasErrors, - netPerformance, - netPerformanceWithCurrencyEffect, - totalInvestment, - totalInvestmentWithCurrencyEffect, - netPerformancePercentage: totalTimeWeightedInvestment.eq(0) - ? new Big(0) - : netPerformance.div(totalTimeWeightedInvestment), - netPerformancePercentageWithCurrencyEffect: - totalTimeWeightedInvestmentWithCurrencyEffect.eq(0) - ? new Big(0) - : netPerformanceWithCurrencyEffect.div( - totalTimeWeightedInvestmentWithCurrencyEffect - ), - grossPerformancePercentage: totalTimeWeightedInvestment.eq(0) - ? new Big(0) - : grossPerformance.div(totalTimeWeightedInvestment), - grossPerformancePercentageWithCurrencyEffect: - totalTimeWeightedInvestmentWithCurrencyEffect.eq(0) - ? new Big(0) - : grossPerformanceWithCurrencyEffect.div( - totalTimeWeightedInvestmentWithCurrencyEffect - ) - }; - } - - private getFactor(type: TypeOfOrder) { - let factor: number; - - switch (type) { - case 'BUY': - case 'STAKE': - case 'ITEM': - factor = 1; - break; - case 'SELL': - factor = -1; - break; - default: - factor = 0; - break; - } - - return factor; - } - - @LogPerformance - private getSymbolMetrics({ - end, - exchangeRates, - isChartMode = false, - marketSymbolMap, - start, - step = 1, - symbol, - calculatePerformance = true - }: { - end: Date; - exchangeRates: { [dateString: string]: number }; - isChartMode?: boolean; - marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; - }; - start: Date; - step?: number; - symbol: string; - calculatePerformance?: boolean; - }): SymbolMetrics { - let { - averagePriceAtStartDate, - totalUnits, - totalInvestment, - investmentAtStartDate, - valueAtStartDate, - maxTotalInvestment, - averagePriceAtEndDate, - initialValue, - fees, - feesAtStartDate, - lastAveragePrice, - grossPerformanceFromSells, - totalInvestmentWithGrossPerformanceFromSell, - grossPerformance, - grossPerformanceAtStartDate, - currentValues, - netPerformanceValues, - netPerformanceValuesPercentage, - investmentValues, - investmentValuesAccumulated, - maxInvestmentValues, - timeWeightedInvestmentValues - }: { - averagePriceAtStartDate: Big; - totalUnits: Big; - totalInvestment: WithCurrencyEffect; - investmentAtStartDate: any; - valueAtStartDate: WithCurrencyEffect; - maxTotalInvestment: Big; - averagePriceAtEndDate: Big; - initialValue: any; - fees: WithCurrencyEffect; - feesAtStartDate: WithCurrencyEffect; - lastAveragePrice: WithCurrencyEffect; - grossPerformanceFromSells: WithCurrencyEffect; - totalInvestmentWithGrossPerformanceFromSell: WithCurrencyEffect; - grossPerformance: WithCurrencyEffect; - grossPerformanceAtStartDate: WithCurrencyEffect; - currentValues: WithCurrencyEffect<{ [date: string]: Big }>; - netPerformanceValues: WithCurrencyEffect<{ [date: string]: Big }>; - netPerformanceValuesPercentage: { [date: string]: Big }; - investmentValues: WithCurrencyEffect<{ [date: string]: Big }>; - investmentValuesAccumulated: WithCurrencyEffect<{ [date: string]: Big }>; - maxInvestmentValues: { [date: string]: Big }; - timeWeightedInvestmentValues: WithCurrencyEffect<{ [date: string]: Big }>; - } = this.InitializeSymbolMetricValues(); - - const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; - - // Clone orders to keep the original values in this.orders - let orders: PortfolioOrderItem[] = cloneDeep(this.orders).filter( - (order) => { - return order.symbol === symbol; - } - ); - - if (orders.length <= 0) { - return { - currentValues: {}, - currentValuesWithCurrencyEffect: {}, - grossPerformance: new Big(0), - grossPerformancePercentage: new Big(0), - grossPerformancePercentageWithCurrencyEffect: new Big(0), - grossPerformanceWithCurrencyEffect: new Big(0), - hasErrors: false, - initialValue: new Big(0), - initialValueWithCurrencyEffect: new Big(0), - investmentValuesAccumulated: {}, - investmentValuesAccumulatedWithCurrencyEffect: {}, - investmentValuesWithCurrencyEffect: {}, - netPerformance: new Big(0), - netPerformancePercentage: new Big(0), - netPerformancePercentageWithCurrencyEffect: new Big(0), - netPerformanceValues: {}, - netPerformanceValuesWithCurrencyEffect: {}, - netPerformanceWithCurrencyEffect: new Big(0), - timeWeightedInvestment: new Big(0), - timeWeightedInvestmentWithCurrencyEffect: new Big(0), - timeWeightedInvestmentValues: {}, - timeWeightedInvestmentValuesWithCurrencyEffect: {}, - totalInvestment: new Big(0), - totalInvestmentWithCurrencyEffect: new Big(0), - netPerformanceValuesPercentage: {}, - unitPrices: {} - }; - } - - const dateOfFirstTransaction = new Date(first(orders).date); - - const unitPriceAtStartDate = - marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol]; - - const unitPriceAtEndDate = - marketSymbolMap[format(end, DATE_FORMAT)]?.[symbol]; - - if ( - !unitPriceAtEndDate || - (!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start)) - ) { - return { - currentValues: {}, - currentValuesWithCurrencyEffect: {}, - grossPerformance: new Big(0), - grossPerformancePercentage: new Big(0), - grossPerformancePercentageWithCurrencyEffect: new Big(0), - grossPerformanceWithCurrencyEffect: new Big(0), - hasErrors: true, - initialValue: new Big(0), - initialValueWithCurrencyEffect: new Big(0), - investmentValuesAccumulated: {}, - investmentValuesAccumulatedWithCurrencyEffect: {}, - investmentValuesWithCurrencyEffect: {}, - netPerformance: new Big(0), - netPerformancePercentage: new Big(0), - netPerformancePercentageWithCurrencyEffect: new Big(0), - netPerformanceValues: {}, - netPerformanceValuesWithCurrencyEffect: {}, - netPerformanceWithCurrencyEffect: new Big(0), - timeWeightedInvestment: new Big(0), - timeWeightedInvestmentValues: {}, - timeWeightedInvestmentValuesWithCurrencyEffect: {}, - timeWeightedInvestmentWithCurrencyEffect: new Big(0), - totalInvestment: new Big(0), - totalInvestmentWithCurrencyEffect: new Big(0), - netPerformanceValuesPercentage: {}, - unitPrices: {} - }; - } - - // Add a synthetic order at the start and the end date - this.addSyntheticStartAndEndOrders( - orders, - symbol, - start, - unitPriceAtStartDate, - end, - unitPriceAtEndDate - ); - - let day = start; - let lastUnitPrice: Big; - - ({ day, lastUnitPrice } = this.handleChartMode( - isChartMode, - orders, - day, - end, - symbol, - marketSymbolMap, - lastUnitPrice, - step - )); - - // Sort orders so that the start and end placeholder order are at the right - // position - orders = this.sortOrdersByTime(orders); - - const indexOfStartOrder = orders.findIndex((order) => { - return order.itemType === 'start'; - }); - - const indexOfEndOrder = orders.findIndex((order) => { - return order.itemType === 'end'; - }); - - const result = this.calculatePerformanceOfSymbol( - orders, - indexOfStartOrder, - unitPriceAtStartDate, - averagePriceAtStartDate, - totalUnits, - totalInvestment, - investmentAtStartDate, - valueAtStartDate, - maxTotalInvestment, - indexOfEndOrder, - averagePriceAtEndDate, - initialValue, - marketSymbolMap, - fees, - feesAtStartDate, - lastAveragePrice, - grossPerformanceFromSells, - totalInvestmentWithGrossPerformanceFromSell, - grossPerformance, - grossPerformanceAtStartDate, - isChartMode, - currentValues, - netPerformanceValues, - netPerformanceValuesPercentage, - investmentValues, - investmentValuesAccumulated, - maxInvestmentValues, - timeWeightedInvestmentValues, - unitPriceAtEndDate, - symbol, - exchangeRates, - currentExchangeRate, - calculatePerformance - ); - - let unitPrices = Object.keys(marketSymbolMap).reduce( - (obj, date) => - (obj = Object.assign(obj, { - [date]: marketSymbolMap[date][symbol]?.mul(exchangeRates[date]) - })), - {} - ); - - return { - currentValues: result.currentValues.Value, - currentValuesWithCurrencyEffect: result.currentValues.WithCurrencyEffect, - grossPerformancePercentage: result.grossPerformancePercentage.Value, - grossPerformancePercentageWithCurrencyEffect: - result.grossPerformancePercentage.WithCurrencyEffect, - initialValue: result.initialValue.Value, - initialValueWithCurrencyEffect: result.initialValue.WithCurrencyEffect, - investmentValuesWithCurrencyEffect: - result.investmentValues.WithCurrencyEffect, - netPerformancePercentage: result.netPerformancePercentage.Value, - netPerformancePercentageWithCurrencyEffect: - result.netPerformancePercentage.WithCurrencyEffect, - netPerformanceValues: result.netPerformanceValues.Value, - netPerformanceValuesWithCurrencyEffect: - result.netPerformanceValues.WithCurrencyEffect, - grossPerformance: result.grossPerformance.Value, - grossPerformanceWithCurrencyEffect: - result.grossPerformance.WithCurrencyEffect, - hasErrors: result.hasErrors, - netPerformance: result.netPerformance.Value, - netPerformanceWithCurrencyEffect: - result.netPerformance.WithCurrencyEffect, - totalInvestment: result.totalInvestment.Value, - totalInvestmentWithCurrencyEffect: - result.totalInvestment.WithCurrencyEffect, - netPerformanceValuesPercentage: result.netPerformanceValuesPercentage, - investmentValuesAccumulated: result.investmentValuesAccumulated.Value, - investmentValuesAccumulatedWithCurrencyEffect: - result.investmentValuesAccumulated.WithCurrencyEffect, - timeWeightedInvestmentValues: result.timeWeightedInvestmentValues.Value, - timeWeightedInvestmentValuesWithCurrencyEffect: - result.timeWeightedInvestmentValues.WithCurrencyEffect, - timeWeightedInvestment: - result.timeWeightedAverageInvestmentBetweenStartAndEndDate.Value, - timeWeightedInvestmentWithCurrencyEffect: - result.timeWeightedAverageInvestmentBetweenStartAndEndDate - .WithCurrencyEffect, - unitPrices - }; - } - - private InitializeSymbolMetricValues() { - const currentValues: WithCurrencyEffect<{ [date: string]: Big }> = { - Value: {}, - WithCurrencyEffect: {} - }; - let fees: WithCurrencyEffect = { - Value: new Big(0), - WithCurrencyEffect: new Big(0) - }; - let feesAtStartDate: WithCurrencyEffect = { - Value: new Big(0), - WithCurrencyEffect: new Big(0) - }; - let grossPerformance: WithCurrencyEffect = { - Value: new Big(0), - WithCurrencyEffect: new Big(0) - }; - let grossPerformanceAtStartDate: WithCurrencyEffect = { - Value: new Big(0), - WithCurrencyEffect: new Big(0) - }; - let grossPerformanceFromSells: WithCurrencyEffect = { - Value: new Big(0), - WithCurrencyEffect: new Big(0) - }; - let averagePriceAtEndDate = new Big(0); - let averagePriceAtStartDate = new Big(0); - const investmentValues: WithCurrencyEffect<{ [date: string]: Big }> = { - Value: {}, - WithCurrencyEffect: {} - }; - const maxInvestmentValues: { [date: string]: Big } = {}; - let maxTotalInvestment = new Big(0); - const netPerformanceValuesPercentage: { [date: string]: Big } = {}; - let initialValue; - let investmentAtStartDate; - const investmentValuesAccumulated: WithCurrencyEffect<{ - [date: string]: Big; - }> = { - Value: {}, - WithCurrencyEffect: {} - }; - let lastAveragePrice: WithCurrencyEffect = { - Value: new Big(0), - WithCurrencyEffect: new Big(0) - }; - const netPerformanceValues: WithCurrencyEffect<{ [date: string]: Big }> = { - Value: {}, - WithCurrencyEffect: {} - }; - const timeWeightedInvestmentValues: WithCurrencyEffect<{ - [date: string]: Big; - }> = { - Value: {}, - WithCurrencyEffect: {} - }; - - let totalInvestment: WithCurrencyEffect = { - Value: new Big(0), - WithCurrencyEffect: new Big(0) - }; - - let totalInvestmentWithGrossPerformanceFromSell: WithCurrencyEffect = { - Value: new Big(0), - WithCurrencyEffect: new Big(0) - }; - - let totalUnits = new Big(0); - let valueAtStartDate: WithCurrencyEffect = { - Value: new Big(0), - WithCurrencyEffect: new Big(0) - }; - return { - averagePriceAtStartDate, - totalUnits, - totalInvestment, - investmentAtStartDate, - valueAtStartDate, - maxTotalInvestment, - averagePriceAtEndDate, - initialValue, - fees, - feesAtStartDate, - lastAveragePrice, - grossPerformanceFromSells, - totalInvestmentWithGrossPerformanceFromSell, - grossPerformance, - grossPerformanceAtStartDate, - currentValues, - netPerformanceValues, - netPerformanceValuesPercentage, - investmentValues, - investmentValuesAccumulated, - maxInvestmentValues, - timeWeightedInvestmentValues - }; - } - - @LogPerformance - private calculatePerformanceOfSymbol( - orders: PortfolioOrderItem[], - indexOfStartOrder: number, - unitPriceAtStartDate: Big, - averagePriceAtStartDate: Big, - totalUnits: Big, - totalInvestment: WithCurrencyEffect, - investmentAtStartDate: WithCurrencyEffect, - valueAtStartDate: WithCurrencyEffect, - maxTotalInvestment: Big, - indexOfEndOrder: number, - averagePriceAtEndDate: Big, - initialValue: WithCurrencyEffect, - marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, - fees: WithCurrencyEffect, - feesAtStartDate: WithCurrencyEffect, - lastAveragePrice: WithCurrencyEffect, - grossPerformanceFromSells: WithCurrencyEffect, - totalInvestmentWithGrossPerformanceFromSell: WithCurrencyEffect, - grossPerformance: WithCurrencyEffect, - grossPerformanceAtStartDate: WithCurrencyEffect, - isChartMode: boolean, - currentValues: WithCurrencyEffect<{ [date: string]: Big }>, - netPerformanceValues: WithCurrencyEffect<{ [date: string]: Big }>, - netPerformanceValuesPercentage: { [date: string]: Big }, - investmentValues: WithCurrencyEffect<{ [date: string]: Big }>, - investmentValuesAccumulated: WithCurrencyEffect<{ [date: string]: Big }>, - maxInvestmentValues: { [date: string]: Big }, - timeWeightedInvestmentValues: WithCurrencyEffect<{ [date: string]: Big }>, - unitPriceAtEndDate: Big, - symbol: string, - exchangeRates: { [dateString: string]: number }, - currentExchangeRate: number, - calculatePerformance: boolean - ) { - let totalInvestmentDays = 0; - let sumOfTimeWeightedInvestments = { - Value: new Big(0), - WithCurrencyEffect: new Big(0) - }; - - ({ - lastAveragePrice, - grossPerformance, - feesAtStartDate, - grossPerformanceAtStartDate, - averagePriceAtStartDate, - totalUnits, - totalInvestment, - investmentAtStartDate, - valueAtStartDate, - maxTotalInvestment, - averagePriceAtEndDate, - initialValue, - fees, - netPerformanceValuesPercentage, - totalInvestmentDays - } = this.handleOrders( - orders, - indexOfStartOrder, - unitPriceAtStartDate, - averagePriceAtStartDate, - totalUnits, - totalInvestment, - investmentAtStartDate, - valueAtStartDate, - maxTotalInvestment, - averagePriceAtEndDate, - initialValue, - fees, - feesAtStartDate, - indexOfEndOrder, - marketSymbolMap, - grossPerformanceFromSells, - totalInvestmentWithGrossPerformanceFromSell, - lastAveragePrice, - grossPerformance, - grossPerformanceAtStartDate, - isChartMode, - currentValues, - netPerformanceValues, - netPerformanceValuesPercentage, - investmentValues, - investmentValuesAccumulated, - totalInvestmentDays, - sumOfTimeWeightedInvestments, - timeWeightedInvestmentValues, - exchangeRates, - currentExchangeRate, - calculatePerformance - )); - - if (!calculatePerformance) { - return { - currentValues, - grossPerformancePercentage: { - Value: new Big(0), - WithCurrencyEffect: new Big(0) - }, - initialValue, - investmentValues, - maxInvestmentValues, - netPerformancePercentage: { - Value: new Big(0), - WithCurrencyEffect: new Big(0) - }, - netPerformanceValues, - grossPerformance: { Value: new Big(0), WithCurrencyEffect: new Big(0) }, - hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), - netPerformance: { Value: new Big(0), WithCurrencyEffect: new Big(0) }, - averagePriceAtStartDate, - totalUnits, - totalInvestment, - investmentAtStartDate, - valueAtStartDate, - maxTotalInvestment, - averagePriceAtEndDate, - fees, - lastAveragePrice, - grossPerformanceFromSells, - totalInvestmentWithGrossPerformanceFromSell, - feesAtStartDate, - grossPerformanceAtStartDate, - netPerformanceValuesPercentage, - investmentValuesAccumulated, - timeWeightedInvestmentValues, - timeWeightedAverageInvestmentBetweenStartAndEndDate: { - Value: new Big(0), - WithCurrencyEffect: new Big(0) - } - }; - } - - const totalGrossPerformance = { - Value: grossPerformance.Value.minus(grossPerformanceAtStartDate.Value), - WithCurrencyEffect: grossPerformance.WithCurrencyEffect.minus( - grossPerformanceAtStartDate.WithCurrencyEffect - ) - }; - - const totalNetPerformance = { - Value: grossPerformance.Value.minus( - grossPerformanceAtStartDate.Value - ).minus(fees.Value.minus(feesAtStartDate.Value)), - WithCurrencyEffect: grossPerformance.WithCurrencyEffect.minus( - grossPerformanceAtStartDate.WithCurrencyEffect - ).minus(fees.WithCurrencyEffect.minus(feesAtStartDate.WithCurrencyEffect)) - }; - - const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.Value.plus( - maxTotalInvestment.minus(investmentAtStartDate.Value) - ); - - const timeWeightedAverageInvestmentBetweenStartAndEndDate = { - Value: - totalInvestmentDays > 0 - ? sumOfTimeWeightedInvestments.Value.div(totalInvestmentDays) - : new Big(0), - WithCurrencyEffect: - totalInvestmentDays > 0 - ? sumOfTimeWeightedInvestments.WithCurrencyEffect.div( - totalInvestmentDays - ) - : new Big(0) - }; - - const grossPerformancePercentage = { - Value: timeWeightedAverageInvestmentBetweenStartAndEndDate.Value.gt(0) - ? totalGrossPerformance.Value.div( - timeWeightedAverageInvestmentBetweenStartAndEndDate.Value - ) - : new Big(0), - WithCurrencyEffect: - timeWeightedAverageInvestmentBetweenStartAndEndDate.WithCurrencyEffect.gt( - 0 - ) - ? totalGrossPerformance.WithCurrencyEffect.div( - timeWeightedAverageInvestmentBetweenStartAndEndDate.WithCurrencyEffect - ) - : new Big(0) - }; - - const feesPerUnit = { - Value: totalUnits.gt(0) - ? fees.Value.minus(feesAtStartDate.Value).div(totalUnits) - : new Big(0), - WithCurrencyEffect: totalUnits.gt(0) - ? fees.WithCurrencyEffect.minus(feesAtStartDate.WithCurrencyEffect).div( - totalUnits - ) - : new Big(0) - }; - - const netPerformancePercentage = this.calculateNetPerformancePercentage( - timeWeightedAverageInvestmentBetweenStartAndEndDate, - totalNetPerformance - ); - - this.handleLogging( - symbol, - orders, - indexOfStartOrder, - unitPriceAtEndDate, - totalInvestment, - totalGrossPerformance, - grossPerformancePercentage, - feesPerUnit, - totalNetPerformance, - netPerformancePercentage - ); - return { - currentValues, - grossPerformancePercentage, - initialValue, - investmentValues, - maxInvestmentValues, - netPerformancePercentage, - netPerformanceValues, - grossPerformance: totalGrossPerformance, - hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), - netPerformance: totalNetPerformance, - averagePriceAtStartDate, - totalUnits, - totalInvestment, - investmentAtStartDate, - valueAtStartDate, - maxTotalInvestment, - averagePriceAtEndDate, - fees, - lastAveragePrice, - grossPerformanceFromSells, - totalInvestmentWithGrossPerformanceFromSell, - feesAtStartDate, - grossPerformanceAtStartDate, - netPerformanceValuesPercentage, - investmentValuesAccumulated, - timeWeightedInvestmentValues, - timeWeightedAverageInvestmentBetweenStartAndEndDate - }; - } - - @LogPerformance - private handleOrders( - orders: PortfolioOrderItem[], - indexOfStartOrder: number, - unitPriceAtStartDate: Big, - averagePriceAtStartDate: Big, - totalUnits: Big, - totalInvestment: WithCurrencyEffect, - investmentAtStartDate: WithCurrencyEffect, - valueAtStartDate: WithCurrencyEffect, - maxTotalInvestment: Big, - averagePriceAtEndDate: Big, - initialValue: WithCurrencyEffect, - fees: WithCurrencyEffect, - feesAtStartDate: WithCurrencyEffect, - indexOfEndOrder: number, - marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, - grossPerformanceFromSells: WithCurrencyEffect, - totalInvestmentWithGrossPerformanceFromSell: WithCurrencyEffect, - lastAveragePrice: WithCurrencyEffect, - grossPerformance: WithCurrencyEffect, - grossPerformanceAtStartDate: WithCurrencyEffect, - isChartMode: boolean, - currentValues: WithCurrencyEffect<{ [date: string]: Big }>, - netPerformanceValues: WithCurrencyEffect<{ [date: string]: Big }>, - netPerformanceValuesPercentage: { [date: string]: Big }, - investmentValues: WithCurrencyEffect<{ [date: string]: Big }>, - investmentValuesAccumulated: WithCurrencyEffect<{ [date: string]: Big }>, - totalInvestmentDays: number, - sumOfTimeWeightedInvestments: WithCurrencyEffect, - timeWeightedInvestmentValues: WithCurrencyEffect<{ [date: string]: Big }>, - exchangeRates: { [dateString: string]: number }, - currentExchangeRate: number, - calculatePerformance: boolean - ) { - for (let i = 0; i < orders.length; i += 1) { - const order = orders[i]; - const previousOrderDateString = i > 0 ? orders[i - 1].date : ''; - if (calculatePerformance) { - this.calculateNetPerformancePercentageForDateAndSymbol( - i, - orders, - order, - netPerformanceValuesPercentage, - marketSymbolMap - ); - } - - if (PortfolioCalculator.ENABLE_LOGGING) { - console.log(); - console.log(); - console.log(i + 1, order.type, order.itemType); - } - - this.handleStartOrder( - order, - indexOfStartOrder, - orders, - i, - unitPriceAtStartDate - ); - - const exchangeRateAtOrderDate = exchangeRates[order.date]; - - this.handleFeeAndUnitPriceOfOrder( - order, - currentExchangeRate, - exchangeRateAtOrderDate - ); - - // Calculate the average start price as soon as any units are held - let transactionInvestment: WithCurrencyEffect; - let totalInvestmentBeforeTransaction: WithCurrencyEffect; - - let valueOfInvestment: WithCurrencyEffect; - let valueOfInvestmentBeforeTransaction: WithCurrencyEffect; - ({ - transactionInvestment, - valueOfInvestment, - averagePriceAtStartDate, - totalUnits, - totalInvestment, - investmentAtStartDate, - valueAtStartDate, - maxTotalInvestment, - averagePriceAtEndDate, - initialValue, - fees, - totalInvestmentBeforeTransaction, - valueOfInvestmentBeforeTransaction - } = this.calculateInvestmentSpecificMetrics( - averagePriceAtStartDate, - i, - indexOfStartOrder, - totalUnits, - totalInvestment, - order, - investmentAtStartDate, - valueAtStartDate, - maxTotalInvestment, - averagePriceAtEndDate, - indexOfEndOrder, - initialValue, - marketSymbolMap, - fees - )); - - if (calculatePerformance) { - ({ - grossPerformanceFromSells, - totalInvestmentWithGrossPerformanceFromSell - } = this.calculateSellOrders( - order, - lastAveragePrice, - grossPerformanceFromSells, - totalInvestmentWithGrossPerformanceFromSell, - transactionInvestment - )); - } - - lastAveragePrice.Value = totalUnits.eq(0) - ? new Big(0) - : totalInvestmentWithGrossPerformanceFromSell.Value.div(totalUnits); - - lastAveragePrice.WithCurrencyEffect = totalUnits.eq(0) - ? new Big(0) - : totalInvestmentWithGrossPerformanceFromSell.WithCurrencyEffect.div( - totalUnits - ); - - if (PortfolioCalculator.ENABLE_LOGGING) { - console.log( - 'totalInvestmentWithGrossPerformanceFromSell', - totalInvestmentWithGrossPerformanceFromSell.Value.toNumber() - ); - console.log( - 'totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect', - totalInvestmentWithGrossPerformanceFromSell.WithCurrencyEffect.toNumber() - ); - console.log( - 'grossPerformanceFromSells', - grossPerformanceFromSells.Value.toNumber() - ); - console.log( - 'grossPerformanceFromSellsWithCurrencyEffect', - grossPerformanceFromSells.WithCurrencyEffect.toNumber() - ); - } - - if (!calculatePerformance) { - return { - lastAveragePrice, - grossPerformance, - feesAtStartDate, - grossPerformanceAtStartDate, - averagePriceAtStartDate, - totalUnits, - totalInvestment, - investmentAtStartDate, - valueAtStartDate, - maxTotalInvestment, - averagePriceAtEndDate, - initialValue, - fees, - netPerformanceValuesPercentage, - totalInvestmentDays - }; - } - - const newGrossPerformance = valueOfInvestment.Value.minus( - totalInvestment.Value - ).plus(grossPerformanceFromSells.Value); - - const newGrossPerformanceWithCurrencyEffect = - valueOfInvestment.WithCurrencyEffect.minus( - totalInvestment.WithCurrencyEffect - ).plus(grossPerformanceFromSells.WithCurrencyEffect); - - grossPerformance.Value = newGrossPerformance; - - grossPerformance.WithCurrencyEffect = - newGrossPerformanceWithCurrencyEffect; - - if (order.itemType === 'start') { - feesAtStartDate = { - Value: fees.Value, - WithCurrencyEffect: fees.WithCurrencyEffect - }; - grossPerformanceAtStartDate.Value = grossPerformance.Value; - - grossPerformanceAtStartDate.WithCurrencyEffect = - grossPerformance.WithCurrencyEffect; - } - - totalInvestmentDays = - this.calculatePerformancesForDateAndReturnTotalInvestmentDays( - isChartMode, - i, - indexOfStartOrder, - currentValues, - order, - valueOfInvestment, - valueOfInvestmentBeforeTransaction, - netPerformanceValues, - grossPerformance, - grossPerformanceAtStartDate, - fees, - feesAtStartDate, - investmentValues, - investmentValuesAccumulated, - totalInvestment, - timeWeightedInvestmentValues, - previousOrderDateString, - totalInvestmentDays, - sumOfTimeWeightedInvestments, - valueAtStartDate, - investmentAtStartDate, - totalInvestmentBeforeTransaction, - transactionInvestment.WithCurrencyEffect - ); - - this.handleLoggingOfInvestmentMetrics( - totalInvestment, - order, - transactionInvestment, - totalInvestmentWithGrossPerformanceFromSell, - grossPerformanceFromSells, - grossPerformance, - grossPerformanceAtStartDate - ); - - if (i === indexOfEndOrder) { - break; - } - } - return { - lastAveragePrice, - grossPerformance, - feesAtStartDate, - grossPerformanceAtStartDate, - averagePriceAtStartDate, - totalUnits, - totalInvestment, - investmentAtStartDate, - valueAtStartDate, - maxTotalInvestment, - averagePriceAtEndDate, - initialValue, - fees, - netPerformanceValuesPercentage, - totalInvestmentDays - }; - } - - @LogPerformance - private handleFeeAndUnitPriceOfOrder( - order: PortfolioOrderItem, - currentExchangeRate: number, - exchangeRateAtOrderDate: number - ) { - if (order.fee) { - order.feeInBaseCurrency = order.fee.mul(currentExchangeRate ?? 1); - order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul( - exchangeRateAtOrderDate ?? 1 - ); - } - - if (order.unitPrice) { - order.unitPriceInBaseCurrency = order.unitPrice.mul( - currentExchangeRate ?? 1 - ); - - order.unitPriceInBaseCurrencyWithCurrencyEffect = order.unitPrice.mul( - exchangeRateAtOrderDate ?? 1 - ); - } - } - - @LogPerformance - private calculateNetPerformancePercentageForDateAndSymbol( - i: number, - orders: PortfolioOrderItem[], - order: PortfolioOrderItem, - netPerformanceValuesPercentage: { [date: string]: Big }, - marketSymbolMap: { [date: string]: { [symbol: string]: Big } } - ) { - if (i > 0 && order) { - const previousOrder = orders[i - 1]; - - if (order.unitPrice.toNumber() && previousOrder.unitPrice.toNumber()) { - netPerformanceValuesPercentage[order.date] = order.unitPrice - .div(previousOrder.unitPrice) - .minus(1); - } else if ( - this.needsStakeHandling(order, marketSymbolMap, previousOrder) - ) { - this.stakeHandling( - previousOrder, - marketSymbolMap, - netPerformanceValuesPercentage, - order - ); - } else if (previousOrder.unitPrice.toNumber()) { - netPerformanceValuesPercentage[order.date] = new Big(-1); - } else if ( - this.ispreviousOrderStakeAndHasInformation( - previousOrder, - marketSymbolMap - ) - ) { - this.handleIfPreviousOrderIsStake( - netPerformanceValuesPercentage, - order, - marketSymbolMap, - previousOrder - ); - } else { - netPerformanceValuesPercentage[order.date] = new Big(0); - } - } - } - - @LogPerformance - private handleIfPreviousOrderIsStake( - netPerformanceValuesPercentage: { [date: string]: Big }, - order: PortfolioOrderItem, - marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, - previousOrder: PortfolioOrderItem - ) { - netPerformanceValuesPercentage[order.date] = order.unitPrice - .div(marketSymbolMap[previousOrder.date][previousOrder.symbol]) - .minus(1); - } - - @LogPerformance - private stakeHandling( - previousOrder: PortfolioOrderItem, - marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, - netPerformanceValuesPercentage: { [date: string]: Big }, - order: PortfolioOrderItem - ) { - let previousUnitPrice = - previousOrder.type === 'STAKE' - ? marketSymbolMap[previousOrder.date][previousOrder.symbol] - : previousOrder.unitPrice; - netPerformanceValuesPercentage[order.date] = marketSymbolMap[order.date][ - order.symbol - ] - ? marketSymbolMap[order.date][order.symbol] - .div(previousUnitPrice) - .minus(1) - : new Big(0); - } - - @LogPerformance - private ispreviousOrderStakeAndHasInformation( - previousOrder: PortfolioOrderItem, - marketSymbolMap: { [date: string]: { [symbol: string]: Big } } - ) { - return ( - previousOrder.type === 'STAKE' && - marketSymbolMap[previousOrder.date] && - marketSymbolMap[previousOrder.date][previousOrder.symbol]?.toNumber() - ); - } - - @LogPerformance - private needsStakeHandling( - order: PortfolioOrderItem, - marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, - previousOrder: PortfolioOrderItem - ) { - return ( - order.type === 'STAKE' && - previousOrder && - marketSymbolMap[order.date] && - marketSymbolMap[previousOrder.date] && - ((marketSymbolMap[previousOrder.date][previousOrder.symbol]?.toNumber() && - previousOrder.type === 'STAKE') || - (previousOrder.type !== 'STAKE' && previousOrder.unitPrice?.toNumber())) - ); - } - - @LogPerformance - private handleLoggingOfInvestmentMetrics( - totalInvestment: WithCurrencyEffect, - order: PortfolioOrderItem, - transactionInvestment: WithCurrencyEffect, - totalInvestmentWithGrossPerformanceFromSell: WithCurrencyEffect, - grossPerformanceFromSells: WithCurrencyEffect, - grossPerformance: WithCurrencyEffect, - grossPerformanceAtStartDate: WithCurrencyEffect - ) { - if (PortfolioCalculator.ENABLE_LOGGING) { - console.log('totalInvestment', totalInvestment.Value.toNumber()); - console.log('order.quantity', order.quantity.toNumber()); - console.log( - 'transactionInvestment', - transactionInvestment.Value.toNumber() - ); - console.log( - 'totalInvestmentWithGrossPerformanceFromSell', - totalInvestmentWithGrossPerformanceFromSell.Value.toNumber() - ); - console.log( - 'grossPerformanceFromSells', - grossPerformanceFromSells.Value.toNumber() - ); - console.log('totalInvestment', totalInvestment.Value.toNumber()); - console.log( - 'totalGrossPerformance', - grossPerformance.Value.minus( - grossPerformanceAtStartDate.Value - ).toNumber() - ); - } - } - - @LogPerformance - private calculateNetPerformancePercentage( - timeWeightedAverageInvestmentBetweenStartAndEndDate: WithCurrencyEffect, - totalNetPerformance: WithCurrencyEffect - ) { - return { - Value: timeWeightedAverageInvestmentBetweenStartAndEndDate.Value.gt(0) - ? totalNetPerformance.Value.div( - timeWeightedAverageInvestmentBetweenStartAndEndDate.Value - ) - : new Big(0), - WithCurrencyEffect: - timeWeightedAverageInvestmentBetweenStartAndEndDate.WithCurrencyEffect.gt( - 0 - ) - ? totalNetPerformance.WithCurrencyEffect.div( - timeWeightedAverageInvestmentBetweenStartAndEndDate.WithCurrencyEffect - ) - : new Big(0) - }; - } - - @LogPerformance - private calculateInvestmentSpecificMetrics( - averagePriceAtStartDate: Big, - i: number, - indexOfStartOrder: number, - totalUnits: Big, - totalInvestment: WithCurrencyEffect, - order: PortfolioOrderItem, - investmentAtStartDate: WithCurrencyEffect, - valueAtStartDate: WithCurrencyEffect, - maxTotalInvestment: Big, - averagePriceAtEndDate: Big, - indexOfEndOrder: number, - initialValue: WithCurrencyEffect, - marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, - fees: WithCurrencyEffect - ) { - averagePriceAtStartDate = this.calculateAveragePrice( - averagePriceAtStartDate, - i, - indexOfStartOrder, - totalUnits, - totalInvestment.Value - ); - - const totalInvestmentBeforeTransaction = { ...totalInvestment }; - - const valueOfInvestmentBeforeTransaction = { - Value: totalUnits.mul(order.unitPriceInBaseCurrency), - WithCurrencyEffect: totalUnits.mul( - order.unitPriceInBaseCurrencyWithCurrencyEffect - ) - }; - if (!investmentAtStartDate && i >= indexOfStartOrder) { - investmentAtStartDate = { - Value: totalInvestment.Value ?? new Big(0), - WithCurrencyEffect: totalInvestment.WithCurrencyEffect ?? new Big(0) - }; - valueAtStartDate.Value = valueOfInvestmentBeforeTransaction.Value; - valueAtStartDate.WithCurrencyEffect = - valueOfInvestmentBeforeTransaction.WithCurrencyEffect; - } - - const transactionInvestment = { - Value: this.getTransactionInvestment( - order, - totalUnits, - totalInvestment.Value - ), - WithCurrencyEffect: this.getTransactionInvestment( - order, - totalUnits, - totalInvestment.WithCurrencyEffect, - true - ) - }; - - totalInvestment.Value = totalInvestment.Value.plus( - transactionInvestment.Value - ); - - totalInvestment.WithCurrencyEffect = - totalInvestment.WithCurrencyEffect.plus( - transactionInvestment.WithCurrencyEffect - ); - - if ( - i >= indexOfStartOrder && - totalInvestment.Value.gt(maxTotalInvestment) - ) { - maxTotalInvestment = totalInvestment.Value; - } - - averagePriceAtEndDate = this.calculateAveragePriceAtEnd( - i, - indexOfEndOrder, - totalUnits, - averagePriceAtEndDate, - totalInvestment.Value - ); - - initialValue = this.calculateInitialValue( - i, - indexOfStartOrder, - initialValue?.Value, - valueOfInvestmentBeforeTransaction, - transactionInvestment, - order, - marketSymbolMap, - initialValue?.WithCurrencyEffect - ); - - fees.Value = fees.Value.plus(order.fee); - - fees.WithCurrencyEffect = fees.WithCurrencyEffect.plus( - order.feeInBaseCurrencyWithCurrencyEffect ?? 0 - ); - - totalUnits = totalUnits.plus( - order.quantity.mul(this.getFactor(order.type)) - ); - - const valueOfInvestment = { - Value: totalUnits.mul(order.unitPriceInBaseCurrency), - WithCurrencyEffect: totalUnits.mul( - order.unitPriceInBaseCurrencyWithCurrencyEffect - ) - }; - - return { - transactionInvestment, - valueOfInvestment, - averagePriceAtStartDate, - totalUnits, - totalInvestment, - investmentAtStartDate, - valueAtStartDate, - maxTotalInvestment, - averagePriceAtEndDate, - initialValue, - fees, - totalInvestmentBeforeTransaction, - valueOfInvestmentBeforeTransaction - }; - } - - @LogPerformance - private calculatePerformancesForDateAndReturnTotalInvestmentDays( - isChartMode: boolean, - i: number, - indexOfStartOrder: number, - currentValues: WithCurrencyEffect<{ [date: string]: Big }>, - order: PortfolioOrderItem, - valueOfInvestment: WithCurrencyEffect, - valueOfInvestmentBeforeTransaction: WithCurrencyEffect, - netPerformanceValues: WithCurrencyEffect<{ [date: string]: Big }>, - grossPerformance: WithCurrencyEffect, - grossPerformanceAtStartDate: WithCurrencyEffect, - fees: WithCurrencyEffect, - feesAtStartDate: WithCurrencyEffect, - investmentValues: WithCurrencyEffect<{ [date: string]: Big }>, - investmentValuesAccumulated: WithCurrencyEffect<{ [date: string]: Big }>, - totalInvestment: WithCurrencyEffect, - timeWeightedInvestmentValues: WithCurrencyEffect<{ [date: string]: Big }>, - previousOrderDateString: string, - totalInvestmentDays: number, - sumOfTimeWeightedInvestments: WithCurrencyEffect, - valueAtStartDate: WithCurrencyEffect, - investmentAtStartDate: WithCurrencyEffect, - totalInvestmentBeforeTransaction: WithCurrencyEffect, - transactionInvestmentWithCurrencyEffect: Big - ): number { - if (i > indexOfStartOrder) { - if (valueOfInvestmentBeforeTransaction.Value.gt(0)) { - // Calculate the number of days since the previous order - const orderDate = new Date(order.date); - const previousOrderDate = new Date(previousOrderDateString); - - let daysSinceLastOrder = differenceInDays(orderDate, previousOrderDate); - - // Set to at least 1 day, otherwise the transactions on the same day - // would not be considered in the time weighted calculation - if (daysSinceLastOrder <= 0) { - daysSinceLastOrder = 1; - } - - // Sum up the total investment days since the start date to calculate - // the time weighted investment - totalInvestmentDays += daysSinceLastOrder; - - sumOfTimeWeightedInvestments.Value = - sumOfTimeWeightedInvestments.Value.add( - valueAtStartDate.Value.minus(investmentAtStartDate.Value) - .plus(totalInvestmentBeforeTransaction.Value) - .mul(daysSinceLastOrder) - ); - - sumOfTimeWeightedInvestments.WithCurrencyEffect = - sumOfTimeWeightedInvestments.WithCurrencyEffect.add( - valueAtStartDate.WithCurrencyEffect.minus( - investmentAtStartDate.WithCurrencyEffect - ) - .plus(totalInvestmentBeforeTransaction.WithCurrencyEffect) - .mul(daysSinceLastOrder) - ); - } - - if (isChartMode) { - currentValues.Value[order.date] = valueOfInvestment.Value; - - currentValues.WithCurrencyEffect[order.date] = - valueOfInvestment.WithCurrencyEffect; - - netPerformanceValues.Value[order.date] = grossPerformance.Value.minus( - grossPerformanceAtStartDate.Value - ).minus(fees.Value.minus(feesAtStartDate.Value)); - - netPerformanceValues.WithCurrencyEffect[order.date] = - grossPerformance.WithCurrencyEffect.minus( - grossPerformanceAtStartDate.WithCurrencyEffect - ).minus( - fees.WithCurrencyEffect.minus(feesAtStartDate.WithCurrencyEffect) - ); - - investmentValuesAccumulated.Value[order.date] = totalInvestment.Value; - - investmentValuesAccumulated.WithCurrencyEffect[order.date] = - totalInvestment.WithCurrencyEffect; - - investmentValues.WithCurrencyEffect[order.date] = ( - investmentValues.WithCurrencyEffect[order.date] ?? new Big(0) - ).add(transactionInvestmentWithCurrencyEffect); - - timeWeightedInvestmentValues.Value[order.date] = - totalInvestmentDays > 0 - ? sumOfTimeWeightedInvestments.Value.div(totalInvestmentDays) - : new Big(0); - - timeWeightedInvestmentValues.WithCurrencyEffect[order.date] = - totalInvestmentDays > 0 - ? sumOfTimeWeightedInvestments.WithCurrencyEffect.div( - totalInvestmentDays - ) - : new Big(0); - } - } - return totalInvestmentDays; - } - - @LogPerformance - private calculateSellOrders( - order: PortfolioOrderItem, - lastAveragePrice: WithCurrencyEffect, - grossPerformanceFromSells: WithCurrencyEffect, - totalInvestmentWithGrossPerformanceFromSell: WithCurrencyEffect, - transactionInvestment: WithCurrencyEffect - ) { - const grossPerformanceFromSell = - order.type === TypeOfOrder.SELL - ? order.unitPriceInBaseCurrency - .minus(lastAveragePrice.Value) - .mul(order.quantity) - : new Big(0); - - const grossPerformanceFromSellWithCurrencyEffect = - order.type === TypeOfOrder.SELL - ? order.unitPriceInBaseCurrencyWithCurrencyEffect - .minus(lastAveragePrice.WithCurrencyEffect) - .mul(order.quantity) - : new Big(0); - - grossPerformanceFromSells.Value = grossPerformanceFromSells.Value.plus( - grossPerformanceFromSell - ); - - grossPerformanceFromSells.WithCurrencyEffect = - grossPerformanceFromSells.WithCurrencyEffect.plus( - grossPerformanceFromSellWithCurrencyEffect - ); - - totalInvestmentWithGrossPerformanceFromSell.Value = - totalInvestmentWithGrossPerformanceFromSell.Value.plus( - transactionInvestment.Value - ).plus(grossPerformanceFromSell); - - totalInvestmentWithGrossPerformanceFromSell.WithCurrencyEffect = - totalInvestmentWithGrossPerformanceFromSell.WithCurrencyEffect.plus( - transactionInvestment.WithCurrencyEffect - ).plus(grossPerformanceFromSellWithCurrencyEffect); - - return { - grossPerformanceFromSells, - totalInvestmentWithGrossPerformanceFromSell - }; - } - - @LogPerformance - private calculateInitialValue( - i: number, - indexOfStartOrder: number, - initialValue: Big, - valueOfInvestmentBeforeTransaction: WithCurrencyEffect, - transactionInvestment: WithCurrencyEffect, - order: PortfolioOrderItem, - marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, - initialValueWithCurrencyEffect: Big - ) { - if (i >= indexOfStartOrder && !initialValue) { - if ( - i === indexOfStartOrder && - !valueOfInvestmentBeforeTransaction.Value.eq(0) - ) { - initialValue = valueOfInvestmentBeforeTransaction.Value; - initialValueWithCurrencyEffect = - valueOfInvestmentBeforeTransaction.WithCurrencyEffect; - } else if (transactionInvestment.Value.gt(0)) { - initialValue = transactionInvestment.Value; - initialValueWithCurrencyEffect = - transactionInvestment.WithCurrencyEffect; - } else if (order.type === 'STAKE') { - // For Parachain Rewards or Stock SpinOffs, first transactionInvestment might be 0 if the symbol has been acquired for free - initialValue = order.quantity.mul( - marketSymbolMap[order.date]?.[order.symbol] ?? new Big(0) - ); - } - } - return { - Value: initialValue, - WithCurrencyEffect: initialValueWithCurrencyEffect - }; - } - - @LogPerformance - private calculateAveragePriceAtEnd( - i: number, - indexOfEndOrder: number, - totalUnits: Big, - averagePriceAtEndDate: Big, - totalInvestment: Big - ) { - if (i === indexOfEndOrder && totalUnits.gt(0)) { - averagePriceAtEndDate = totalInvestment.div(totalUnits); - } - return averagePriceAtEndDate; - } - - @LogPerformance - private getTransactionInvestment( - order: PortfolioOrderItem, - totalUnits: Big, - totalInvestment: Big, - withCurrencyEffect: boolean = false - ) { - return order.type === 'BUY' || order.type === 'STAKE' - ? order.quantity - .mul( - withCurrencyEffect - ? order.unitPriceInBaseCurrencyWithCurrencyEffect - : order.unitPriceInBaseCurrency - ) - .mul(this.getFactor(order.type)) - : totalUnits.gt(0) - ? totalInvestment - .div(totalUnits) - .mul(order.quantity) - .mul(this.getFactor(order.type)) - : new Big(0); - } - - @LogPerformance - private calculateAveragePrice( - averagePriceAtStartDate: Big, - i: number, - indexOfStartOrder: number, - totalUnits: Big, - totalInvestment: Big - ) { - if ( - averagePriceAtStartDate.eq(0) && - i >= indexOfStartOrder && - totalUnits.gt(0) - ) { - averagePriceAtStartDate = totalInvestment.div(totalUnits); - } - return averagePriceAtStartDate; - } - - @LogPerformance - private handleStartOrder( - order: PortfolioOrderItem, - indexOfStartOrder: number, - orders: PortfolioOrderItem[], - i: number, - unitPriceAtStartDate: Big - ) { - 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; - } - } - - @LogPerformance - private handleLogging( - symbol: string, - orders: PortfolioOrderItem[], - indexOfStartOrder: number, - unitPriceAtEndDate: Big, - totalInvestment: WithCurrencyEffect, - totalGrossPerformance: WithCurrencyEffect, - grossPerformancePercentage: WithCurrencyEffect, - feesPerUnit: WithCurrencyEffect, - totalNetPerformance: WithCurrencyEffect, - netPerformancePercentage: WithCurrencyEffect - ) { - if (PortfolioCalculator.ENABLE_LOGGING) { - console.log( - ` - ${symbol} - Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed( - 2 - )} -> ${unitPriceAtEndDate.toFixed(2)} - Total investment: ${totalInvestment.Value.toFixed(2)} - Total investment with currency effect: ${totalInvestment.WithCurrencyEffect.toFixed( - 2 - )} - Gross performance: ${totalGrossPerformance.Value.toFixed( - 2 - )} / ${grossPerformancePercentage.Value.mul(100).toFixed(2)}% - Gross performance with currency effect: ${totalGrossPerformance.WithCurrencyEffect.toFixed( - 2 - )} / ${grossPerformancePercentage.WithCurrencyEffect.mul(100).toFixed( - 2 - )}% - Fees per unit: ${feesPerUnit.Value.toFixed(2)} - Fees per unit with currency effect: ${feesPerUnit.WithCurrencyEffect.toFixed( - 2 - )} - Net performance: ${totalNetPerformance.Value.toFixed( - 2 - )} / ${netPerformancePercentage.Value.mul(100).toFixed(2)} - Net performance with currency effect: ${totalNetPerformance.WithCurrencyEffect.toFixed( - 2 - )} / ${netPerformancePercentage.WithCurrencyEffect.mul(100).toFixed(2)}%` - ); - } - } - - @LogPerformance - private sortOrdersByTime(orders: PortfolioOrderItem[]) { - return sortBy(orders, (order) => { - let sortIndex = new Date(order.date); - - if (order.itemType === 'start') { - sortIndex = addMilliseconds(sortIndex, -1); - } - - if (order.itemType === 'end') { - sortIndex = addMilliseconds(sortIndex, 1); - } - - return sortIndex.getTime(); - }); - } - - @LogPerformance - private handleChartMode( - isChartMode: boolean, - orders: PortfolioOrderItem[], - day: Date, - end: Date, - symbol: string, - marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, - lastUnitPrice: Big, - step: number - ) { - if (isChartMode) { - const datesWithOrders = {}; - - for (const order of orders) { - datesWithOrders[order.date] = true; - } - - while (isBefore(day, end)) { - this.handleDay( - datesWithOrders, - day, - orders, - symbol, - marketSymbolMap, - lastUnitPrice - ); - - lastUnitPrice = last(orders).unitPrice; - - day = addDays(day, step); - } - } - return { day, lastUnitPrice }; - } - - @LogPerformance - private handleDay( - datesWithOrders: {}, - day: Date, - orders: PortfolioOrderItem[], - symbol: string, - marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, - lastUnitPrice: Big - ) { - const hasDate = datesWithOrders[format(day, DATE_FORMAT)]; - - if (!hasDate) { - orders.push({ - symbol, - currency: null, - date: format(day, DATE_FORMAT), - dataSource: null, - fee: new Big(0), - name: '', - quantity: new Big(0), - type: TypeOfOrder.BUY, - unitPrice: - marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ?? lastUnitPrice - }); - } else { - let orderIndex = orders.findIndex( - (o) => o.date === format(day, DATE_FORMAT) && o.type === 'STAKE' - ); - if (orderIndex >= 0) { - let order = orders[orderIndex]; - orders.splice(orderIndex, 1); - orders.push({ - ...order, - unitPrice: - marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ?? lastUnitPrice - }); - } - } - } - - @LogPerformance - private addSyntheticStartAndEndOrders( - orders: PortfolioOrderItem[], - symbol: string, - start: Date, - unitPriceAtStartDate: Big, - end: Date, - unitPriceAtEndDate: Big - ) { - orders.push({ - symbol, - currency: null, - date: format(start, DATE_FORMAT), - dataSource: null, - fee: new Big(0), - itemType: 'start', - name: '', - quantity: new Big(0), - type: TypeOfOrder.BUY, - unitPrice: unitPriceAtStartDate - }); - - orders.push({ - symbol, - currency: null, - date: format(end, DATE_FORMAT), - dataSource: null, - fee: new Big(0), - itemType: 'end', - name: '', - quantity: new Big(0), - type: TypeOfOrder.BUY, - unitPrice: unitPriceAtEndDate - }); - } -} diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 8b90dcd4f..95b94c78e 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -1,17 +1,20 @@ import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor'; import { AccessService } from '@ghostfolio/api/app/access/access.service'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { hasNotDefinedValuesInObject, nullifyValuesInObject } from '@ghostfolio/api/helper/object.helper'; +import { getInterval } from '@ghostfolio/api/helper/portfolio.helper'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { DEFAULT_CURRENCY, HEADER_KEY_IMPERSONATION @@ -19,6 +22,7 @@ import { import { PortfolioDetails, PortfolioDividends, + PortfolioHoldingsResponse, PortfolioInvestments, PortfolioPerformanceResponse, PortfolioPublicDetails, @@ -44,11 +48,11 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import Big from 'big.js'; +import { AssetClass, AssetSubClass } from '@prisma/client'; +import { Big } from 'big.js'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; -import { PortfolioPositions } from './interfaces/portfolio-positions.interface'; import { PortfolioService } from './portfolio.service'; @Controller('portfolio') @@ -58,6 +62,8 @@ export class PortfolioController { private readonly apiService: ApiService, private readonly configurationService: ConfigurationService, private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly impersonationService: ImpersonationService, + private readonly orderService: OrderService, private readonly portfolioService: PortfolioService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService @@ -73,8 +79,11 @@ export class PortfolioController { @Query('assetClasses') filterByAssetClasses?: string, @Query('range') dateRange: DateRange = 'max', @Query('tags') filterByTags?: string, - @Query('isAllocation') isAllocation: boolean = false + @Query('isAllocation') isAllocation: boolean = false, + @Query('withMarkets') withMarketsParam = 'false' ): Promise { + const withMarkets = withMarketsParam === 'true'; + let hasDetails = true; let hasError = false; const hasReadRestrictedAccessPermission = @@ -93,22 +102,15 @@ export class PortfolioController { filterByTags }); - const { - accounts, - filteredValueInBaseCurrency, - filteredValueInPercentage, - hasErrors, - holdings, - platforms, - summary, - totalValueInBaseCurrency - } = await this.portfolioService.getDetails({ - dateRange, - filters, - impersonationId, - userId: this.request.user.id, - isAllocation - }); + const { accounts, hasErrors, holdings, platforms, summary } = + await this.portfolioService.getDetails({ + dateRange, + filters, + impersonationId, + withMarkets, + userId: this.request.user.id, + withSummary: true + }); if (hasErrors || hasNotDefinedValuesInObject(holdings)) { hasError = true; @@ -120,40 +122,27 @@ export class PortfolioController { hasReadRestrictedAccessPermission || this.userService.isRestrictedView(this.request.user) ) { - let investmentTuple: [number, number] = [0, 0]; - for (let holding of Object.entries(holdings)) { - var portfolioPosition = holding[1]; - investmentTuple[0] += portfolioPosition.investment; - investmentTuple[1] += this.exchangeRateDataService.toCurrency( - portfolioPosition.quantity * portfolioPosition.marketPrice, - portfolioPosition.currency, - this.request.user.Settings.settings.baseCurrency - ); - } - const totalInvestment = investmentTuple[0]; - - const totalValue = investmentTuple[1]; - - if (hasDetails === false) { - portfolioSummary = nullifyValuesInObject(summary, [ - 'cash', - 'committedFunds', - 'currentGrossPerformance', - 'currentNetPerformance', - 'currentValue', - 'dividend', - 'emergencyFund', - 'excludedAccountsAndActivities', - 'fees', - 'items', - 'liabilities', - 'netWorth', - 'totalBuy', - 'totalSell' - ]); - } - - for (const [symbol, portfolioPosition] of Object.entries(holdings)) { + const totalInvestment = Object.values(holdings) + .map(({ investment }) => { + return investment; + }) + .reduce((a, b) => a + b, 0); + + const totalValue = Object.values(holdings) + .filter(({ assetClass, assetSubClass }) => { + return ( + assetClass !== AssetClass.LIQUIDITY && + assetSubClass !== AssetSubClass.CASH + ); + }) + .map(({ valueInBaseCurrency }) => { + return valueInBaseCurrency; + }) + .reduce((a, b) => { + return a + b; + }, 0); + + for (const [, portfolioPosition] of Object.entries(holdings)) { portfolioPosition.investment = portfolioPosition.investment / totalInvestment; portfolioPosition.valueInPercentage = @@ -195,23 +184,25 @@ export class PortfolioController { portfolioSummary = nullifyValuesInObject(summary, [ 'cash', 'committedFunds', - 'currentGrossPerformance', - 'currentGrossPerformanceWithCurrencyEffect', - 'currentNetPerformance', - 'currentNetPerformanceWithCurrencyEffect', - 'currentValue', - 'dividend', + 'currentNetWorth', + 'currentValueInBaseCurrency', + 'dividendInBaseCurrency', 'emergencyFund', 'excludedAccountsAndActivities', 'fees', + 'filteredValueInBaseCurrency', 'fireWealth', + 'grossPerformance', + 'grossPerformanceWithCurrencyEffect', 'interest', 'items', 'liabilities', - 'netWorth', + 'netPerformance', + 'netPerformanceWithCurrencyEffect', 'totalBuy', 'totalInvestment', - 'totalSell' + 'totalSell', + 'totalValueInBaseCurrency' ]); } @@ -219,11 +210,11 @@ export class PortfolioController { holdings[symbol] = { ...portfolioPosition, assetClass: - hasDetails || portfolioPosition.assetClass === 'CASH' + hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY ? portfolioPosition.assetClass : undefined, assetSubClass: - hasDetails || portfolioPosition.assetSubClass === 'CASH' + hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH ? portfolioPosition.assetSubClass : undefined, countries: hasDetails ? portfolioPosition.countries : [], @@ -238,12 +229,9 @@ export class PortfolioController { return { accounts, - filteredValueInBaseCurrency, - filteredValueInPercentage, hasError, holdings, platforms, - totalValueInBaseCurrency, summary: portfolioSummary }; } @@ -270,11 +258,24 @@ export class PortfolioController { filterByTags }); - let dividends = await this.portfolioService.getDividends({ - dateRange, + const impersonationUserId = + await this.impersonationService.validateImpersonationId(impersonationId); + const userCurrency = this.request.user.Settings.settings.baseCurrency; + + const { endDate, startDate } = getInterval(dateRange); + + const { activities } = await this.orderService.getOrders({ + endDate, filters, - groupBy, - impersonationId + startDate, + userCurrency, + userId: impersonationUserId || this.request.user.id, + types: ['DIVIDEND'] + }); + + let dividends = await this.portfolioService.getDividends({ + activities, + groupBy }); if ( @@ -304,6 +305,37 @@ export class PortfolioController { return { dividends }; } + @Get('holdings') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(RedactValuesInResponseInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getHoldings( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('holdingType') filterByHoldingType?: string, + @Query('query') filterBySearchQuery?: string, + @Query('range') dateRange: DateRange = 'max', + @Query('tags') filterByTags?: string + ): Promise { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByHoldingType, + filterBySearchQuery, + filterByTags + }); + + const { holdings } = await this.portfolioService.getDetails({ + dateRange, + filters, + impersonationId, + userId: this.request.user.id + }); + + return { holdings: Object.values(holdings) }; + } + @Get('investments') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getInvestments( @@ -403,7 +435,6 @@ export class PortfolioController { filters, impersonationId, withExcludedAccounts, - withItems, userId: this.request.user.id, calculateTimeWeightedPerformance }); @@ -417,6 +448,7 @@ export class PortfolioController { ({ date, netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, netWorth, totalInvestment, value @@ -424,6 +456,7 @@ export class PortfolioController { return { date, netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, netWorthInPercentage: performanceInformation.performance.currentNetWorth === 0 ? 0 @@ -437,10 +470,14 @@ export class PortfolioController { .div(performanceInformation.performance.totalInvestment) .toNumber(), valueInPercentage: - performanceInformation.performance.currentValue === 0 + performanceInformation.performance.currentValueInBaseCurrency === + 0 ? 0 : new Big(value) - .div(performanceInformation.performance.currentValue) + .div( + performanceInformation.performance + .currentValueInBaseCurrency + ) .toNumber() }; } @@ -449,12 +486,12 @@ export class PortfolioController { performanceInformation.performance = nullifyValuesInObject( performanceInformation.performance, [ - 'currentGrossPerformance', - 'currentGrossPerformanceWithCurrencyEffect', - 'currentNetPerformance', - 'currentNetPerformanceWithCurrencyEffect', 'currentNetWorth', - 'currentValue', + 'currentValueInBaseCurrency', + 'grossPerformance', + 'grossPerformanceWithCurrencyEffect', + 'netPerformance', + 'netPerformanceWithCurrencyEffect', 'totalInvestment' ] ); @@ -471,39 +508,13 @@ export class PortfolioController { ); performanceInformation.performance = nullifyValuesInObject( performanceInformation.performance, - ['currentNetPerformance', 'currentNetPerformancePercent'] + ['netPerformance'] ); } return performanceInformation; } - @Get('positions') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - @UseInterceptors(RedactValuesInResponseInterceptor) - @UseInterceptors(TransformDataSourceInResponseInterceptor) - public async getPositions( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, - @Query('accounts') filterByAccounts?: string, - @Query('assetClasses') filterByAssetClasses?: string, - @Query('query') filterBySearchQuery?: string, - @Query('range') dateRange: DateRange = 'max', - @Query('tags') filterByTags?: string - ): Promise { - const filters = this.apiService.buildFiltersFromQueryParams({ - filterByAccounts, - filterByAssetClasses, - filterBySearchQuery, - filterByTags - }); - - return this.portfolioService.getPositions({ - dateRange, - filters, - impersonationId - }); - } - @Get('public/:accessId') @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getPublic( @@ -527,10 +538,10 @@ export class PortfolioController { } const { holdings } = await this.portfolioService.getDetails({ - dateRange: 'max', filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }], impersonationId: access.userId, - userId: user.id + userId: user.id, + withMarkets: true }); const portfolioPublicDetails: PortfolioPublicDetails = { diff --git a/apps/api/src/app/portfolio/portfolio.module.ts b/apps/api/src/app/portfolio/portfolio.module.ts index 4b5034979..5659f2a7e 100644 --- a/apps/api/src/app/portfolio/portfolio.module.ts +++ b/apps/api/src/app/portfolio/portfolio.module.ts @@ -2,6 +2,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; @@ -15,6 +16,7 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/sym import { Module } from '@nestjs/common'; +import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; import { CurrentRateService } from './current-rate.service'; import { PortfolioController } from './portfolio.controller'; import { PortfolioService } from './portfolio.service'; @@ -34,6 +36,7 @@ import { RulesService } from './rules.service'; MarketDataModule, OrderModule, PrismaModule, + RedisCacheModule, SymbolProfileModule, UserModule ], @@ -41,6 +44,7 @@ import { RulesService } from './rules.service'; AccountBalanceService, AccountService, CurrentRateService, + PortfolioCalculatorFactory, PortfolioService, RulesService ] diff --git a/apps/api/src/app/portfolio/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts similarity index 53% rename from apps/api/src/app/portfolio/portfolio-calculator.spec.ts rename to apps/api/src/app/portfolio/portfolio.service.spec.ts index a59b877ab..92970f547 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -1,18 +1,19 @@ -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { Big } from 'big.js'; -import Big from 'big.js'; +import { PortfolioService } from './portfolio.service'; -import { CurrentRateService } from './current-rate.service'; -import { PortfolioCalculator } from './portfolio-calculator'; +describe('PortfolioService', () => { + let portfolioService: PortfolioService; -describe('PortfolioCalculator', () => { - let currentRateService: CurrentRateService; - let exchangeRateDataService: ExchangeRateDataService; - - beforeEach(() => { - currentRateService = new CurrentRateService(null, null, null, null); - - exchangeRateDataService = new ExchangeRateDataService( + beforeAll(async () => { + portfolioService = new PortfolioService( + null, + null, + null, + null, + null, + null, + null, null, null, null, @@ -21,28 +22,21 @@ describe('PortfolioCalculator', () => { }); describe('annualized performance percentage', () => { - const portfolioCalculator = new PortfolioCalculator({ - currentRateService, - exchangeRateDataService, - currency: 'USD', - orders: [] - }); - it('Get annualized performance', async () => { expect( - portfolioCalculator + portfolioService .getAnnualizedPerformancePercent({ daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day - netPerformancePercent: new Big(0) + netPerformancePercentage: new Big(0) }) .toNumber() ).toEqual(0); expect( - portfolioCalculator + portfolioService .getAnnualizedPerformancePercent({ daysInMarket: 0, - netPerformancePercent: new Big(0) + netPerformancePercentage: new Big(0) }) .toNumber() ).toEqual(0); @@ -51,19 +45,19 @@ describe('PortfolioCalculator', () => { * Source: https://www.readyratios.com/reference/analysis/annualized_rate.html */ expect( - portfolioCalculator + portfolioService .getAnnualizedPerformancePercent({ daysInMarket: 65, // < 1 year - netPerformancePercent: new Big(0.1025) + netPerformancePercentage: new Big(0.1025) }) .toNumber() ).toBeCloseTo(0.729705); expect( - portfolioCalculator + portfolioService .getAnnualizedPerformancePercent({ daysInMarket: 365, // 1 year - netPerformancePercent: new Big(0.05) + netPerformancePercentage: new Big(0.05) }) .toNumber() ).toBeCloseTo(0.05); @@ -72,10 +66,10 @@ describe('PortfolioCalculator', () => { * Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation */ expect( - portfolioCalculator + portfolioService .getAnnualizedPerformancePercent({ daysInMarket: 575, // > 1 year - netPerformancePercent: new Big(0.2374) + netPerformancePercentage: new Big(0.2374) }) .toNumber() ).toBeCloseTo(0.145); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 082721701..81601dbb3 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -4,10 +4,11 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { OrderService } from '@ghostfolio/api/app/order/order.service'; -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; -import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { + getFactor, + getInterval +} from '@ghostfolio/api/helper/portfolio.helper'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; @@ -22,7 +23,6 @@ import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/sy import { DEFAULT_CURRENCY, EMERGENCY_FUND_TAG_ID, - MAX_CHART_ITEMS, UNKNOWN_KEY } from '@ghostfolio/common/config'; import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper'; @@ -31,6 +31,7 @@ import { EnhancedSymbolProfile, Filter, HistoricalDataItem, + InvestmentItem, PortfolioDetails, PortfolioInvestments, PortfolioPerformanceResponse, @@ -38,15 +39,13 @@ import { PortfolioReport, PortfolioSummary, Position, - TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; -import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; +import { TimelinePosition } from '@ghostfolio/common/models'; import type { AccountWithValue, DateRange, GroupBy, - OrderWithAccount, RequestWithUser, UserWithSettings } from '@ghostfolio/common/types'; @@ -57,41 +56,30 @@ import { Account, Type as ActivityType, AssetClass, + AssetSubClass, DataSource, Order, Platform, - Prisma, - Tag + Prisma } from '@prisma/client'; -import Big from 'big.js'; -import { isUUID } from 'class-validator'; +import { Big } from 'big.js'; import { differenceInDays, format, isAfter, - isBefore, isSameMonth, isSameYear, - isValid, - max, - min, parseISO, - set, - startOfWeek, - startOfMonth, - startOfYear, - subDays, - subMonths, - subYears + set } from 'date-fns'; -import { isEmpty, last, uniq, uniqBy } from 'lodash'; +import { isEmpty, isNumber, last, uniq, uniqBy } from 'lodash'; -import { CurrentPositions } from './interfaces/current-positions.interface'; +import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { - HistoricalDataContainer, - PortfolioPositionDetail -} from './interfaces/portfolio-position-detail.interface'; -import { PortfolioCalculator } from './portfolio-calculator'; + PerformanceCalculationType, + PortfolioCalculatorFactory +} from './calculator/portfolio-calculator.factory'; +import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; import { RulesService } from './rules.service'; const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json'); @@ -104,7 +92,7 @@ export class PortfolioService { public constructor( private readonly accountBalanceService: AccountBalanceService, private readonly accountService: AccountService, - private readonly currentRateService: CurrentRateService, + private readonly calculatorFactory: PortfolioCalculatorFactory, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly impersonationService: ImpersonationService, @@ -222,29 +210,31 @@ export class PortfolioService { }; } - @LogPerformance + public getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage + }: { + daysInMarket: number; + netPerformancePercentage: Big; + }): Big { + if (isNumber(daysInMarket) && daysInMarket > 0) { + const exponent = new Big(365).div(daysInMarket).toNumber(); + + return new Big( + Math.pow(netPerformancePercentage.plus(1).toNumber(), exponent) + ).minus(1); + } + + return new Big(0); + } + public async getDividends({ - dateRange, - filters, - groupBy, - impersonationId + activities, + groupBy }: { - dateRange: DateRange; - filters?: Filter[]; + activities: Activity[]; groupBy?: GroupBy; - impersonationId: string; }): Promise { - const userId = await this.getUserId(impersonationId, this.request.user.id); - const user = await this.userService.user({ id: userId }); - const userCurrency = this.getUserCurrency(user); - - const { activities } = await this.orderService.getOrders({ - filters, - userCurrency, - userId, - types: ['DIVIDEND'] - }); - let dividends = activities.map(({ date, valueInBaseCurrency }) => { return { date: format(date, DATE_FORMAT), @@ -256,14 +246,7 @@ export class PortfolioService { dividends = this.getDividendsByGroup({ dividends, groupBy }); } - const startDate = this.getStartDate( - dateRange, - parseDate(dividends[0]?.date) - ); - - return dividends.filter(({ date }) => { - return !isBefore(parseDate(date), startDate); - }); + return dividends; } @LogPerformance @@ -282,38 +265,34 @@ export class PortfolioService { }): Promise { const userId = await this.getUserId(impersonationId, this.request.user.id); - const { portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ - filters, - userId, - includeDrafts: true, - types: ['BUY', 'SELL'] - }); + const { activities } = await this.orderService.getOrders({ + filters, + userId, + includeDrafts: true, + types: ['BUY', 'SELL'], + userCurrency: this.getUserCurrency() + }); - if (transactionPoints.length === 0) { + if (activities.length === 0) { return { investments: [], streaks: { currentStreak: 0, longestStreak: 0 } }; } - const portfolioCalculator = new PortfolioCalculator({ + const portfolioCalculator = this.calculatorFactory.createCalculator({ + activities, + dateRange, + userId, + calculationType: PerformanceCalculationType.TWR, currency: this.request.user.Settings.settings.baseCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + hasFilters: filters?.length > 0, + isExperimentalFeatures: + this.request.user.Settings.settings.isExperimentalFeatures }); - portfolioCalculator.setTransactionPoints(transactionPoints); - - const { items } = await this.getChart({ + const items = await portfolioCalculator.getChart({ dateRange, - impersonationId, - portfolioOrders, - transactionPoints, - userCurrency: this.request.user.Settings.settings.baseCurrency, - userId, - calculateTimeWeightedPerformance: false, withDataDecimation: false }); @@ -355,14 +334,16 @@ export class PortfolioService { impersonationId, userId, withExcludedAccounts = false, - isAllocation = false + withMarkets = false, + withSummary = false }: { dateRange?: DateRange; filters?: Filter[]; impersonationId: string; userId: string; withExcludedAccounts?: boolean; - isAllocation?: boolean; + withMarkets?: boolean; + withSummary?: boolean; }): Promise { userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); @@ -372,31 +353,26 @@ export class PortfolioService { (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 ); - const { orders, portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ - filters, - userId, - withExcludedAccounts - }); + const { activities } = await this.orderService.getOrders({ + filters, + userCurrency, + userId, + withExcludedAccounts + }); - const portfolioCalculator = new PortfolioCalculator({ + const portfolioCalculator = this.calculatorFactory.createCalculator({ + activities, + dateRange, + userId, + calculationType: PerformanceCalculationType.TWR, currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + hasFilters: true, // disable cache + isExperimentalFeatures: + this.request.user?.Settings.settings.isExperimentalFeatures }); - portfolioCalculator.setTransactionPoints(transactionPoints); - - const portfolioStart = parseDate( - transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) - ); - const startDate = this.getStartDate(dateRange, portfolioStart); - const currentPositions = await portfolioCalculator.getCurrentPositions( - startDate, - new Date(Date.now()), - !isAllocation - ); + const { currentValueInBaseCurrency, hasErrors, positions } = + await portfolioCalculator.getSnapshot(); const cashDetails = await this.accountService.getCashDetails({ filters, @@ -405,38 +381,46 @@ export class PortfolioService { }); const holdings: PortfolioDetails['holdings'] = {}; - const totalValueInBaseCurrency = currentPositions.currentValue.plus( + + const totalValueInBaseCurrency = currentValueInBaseCurrency.plus( cashDetails.balanceInBaseCurrency ); const isFilteredByAccount = - filters?.some((filter) => { - return filter.type === 'ACCOUNT'; + filters?.some(({ type }) => { + return type === 'ACCOUNT'; + }) ?? false; + + const isFilteredByCash = filters?.some(({ id, type }) => { + return id === AssetClass.LIQUIDITY && type === 'ASSET_CLASS'; + }); + + const isFilteredByClosedHoldings = + filters?.some(({ id, type }) => { + return id === 'CLOSED' && type === 'HOLDING_TYPE'; }) ?? false; let filteredValueInBaseCurrency = isFilteredByAccount ? totalValueInBaseCurrency - : currentPositions.currentValue; + : currentValueInBaseCurrency; if ( filters?.length === 0 || (filters?.length === 1 && - filters[0].type === 'ASSET_CLASS' && - filters[0].id === 'CASH') + filters[0].id === AssetClass.LIQUIDITY && + filters[0].type === 'ASSET_CLASS') ) { filteredValueInBaseCurrency = filteredValueInBaseCurrency.plus( cashDetails.balanceInBaseCurrency ); } - const dataGatheringItems = currentPositions.positions.map( - ({ dataSource, symbol }) => { - return { - dataSource, - symbol - }; - } - ); + const dataGatheringItems = positions.map(({ dataSource, symbol }) => { + return { + dataSource, + symbol + }; + }); const [dataProviderResponses, symbolProfiles] = await Promise.all([ this.dataProviderService.getQuotes({ user, items: dataGatheringItems }), @@ -449,174 +433,93 @@ export class PortfolioService { } const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; - - this.handlePositions( - currentPositions, - portfolioItemsNow, - symbolProfileMap, - dataProviderResponses, - holdings, - filteredValueInBaseCurrency - ); - - await this.handleCashPosition( - filters, - isFilteredByAccount, - cashDetails, - userCurrency, - filteredValueInBaseCurrency, - holdings - ); - - const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({ - filters, - orders, - portfolioItemsNow, - userCurrency, - userId, - withExcludedAccounts - }); - - filteredValueInBaseCurrency = await this.handleEmergencyFunds( - filters, - cashDetails, - userCurrency, - filteredValueInBaseCurrency, - emergencyFund, - orders, - accounts, - holdings - ); - let summary; - if (!isAllocation) { - summary = await this.getSummary({ - impersonationId, - userCurrency, - userId, - holdings, - balanceInBaseCurrency: cashDetails.balanceInBaseCurrency, - emergencyFundPositionsValueInBaseCurrency: - this.getEmergencyFundPositionsValueInBaseCurrency({ - holdings - }) - }); + for (const position of positions) { + portfolioItemsNow[position.symbol] = position; } - var netWorth = - summary?.netWorth ?? - (await this.getNetWorth(impersonationId, userId, userCurrency)); - - return { - accounts, - holdings, - platforms, - summary, - filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(), - filteredValueInPercentage: netWorth - ? filteredValueInBaseCurrency.div(netWorth).toNumber() - : 0, - hasErrors: currentPositions.hasErrors, - totalValueInBaseCurrency: netWorth - }; - } - - @LogPerformance - private handlePositions( - currentPositions: CurrentPositions, - portfolioItemsNow: { [symbol: string]: TimelinePosition }, - symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile }, - dataProviderResponses: { - [symbol: string]: IDataProviderResponse; - }, - holdings: { [symbol: string]: PortfolioPosition }, - filteredValueInBaseCurrency: Big - ) { - for (const item of currentPositions.positions) { - portfolioItemsNow[item.symbol] = item; - if (item.quantity.lte(0)) { - // Ignore positions without any quantity - continue; + for (const { + currency, + dividend, + firstBuyDate, + grossPerformance, + grossPerformanceWithCurrencyEffect, + grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, + investment, + marketPrice, + netPerformance, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffect, + netPerformanceWithCurrencyEffect, + quantity, + symbol, + tags, + transactionCount, + valueInBaseCurrency + } of positions) { + if (isFilteredByClosedHoldings === true) { + if (!quantity.eq(0)) { + // Ignore positions with a quantity + continue; + } + } else { + if (quantity.eq(0)) { + // Ignore positions without any quantity + continue; + } } - const value = item.quantity.mul(item.marketPriceInBaseCurrency ?? 0); - const symbolProfile = symbolProfileMap[item.symbol]; - const dataProviderResponse = dataProviderResponses[item.symbol]; + const assetProfile = symbolProfileMap[symbol]; + const dataProviderResponse = dataProviderResponses[symbol]; - const markets: PortfolioPosition['markets'] = { - [UNKNOWN_KEY]: 0, - developedMarkets: 0, - emergingMarkets: 0, - otherMarkets: 0 - }; - const marketsAdvanced: PortfolioPosition['marketsAdvanced'] = { - [UNKNOWN_KEY]: 0, - asiaPacific: 0, - emergingMarkets: 0, - europe: 0, - japan: 0, - northAmerica: 0, - otherMarkets: 0 - }; + let markets: PortfolioPosition['markets']; + let marketsAdvanced: PortfolioPosition['marketsAdvanced']; - this.calculateMarketsAllocation( - symbolProfile, - markets, - marketsAdvanced, - value - ); + if (withMarkets) { + ({ markets, marketsAdvanced } = this.getMarkets({ + assetProfile, + valueInBaseCurrency + })); + } - holdings[item.symbol] = { + holdings[symbol] = { + currency, markets, marketsAdvanced, + marketPrice, + symbol, + tags, + transactionCount, allocationInPercentage: filteredValueInBaseCurrency.eq(0) ? 0 - : value.div(filteredValueInBaseCurrency).toNumber(), - assetClass: symbolProfile.assetClass, - assetSubClass: symbolProfile.assetSubClass, - countries: symbolProfile.countries, - currency: item.currency, - dataSource: symbolProfile.dataSource, - dateOfFirstActivity: parseDate(item.firstBuyDate), - grossPerformance: item.grossPerformance?.toNumber() ?? 0, - grossPerformancePercent: - item.grossPerformancePercentage?.toNumber() ?? 0, + : valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(), + assetClass: assetProfile.assetClass, + assetSubClass: assetProfile.assetSubClass, + countries: assetProfile.countries, + dataSource: assetProfile.dataSource, + dateOfFirstActivity: parseDate(firstBuyDate), + dividend: dividend?.toNumber() ?? 0, + grossPerformance: grossPerformance?.toNumber() ?? 0, + grossPerformancePercent: grossPerformancePercentage?.toNumber() ?? 0, grossPerformancePercentWithCurrencyEffect: - item.grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0, + grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0, grossPerformanceWithCurrencyEffect: - item.grossPerformanceWithCurrencyEffect?.toNumber() ?? 0, - investment: item.investment.toNumber(), - marketPrice: item.marketPrice, + grossPerformanceWithCurrencyEffect?.toNumber() ?? 0, + investment: investment.toNumber(), marketState: dataProviderResponse?.marketState ?? 'delayed', - name: symbolProfile.name, - netPerformance: item.netPerformance?.toNumber() ?? 0, - netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0, + name: assetProfile.name, + netPerformance: netPerformance?.toNumber() ?? 0, + netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0, netPerformancePercentWithCurrencyEffect: - item.netPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0, + netPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0, netPerformanceWithCurrencyEffect: - item.netPerformanceWithCurrencyEffect?.toNumber() ?? 0, - quantity: item.quantity.toNumber(), - sectors: symbolProfile.sectors, - symbol: item.symbol, - tags: item.tags, - transactionCount: item.transactionCount, - url: symbolProfile.url, - valueInBaseCurrency: value.toNumber() + netPerformanceWithCurrencyEffect?.toNumber() ?? 0, + quantity: quantity.toNumber(), + sectors: assetProfile.sectors, + url: assetProfile.url, + valueInBaseCurrency: valueInBaseCurrency.toNumber() }; } - } - - @LogPerformance - private async handleCashPosition( - filters: Filter[], - isFilteredByAccount: boolean, - cashDetails: CashDetails, - userCurrency: string, - filteredValueInBaseCurrency: Big, - holdings: { [symbol: string]: PortfolioPosition } - ) { - const isFilteredByCash = filters?.some((filter) => { - return filter.type === 'ASSET_CLASS' && filter.id === 'CASH'; - }); if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) { const cashPositions = await this.getCashPositions({ @@ -629,27 +532,16 @@ export class PortfolioService { holdings[symbol] = cashPositions[symbol]; } } - } - @LogPerformance - private async handleEmergencyFunds( - filters: Filter[], - cashDetails: CashDetails, - userCurrency: string, - filteredValueInBaseCurrency: Big, - emergencyFund: Big, - orders: Activity[], - accounts: { - [id: string]: { - balance: number; - currency: string; - name: string; - valueInBaseCurrency: number; - valueInPercentage?: number; - }; - }, - holdings: { [symbol: string]: PortfolioPosition } - ) { + const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({ + activities, + filters, + portfolioItemsNow, + userCurrency, + userId, + withExcludedAccounts + }); + if ( filters?.length === 1 && filters[0].id === EMERGENCY_FUND_TAG_ID && @@ -684,7 +576,32 @@ export class PortfolioService { valueInBaseCurrency: emergencyFundInCash }; } - return filteredValueInBaseCurrency; + + let summary: PortfolioSummary; + + if (withSummary) { + summary = await this.getSummary({ + filteredValueInBaseCurrency, + holdings, + impersonationId, + portfolioCalculator, + userCurrency, + userId, + balanceInBaseCurrency: cashDetails.balanceInBaseCurrency, + emergencyFundPositionsValueInBaseCurrency: + this.getEmergencyFundPositionsValueInBaseCurrency({ + holdings + }) + }); + } + + return { + accounts, + hasErrors, + holdings, + platforms, + summary + }; } @LogPerformance @@ -783,16 +700,15 @@ export class PortfolioService { ); }); - let tags: Tag[] = []; - if (orders.length <= 0) { return { - tags, accounts: [], averagePrice: undefined, dataProviderInfo: undefined, - dividendInBaseCurrency: undefined, stakeRewards: undefined, + dividendInBaseCurrency: undefined, + dividendYieldPercent: undefined, + dividendYieldPercentWithCurrencyEffect: undefined, feeInBaseCurrency: undefined, firstBuyDate: undefined, grossPerformance: undefined, @@ -811,6 +727,7 @@ export class PortfolioService { orders: [], quantity: undefined, SymbolProfile: undefined, + tags: [], transactionCount: undefined, value: undefined }; @@ -820,48 +737,26 @@ export class PortfolioService { { dataSource: aDataSource, symbol: aSymbol } ]); - const portfolioOrders: PortfolioOrder[] = orders - .filter((order) => { - tags = tags.concat(order.tags); - - return ( - order.type === 'BUY' || - order.type === 'SELL' || - order.type === 'STAKE' || - order.type === 'ITEM' + const portfolioCalculator = this.calculatorFactory.createCalculator({ + userId, + activities: orders.filter((order) => { + return ['BUY', 'DIVIDEND', 'ITEM', 'SELL', 'STAKE'].includes( + order.type ); - }) - .map((order) => ({ - currency: order.SymbolProfile.currency, - dataSource: order.SymbolProfile.dataSource, - date: format(order.date, DATE_FORMAT), - fee: new Big(order.fee), - name: order.SymbolProfile?.name, - quantity: new Big(order.quantity), - symbol: order.SymbolProfile.symbol, - tags: order.tags, - type: order.type, - unitPrice: new Big(order.unitPrice) - })); - - tags = uniqBy(tags, 'id'); - - const portfolioCalculator = new PortfolioCalculator({ + }), + calculationType: PerformanceCalculationType.TWR, currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + hasFilters: true, + isExperimentalFeatures: + this.request.user.Settings.settings.isExperimentalFeatures }); - portfolioCalculator.computeTransactionPoints(); + const portfolioStart = portfolioCalculator.getStartDate(); const transactionPoints = portfolioCalculator.getTransactionPoints(); - const portfolioStart = parseDate(transactionPoints[0].date); - - const currentPositions = - await portfolioCalculator.getCurrentPositions(portfolioStart); + const { positions } = await portfolioCalculator.getSnapshot(); - const position = currentPositions.positions.find(({ symbol }) => { + const position = positions.find(({ symbol }) => { return symbol === aSymbol; }); @@ -870,10 +765,14 @@ export class PortfolioService { averagePrice, currency, dataSource, + dividendInBaseCurrency, fee, firstBuyDate, marketPrice, quantity, + tags, + timeWeightedInvestment, + timeWeightedInvestmentWithCurrencyEffect, transactionCount } = position; @@ -886,15 +785,24 @@ export class PortfolioService { return Account; }); - const dividendInBaseCurrency = getSum( - orders - .filter(({ type }) => { - return type === 'DIVIDEND'; - }) - .map(({ valueInBaseCurrency }) => { - return new Big(valueInBaseCurrency); - }) - ); + const dividendYieldPercent = this.getAnnualizedPerformancePercent({ + daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), + netPerformancePercentage: timeWeightedInvestment.eq(0) + ? new Big(0) + : dividendInBaseCurrency.div(timeWeightedInvestment) + }); + + const dividendYieldPercentWithCurrencyEffect = + this.getAnnualizedPerformancePercent({ + daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), + netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq( + 0 + ) + ? new Big(0) + : dividendInBaseCurrency.div( + timeWeightedInvestmentWithCurrencyEffect + ) + }); const stakeRewards = getSum( orders @@ -956,9 +864,7 @@ export class PortfolioService { ); if (currentSymbol) { - currentAveragePrice = currentSymbol.quantity.eq(0) - ? 0 - : currentSymbol.investment.div(currentSymbol.quantity).toNumber(); + currentAveragePrice = currentSymbol.averagePrice.toNumber(); currentQuantity = currentSymbol.quantity.toNumber(); } @@ -997,8 +903,11 @@ export class PortfolioService { transactionCount, averagePrice: averagePrice.toNumber(), dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], - dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), stakeRewards: stakeRewards.toNumber(), + dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), + dividendYieldPercent: dividendYieldPercent.toNumber(), + dividendYieldPercentWithCurrencyEffect: + dividendYieldPercentWithCurrencyEffect.toNumber(), feeInBaseCurrency: this.exchangeRateDataService.toCurrency( fee.toNumber(), SymbolProfile.currency, @@ -1041,11 +950,19 @@ export class PortfolioService { ); if (isEmpty(historicalData)) { - historicalData = await this.dataProviderService.getHistoricalRaw( - [{ dataSource: DataSource.YAHOO, symbol: aSymbol }], - portfolioStart, - new Date() - ); + try { + historicalData = await this.dataProviderService.getHistoricalRaw({ + dataGatheringItems: [ + { dataSource: DataSource.YAHOO, symbol: aSymbol } + ], + from: portfolioStart, + to: new Date() + }); + } catch { + historicalData = { + [aSymbol]: {} + }; + } } const historicalDataArray: HistoricalDataItem[] = []; @@ -1070,12 +987,13 @@ export class PortfolioService { minPrice, orders, SymbolProfile, - tags, accounts: [], averagePrice: 0, dataProviderInfo: undefined, - dividendInBaseCurrency: 0, stakeRewards: 0, + dividendInBaseCurrency: 0, + dividendYieldPercent: 0, + dividendYieldPercentWithCurrencyEffect: 0, feeInBaseCurrency: 0, firstBuyDate: undefined, grossPerformance: undefined, @@ -1089,6 +1007,7 @@ export class PortfolioService { netPerformancePercentWithCurrencyEffect: undefined, netPerformanceWithCurrencyEffect: undefined, quantity: 0, + tags: [], transactionCount: undefined, value: 0 }; @@ -1111,35 +1030,36 @@ export class PortfolioService { const userId = await this.getUserId(impersonationId, this.request.user.id); const user = await this.userService.user({ id: userId }); - const { portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ - filters, - userId, - types: ['BUY', 'SELL'] - }); + const { endDate } = getInterval(dateRange); + + const { activities } = await this.orderService.getOrders({ + endDate, + filters, + userId, + userCurrency: this.getUserCurrency() + }); - if (transactionPoints?.length <= 0) { + if (activities?.length <= 0) { return { hasErrors: false, positions: [] }; } - const portfolioCalculator = new PortfolioCalculator({ + const portfolioCalculator = this.calculatorFactory.createCalculator({ + activities, + dateRange, + userId, + calculationType: PerformanceCalculationType.TWR, currency: this.request.user.Settings.settings.baseCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + hasFilters: filters?.length > 0, + isExperimentalFeatures: + this.request.user.Settings.settings.isExperimentalFeatures }); - portfolioCalculator.setTransactionPoints(transactionPoints); - - const portfolioStart = parseDate(transactionPoints[0].date); - const startDate = this.getStartDate(dateRange, portfolioStart); - const currentPositions = - await portfolioCalculator.getCurrentPositions(startDate); + let { hasErrors, positions } = await portfolioCalculator.getSnapshot(); - let positions = currentPositions.positions.filter(({ quantity }) => { + positions = positions.filter(({ quantity }) => { return !quantity.eq(0); }); @@ -1178,7 +1098,7 @@ export class PortfolioService { } return { - hasErrors: currentPositions.hasErrors, + hasErrors, positions: positions.map( ({ averagePrice, @@ -1248,8 +1168,7 @@ export class PortfolioService { impersonationId, userId, withExcludedAccounts = false, - calculateTimeWeightedPerformance = false, - withItems = false + calculateTimeWeightedPerformance = false }: { dateRange?: DateRange; filters?: Filter[]; @@ -1257,7 +1176,6 @@ export class PortfolioService { userId: string; withExcludedAccounts?: boolean; calculateTimeWeightedPerformance?: boolean; - withItems?: boolean; }): Promise { userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); @@ -1276,11 +1194,16 @@ export class PortfolioService { ) => { const formattedDate = format(date, DATE_FORMAT); - // Store the item in the map, overwriting if the date already exists - map[formattedDate] = { - date: formattedDate, - value: valueInBaseCurrency - }; + if (map[formattedDate]) { + // If the value exists, add the current value to the existing one + map[formattedDate].value += valueInBaseCurrency; + } else { + // Otherwise, initialize the value for that date + map[formattedDate] = { + date: formattedDate, + value: valueInBaseCurrency + }; + } return map; }, @@ -1288,58 +1211,51 @@ export class PortfolioService { ) ); - const { portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ - filters, - userId, - withExcludedAccounts, - types: withItems - ? ['BUY', 'ITEM', 'STAKE', 'SELL'] - : ['BUY', 'STAKE', 'SELL'] - }); + const { endDate } = getInterval(dateRange); - const portfolioCalculator = new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + const { activities } = await this.orderService.getOrders({ + endDate, + filters, + userCurrency, + userId, + withExcludedAccounts }); - if (accountBalanceItems?.length <= 0 && transactionPoints?.length <= 0) { + if (accountBalanceItems?.length <= 0 && activities?.length <= 0) { return { chart: [], firstOrderDate: undefined, hasErrors: false, performance: { - currentGrossPerformance: 0, - currentGrossPerformancePercent: 0, - currentGrossPerformancePercentWithCurrencyEffect: 0, - currentGrossPerformanceWithCurrencyEffect: 0, - currentNetPerformance: 0, - currentNetPerformancePercent: 0, - currentNetPerformancePercentWithCurrencyEffect: 0, - currentNetPerformanceWithCurrencyEffect: 0, currentNetWorth: 0, - currentValue: 0, + currentValueInBaseCurrency: 0, + grossPerformance: 0, + grossPerformancePercentage: 0, + grossPerformancePercentageWithCurrencyEffect: 0, + grossPerformanceWithCurrencyEffect: 0, + netPerformance: 0, + netPerformancePercentage: 0, + netPerformancePercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, totalInvestment: 0 } }; } - portfolioCalculator.setTransactionPoints(transactionPoints); - - const portfolioStart = min( - [ - parseDate(accountBalanceItems[0]?.date), - parseDate(transactionPoints[0]?.date) - ].filter((date) => { - return isValid(date); - }) - ); + const portfolioCalculator = this.calculatorFactory.createCalculator({ + accountBalanceItems, + activities, + dateRange, + userId, + calculationType: PerformanceCalculationType.TWR, + currency: userCurrency, + hasFilters: filters?.length > 0, + isExperimentalFeatures: + this.request.user.Settings.settings.isExperimentalFeatures + }); - const startDate = this.getStartDate(dateRange, portfolioStart); const { - currentValue, + currentValueInBaseCurrency, errors, grossPerformance, grossPerformancePercentage, @@ -1351,126 +1267,144 @@ export class PortfolioService { netPerformancePercentageWithCurrencyEffect, netPerformanceWithCurrencyEffect, totalInvestment - } = await portfolioCalculator.getCurrentPositions(startDate); + } = await portfolioCalculator.getSnapshot(); let currentNetPerformance = netPerformance; - let currentNetPerformancePercent = netPerformancePercentage; + let currentNetPerformancePercentage = netPerformancePercentage; - let currentNetPerformancePercentWithCurrencyEffect = + let currentNetPerformancePercentageWithCurrencyEffect = netPerformancePercentageWithCurrencyEffect; let currentNetPerformanceWithCurrencyEffect = netPerformanceWithCurrencyEffect; - const { items } = await this.getChart({ - dateRange, - impersonationId, - portfolioOrders, - transactionPoints, - userCurrency, - userId, - calculateTimeWeightedPerformance + let currentNetWorth = 0; + + let items = await portfolioCalculator.getChart({ + dateRange }); - const itemOfToday = items.find(({ date }) => { + items = await this.calculatedTimeWeightedPerformance( + calculateTimeWeightedPerformance, + activities, + dateRange, + userId, + userCurrency, + filters, + items + ); + + const itemOfToday = items.find(({ date }) => { return date === format(new Date(), DATE_FORMAT); }); if (itemOfToday) { currentNetPerformance = new Big(itemOfToday.netPerformance); - currentNetPerformancePercent = new Big( + currentNetPerformancePercentage = new Big( itemOfToday.netPerformanceInPercentage ).div(100); - currentNetPerformancePercentWithCurrencyEffect = new Big( + currentNetPerformancePercentageWithCurrencyEffect = new Big( itemOfToday.netPerformanceInPercentageWithCurrencyEffect ).div(100); currentNetPerformanceWithCurrencyEffect = new Big( itemOfToday.netPerformanceWithCurrencyEffect ); - } - accountBalanceItems = accountBalanceItems.filter(({ date }) => { - return !isBefore(parseDate(date), startDate); - }); - - const accountBalanceItemOfToday = accountBalanceItems.find(({ date }) => { - return date === format(new Date(), DATE_FORMAT); - }); - - if (!accountBalanceItemOfToday) { - accountBalanceItems.push({ - date: format(new Date(), DATE_FORMAT), - value: last(accountBalanceItems)?.value ?? 0 - }); + currentNetWorth = itemOfToday.netWorth; } - const mergedHistoricalDataItems = this.mergeHistoricalDataItems( - accountBalanceItems, - items - ); - - const currentHistoricalDataItem = last(mergedHistoricalDataItems); - const currentNetWorth = currentHistoricalDataItem?.netWorth ?? 0; - return { errors, hasErrors, - chart: mergedHistoricalDataItems, + chart: items, firstOrderDate: parseDate(items[0]?.date), performance: { currentNetWorth, - currentGrossPerformance: grossPerformance.toNumber(), - currentGrossPerformancePercent: grossPerformancePercentage.toNumber(), - currentGrossPerformancePercentWithCurrencyEffect: + currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(), + grossPerformance: grossPerformance.toNumber(), + grossPerformancePercentage: grossPerformancePercentage.toNumber(), + grossPerformancePercentageWithCurrencyEffect: grossPerformancePercentageWithCurrencyEffect.toNumber(), - currentGrossPerformanceWithCurrencyEffect: + grossPerformanceWithCurrencyEffect: grossPerformanceWithCurrencyEffect.toNumber(), - currentNetPerformance: currentNetPerformance.toNumber(), - currentNetPerformancePercent: currentNetPerformancePercent.toNumber(), - currentNetPerformancePercentWithCurrencyEffect: - currentNetPerformancePercentWithCurrencyEffect.toNumber(), - currentNetPerformanceWithCurrencyEffect: + netPerformance: currentNetPerformance.toNumber(), + netPerformancePercentage: currentNetPerformancePercentage.toNumber(), + netPerformancePercentageWithCurrencyEffect: + currentNetPerformancePercentageWithCurrencyEffect.toNumber(), + netPerformanceWithCurrencyEffect: currentNetPerformanceWithCurrencyEffect.toNumber(), - currentValue: currentValue.toNumber(), totalInvestment: totalInvestment.toNumber() } }; } + private async calculatedTimeWeightedPerformance( + calculateTimeWeightedPerformance: boolean, + activities: Activity[], + dateRange: string, + userId: string, + userCurrency: string, + filters: Filter[], + items: HistoricalDataItem[] + ) { + if (calculateTimeWeightedPerformance) { + const portfolioCalculatorCPR = this.calculatorFactory.createCalculator({ + activities, + dateRange, + userId, + calculationType: PerformanceCalculationType.CPR, + currency: userCurrency, + hasFilters: filters?.length > 0, + isExperimentalFeatures: + this.request.user.Settings.settings.isExperimentalFeatures + }); + let timeWeightedInvestmentItems = await portfolioCalculatorCPR.getChart({ + dateRange + }); + + items = items.map((item) => { + let matchingItem = timeWeightedInvestmentItems.find( + (timeWeightedInvestmentItem) => + timeWeightedInvestmentItem.date === item.date + ); + item.timeWeightedPerformance = matchingItem.netPerformanceInPercentage; + item.timeWeightedPerformanceWithCurrencyEffect = + matchingItem.netPerformanceInPercentageWithCurrencyEffect; + return item; + }); + } + return items; + } + @LogPerformance public async getReport(impersonationId: string): Promise { const userId = await this.getUserId(impersonationId, this.request.user.id); const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); - const { orders, portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ - userId, - types: ['BUY', 'SELL'] - }); - - const portfolioCalculator = new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + const { activities } = await this.orderService.getOrders({ + userCurrency, + userId }); - portfolioCalculator.setTransactionPoints(transactionPoints); + const portfolioCalculator = this.calculatorFactory.createCalculator({ + activities, + userId, + calculationType: PerformanceCalculationType.TWR, + currency: this.request.user.Settings.settings.baseCurrency, + hasFilters: false, + isExperimentalFeatures: + this.request.user.Settings.settings.isExperimentalFeatures + }); - const portfolioStart = parseDate( - transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) - ); - const currentPositions = - await portfolioCalculator.getCurrentPositions(portfolioStart); + let { totalFeesWithCurrencyEffect, positions, totalInvestment } = + await portfolioCalculator.getSnapshot(); - const positions = currentPositions.positions.filter( - (item) => !item.quantity.eq(0) - ); + positions = positions.filter((item) => !item.quantity.eq(0)); const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; @@ -1479,7 +1413,7 @@ export class PortfolioService { } const { accounts } = await this.getValueOfAccountsAndPlatforms({ - orders, + activities, portfolioItemsNow, userCurrency, userId @@ -1489,7 +1423,7 @@ export class PortfolioService { return { rules: { - accountClusterRisk: isEmpty(orders) + accountClusterRisk: isEmpty(activities) ? undefined : await this.rulesService.evaluate( [ @@ -1504,7 +1438,7 @@ export class PortfolioService { ], userSettings ), - currencyClusterRisk: isEmpty(orders) + currencyClusterRisk: isEmpty(activities) ? undefined : await this.rulesService.evaluate( [ @@ -1532,8 +1466,8 @@ export class PortfolioService { [ new FeeRatioInitialInvestment( this.exchangeRateDataService, - currentPositions.totalInvestment.toNumber(), - this.getFees({ userCurrency, activities: orders }).toNumber() + totalInvestment.toNumber(), + totalFeesWithCurrencyEffect.toNumber() ) ], userSettings @@ -1593,72 +1527,6 @@ export class PortfolioService { return cashPositions; } - @LogPerformance - private async getChart({ - dateRange = 'max', - impersonationId, - portfolioOrders, - transactionPoints, - userCurrency, - userId, - calculateTimeWeightedPerformance, - withDataDecimation = true - }: { - dateRange?: DateRange; - impersonationId: string; - portfolioOrders: PortfolioOrder[]; - transactionPoints: TransactionPoint[]; - userCurrency: string; - userId: string; - calculateTimeWeightedPerformance: boolean; - withDataDecimation?: boolean; - }): Promise { - if (transactionPoints.length === 0) { - return { - isAllTimeHigh: false, - isAllTimeLow: false, - items: [] - }; - } - - userId = await this.getUserId(impersonationId, userId); - - const portfolioCalculator = new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders - }); - - portfolioCalculator.setTransactionPoints(transactionPoints); - - const endDate = new Date(); - - const portfolioStart = parseDate(transactionPoints[0].date); - const startDate = this.getStartDate(dateRange, portfolioStart); - - let step = 1; - - if (withDataDecimation) { - const daysInMarket = differenceInDays(new Date(), startDate); - step = Math.round(daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS)); - } - - const items = await portfolioCalculator.getChartData({ - start: startDate, - end: endDate, - step, - calculateTimeWeightedPerformance - }); - - return { - items, - isAllTimeHigh: false, - isAllTimeLow: false - }; - } - - @LogPerformance private getDividendsByGroup({ dividends, groupBy @@ -1745,35 +1613,6 @@ export class PortfolioService { return valueInBaseCurrencyOfEmergencyFundPositions.toNumber(); } - @LogPerformance - private getFees({ - activities, - date = new Date(0), - userCurrency - }: { - activities: OrderWithAccount[]; - date?: Date; - userCurrency: string; - }) { - return activities - .filter((activity) => { - // Filter out all activities before given date (drafts) - return isBefore(date, new Date(activity.date)); - }) - .map(({ fee, SymbolProfile }) => { - return this.exchangeRateDataService.toCurrency( - fee, - SymbolProfile.currency, - userCurrency - ); - }) - .reduce( - (previous, current) => new Big(previous).plus(current), - new Big(0) - ); - } - - @LogPerformance private getInitialCashPosition({ balance, currency @@ -1784,11 +1623,12 @@ export class PortfolioService { return { currency, allocationInPercentage: 0, - assetClass: AssetClass.CASH, - assetSubClass: AssetClass.CASH, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, countries: [], dataSource: undefined, dateOfFirstActivity: undefined, + dividend: 0, grossPerformance: 0, grossPerformancePercent: 0, grossPerformancePercentWithCurrencyEffect: 0, @@ -1810,73 +1650,84 @@ export class PortfolioService { }; } - @LogPerformance - private getStartDate(aDateRange: DateRange, portfolioStart: Date) { - switch (aDateRange) { - case '1d': - portfolioStart = max([ - portfolioStart, - subDays(new Date().setHours(0, 0, 0, 0), 1) - ]); - break; - case 'mtd': - portfolioStart = max([ - portfolioStart, - subDays(startOfMonth(new Date().setHours(0, 0, 0, 0)), 1) - ]); - break; - case 'wtd': - portfolioStart = max([ - portfolioStart, - subDays( - startOfWeek(new Date().setHours(0, 0, 0, 0), { weekStartsOn: 1 }), - 1 + private getMarkets({ + assetProfile, + valueInBaseCurrency + }: { + assetProfile: EnhancedSymbolProfile; + valueInBaseCurrency: Big; + }) { + const markets = { + [UNKNOWN_KEY]: 0, + developedMarkets: 0, + emergingMarkets: 0, + otherMarkets: 0 + }; + const marketsAdvanced = { + [UNKNOWN_KEY]: 0, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 0, + otherMarkets: 0 + }; + + if (assetProfile.countries.length > 0) { + for (const country of assetProfile.countries) { + if (developedMarkets.includes(country.code)) { + markets.developedMarkets = new Big(markets.developedMarkets) + .plus(country.weight) + .toNumber(); + } else if (emergingMarkets.includes(country.code)) { + markets.emergingMarkets = new Big(markets.emergingMarkets) + .plus(country.weight) + .toNumber(); + } else { + markets.otherMarkets = new Big(markets.otherMarkets) + .plus(country.weight) + .toNumber(); + } + + if (country.code === 'JP') { + marketsAdvanced.japan = new Big(marketsAdvanced.japan) + .plus(country.weight) + .toNumber(); + } else if (country.code === 'CA' || country.code === 'US') { + marketsAdvanced.northAmerica = new Big(marketsAdvanced.northAmerica) + .plus(country.weight) + .toNumber(); + } else if (asiaPacificMarkets.includes(country.code)) { + marketsAdvanced.asiaPacific = new Big(marketsAdvanced.asiaPacific) + .plus(country.weight) + .toNumber(); + } else if (emergingMarkets.includes(country.code)) { + marketsAdvanced.emergingMarkets = new Big( + marketsAdvanced.emergingMarkets ) - ]); - break; - case 'ytd': - portfolioStart = max([ - portfolioStart, - subDays(startOfYear(new Date().setHours(0, 0, 0, 0)), 1) - ]); - break; - - case '1w': - portfolioStart = max([ - portfolioStart, - subDays(new Date().setHours(0, 0, 0, 0), 7) - ]); - break; - - case '1m': - portfolioStart = max([ - portfolioStart, - subMonths(new Date().setHours(0, 0, 0, 0), 1) - ]); - break; - - case '3m': - portfolioStart = max([ - portfolioStart, - subMonths(new Date().setHours(0, 0, 0, 0), 3) - ]); - break; - - case '1y': - portfolioStart = max([ - portfolioStart, - subYears(new Date().setHours(0, 0, 0, 0), 1) - ]); - break; - case '5y': - portfolioStart = max([ - portfolioStart, - subYears(new Date().setHours(0, 0, 0, 0), 5) - ]); - break; + .plus(country.weight) + .toNumber(); + } else if (europeMarkets.includes(country.code)) { + marketsAdvanced.europe = new Big(marketsAdvanced.europe) + .plus(country.weight) + .toNumber(); + } else { + marketsAdvanced.otherMarkets = new Big(marketsAdvanced.otherMarkets) + .plus(country.weight) + .toNumber(); + } + } + } else { + markets[UNKNOWN_KEY] = new Big(markets[UNKNOWN_KEY]) + .plus(valueInBaseCurrency) + .toNumber(); + + marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY]) + .plus(valueInBaseCurrency) + .toNumber(); } - return portfolioStart; + return { markets, marketsAdvanced }; } @LogPerformance @@ -1902,80 +1753,28 @@ export class PortfolioService { return { currentStreak, longestStreak }; } - @LogPerformance - private async getNetWorth( - impersonationId: string, - userId: string, - userCurrency: string - ) { - userId = await this.getUserId(impersonationId, userId); - - const { orders, portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ - userId, - withExcludedAccounts: true - }); - - const portfolioCalculator = new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders - }); - - const portfolioStart = parseDate( - transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) - ); - - portfolioCalculator.setTransactionPoints(transactionPoints); - - const { currentValue } = await portfolioCalculator.getCurrentPositions( - portfolioStart, - new Date(Date.now()), - false - ); - - return currentValue; - } - @LogPerformance private async getSummary({ balanceInBaseCurrency, emergencyFundPositionsValueInBaseCurrency, + filteredValueInBaseCurrency, holdings, impersonationId, + portfolioCalculator, userCurrency, userId }: { balanceInBaseCurrency: number; emergencyFundPositionsValueInBaseCurrency: number; + filteredValueInBaseCurrency: Big; holdings: PortfolioDetails['holdings']; impersonationId: string; + portfolioCalculator: PortfolioCalculator; userCurrency: string; userId: string; }): Promise { userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); - let performanceInformation: PortfolioPerformanceResponse = { - chart: [], - firstOrderDate: undefined, - performance: { - annualizedPerformancePercent: 0, - currentGrossPerformance: 0, - currentGrossPerformancePercent: 0, - currentGrossPerformancePercentWithCurrencyEffect: 0, - currentGrossPerformanceWithCurrencyEffect: 0, - currentNetPerformance: 0, - currentNetPerformancePercent: 0, - currentNetPerformancePercentWithCurrencyEffect: 0, - currentNetPerformanceWithCurrencyEffect: 0, - currentNetWorth: 0, - currentValue: 0, - totalInvestment: 0 - }, - errors: [], - hasErrors: false - }; const { activities } = await this.orderService.getOrders({ userCurrency, @@ -1983,63 +1782,32 @@ export class PortfolioService { withExcludedAccounts: true }); const excludedActivities: Activity[] = []; - let dividend = 0; - let fees = 0; - let items = 0; - let interest = 0; - - let liabilities = 0; - - let totalBuy = 0; - let totalSell = 0; - let activitiesUsed: Activity[] = []; - let ordersCount = 0; - let excludedAccountsAndActivities = 0; - const firstOrderDate = activities[0]?.date; - - performanceInformation = await this.getPerformance({ - impersonationId, - userId - }); - for (let order of activities) { - if (order.Account?.isExcluded ?? false) { - excludedActivities.push(order); + const nonExcludedActivities: Activity[] = []; + + for (const activity of activities) { + if (activity.Account?.isExcluded) { + excludedActivities.push(activity); } else { - activitiesUsed.push(order); - fees += this.exchangeRateDataService.toCurrency( - order.fee, - order.SymbolProfile.currency, - userCurrency - ); - let amount = this.exchangeRateDataService.toCurrency( - new Big(order.quantity).mul(order.unitPrice).toNumber(), - order.SymbolProfile.currency, - userCurrency - ); - switch (order.type) { - case 'DIVIDEND': - dividend += amount; - break; - case 'ITEM': - items += amount; - break; - case 'SELL': - totalSell += amount; - ordersCount++; - break; - case 'BUY': - totalBuy += amount; - ordersCount++; - break; - case 'LIABILITY': - liabilities += amount; - break; - case 'INTEREST': - interest += amount; - break; - } + nonExcludedActivities.push(activity); } } + + const { + currentValueInBaseCurrency, + grossPerformance, + grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, + grossPerformanceWithCurrencyEffect, + netPerformance, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffect, + netPerformanceWithCurrencyEffect, + totalInvestment + } = await portfolioCalculator.getSnapshot(); + + const dividendInBaseCurrency = + await portfolioCalculator.getDividendInBaseCurrency(); + const emergencyFund = new Big( Math.max( emergencyFundPositionsValueInBaseCurrency, @@ -2047,6 +1815,29 @@ export class PortfolioService { ) ); + const fees = await portfolioCalculator.getFeesInBaseCurrency(); + + const firstOrderDate = portfolioCalculator.getStartDate(); + + const interest = await portfolioCalculator.getInterestInBaseCurrency(); + + const liabilities = + await portfolioCalculator.getLiabilitiesInBaseCurrency(); + + const valuables = await portfolioCalculator.getValuablesInBaseCurrency(); + + const totalBuy = this.getSumOfActivityType({ + userCurrency, + activities: nonExcludedActivities, + activityType: 'BUY' + }).toNumber(); + + const totalSell = this.getSumOfActivityType({ + userCurrency, + activities: nonExcludedActivities, + activityType: 'SELL' + }).toNumber(); + const cash = new Big(balanceInBaseCurrency) .minus(emergencyFund) .plus(emergencyFundPositionsValueInBaseCurrency) @@ -2076,64 +1867,43 @@ export class PortfolioService { cashDetailsWithExcludedAccounts.balanceInBaseCurrency ).minus(balanceInBaseCurrency); - excludedAccountsAndActivities = excludedBalanceInBaseCurrency + let excludedAccountsAndActivities = excludedBalanceInBaseCurrency .plus(totalOfExcludedActivities) .toNumber(); const netWorth = new Big(balanceInBaseCurrency) - .plus(performanceInformation.performance.currentValue) - .plus(items) + .plus(currentValueInBaseCurrency) + .plus(valuables) .plus(excludedAccountsAndActivities) .minus(liabilities) .toNumber(); const daysInMarket = differenceInDays(new Date(), firstOrderDate); - const annualizedPerformancePercent = new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: [] - }) - .getAnnualizedPerformancePercent({ - daysInMarket, - netPerformancePercent: new Big( - performanceInformation.performance.currentNetPerformancePercent - ) - }) - ?.toNumber(); + const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage: new Big(netPerformancePercentage) + })?.toNumber(); const annualizedPerformancePercentWithCurrencyEffect = - new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: [] - }) - .getAnnualizedPerformancePercent({ - daysInMarket, - netPerformancePercent: new Big( - performanceInformation.performance.currentNetPerformancePercentWithCurrencyEffect - ) - }) - ?.toNumber(); + this.getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage: new Big( + netPerformancePercentageWithCurrencyEffect + ) + })?.toNumber(); return { - ...performanceInformation.performance, annualizedPerformancePercent, annualizedPerformancePercentWithCurrencyEffect, cash, - dividend, excludedAccountsAndActivities, - fees, firstOrderDate, - interest, - items, - liabilities, - netWorth, totalBuy, totalSell, committedFunds: committedFunds.toNumber(), + currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(), + dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), emergencyFund: { assets: emergencyFundPositionsValueInBaseCurrency, cash: emergencyFund @@ -2141,10 +1911,34 @@ export class PortfolioService { .toNumber(), total: emergencyFund.toNumber() }, - fireWealth: new Big(performanceInformation.performance.currentValue) + fees: fees.toNumber(), + filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(), + filteredValueInPercentage: netWorth + ? filteredValueInBaseCurrency.div(netWorth).toNumber() + : undefined, + fireWealth: new Big(currentValueInBaseCurrency) .minus(emergencyFundPositionsValueInBaseCurrency) .toNumber(), - ordersCount: ordersCount + grossPerformance: grossPerformance.toNumber(), + grossPerformancePercentage: grossPerformancePercentage.toNumber(), + grossPerformancePercentageWithCurrencyEffect: + grossPerformancePercentageWithCurrencyEffect.toNumber(), + grossPerformanceWithCurrencyEffect: + grossPerformanceWithCurrencyEffect.toNumber(), + interest: interest.toNumber(), + items: valuables.toNumber(), + liabilities: liabilities.toNumber(), + netPerformance: netPerformance.toNumber(), + netPerformancePercentage: netPerformancePercentage.toNumber(), + netPerformancePercentageWithCurrencyEffect: + netPerformancePercentageWithCurrencyEffect.toNumber(), + netPerformanceWithCurrencyEffect: + netPerformanceWithCurrencyEffect.toNumber(), + ordersCount: activities.filter(({ type }) => { + return ['BUY', 'SELL'].includes(type); + }).length, + totalInvestment: totalInvestment.toNumber(), + totalValueInBaseCurrency: netWorth }; } @@ -2152,102 +1946,32 @@ export class PortfolioService { private getSumOfActivityType({ activities, activityType, - date = new Date(0), userCurrency }: { - activities: OrderWithAccount[]; + activities: Activity[]; activityType: ActivityType; - date?: Date; userCurrency: string; }) { - return activities - .filter((activity) => { - // Filter out all activities before given date (drafts) and - // activity type - return ( - isBefore(date, new Date(activity.date)) && - activity.type === activityType - ); - }) - .map(({ quantity, SymbolProfile, unitPrice }) => { - return this.exchangeRateDataService.toCurrency( - new Big(quantity).mul(unitPrice).toNumber(), - SymbolProfile.currency, - userCurrency - ); - }) - .reduce( - (previous, current) => new Big(previous).plus(current), - new Big(0) - ); - } - - @LogPerformance - private async getTransactionPoints({ - filters, - includeDrafts = false, - types = ['BUY', 'ITEM', 'SELL', 'STAKE'], - userId, - withExcludedAccounts = false - }: { - filters?: Filter[]; - includeDrafts?: boolean; - types?: ActivityType[]; - userId: string; - withExcludedAccounts?: boolean; - }): Promise<{ - transactionPoints: TransactionPoint[]; - orders: Activity[]; - portfolioOrders: PortfolioOrder[]; - }> { - const userCurrency = - this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY; - - const { activities, count } = await this.orderService.getOrders({ - filters, - includeDrafts, - types, - userCurrency, - userId, - withExcludedAccounts - }); - - if (count <= 0) { - return { transactionPoints: [], orders: [], portfolioOrders: [] }; - } - - const portfolioOrders: PortfolioOrder[] = activities.map((order) => ({ - currency: order.SymbolProfile.currency, - dataSource: order.SymbolProfile.dataSource, - date: format(order.date, DATE_FORMAT), - fee: new Big(order.fee), - name: order.SymbolProfile?.name, - quantity: new Big(order.quantity), - symbol: order.SymbolProfile.symbol, - tags: order.tags, - type: order.type, - unitPrice: new Big(order.unitPrice) - })); - - const portfolioCalculator = new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders - }); - - portfolioCalculator.computeTransactionPoints(); - - return { - portfolioOrders, - orders: activities, - transactionPoints: portfolioCalculator.getTransactionPoints() - }; + return getSum( + activities + .filter(({ isDraft, type }) => { + return isDraft === false && type === activityType; + }) + .map(({ quantity, SymbolProfile, unitPrice }) => { + return new Big( + this.exchangeRateDataService.toCurrency( + new Big(quantity).mul(unitPrice).toNumber(), + SymbolProfile.currency, + userCurrency + ) + ); + }) + ); } - private getUserCurrency(aUser: UserWithSettings) { + private getUserCurrency(aUser?: UserWithSettings) { return ( - aUser.Settings?.settings.baseCurrency ?? + aUser?.Settings?.settings.baseCurrency ?? this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY ); @@ -2262,29 +1986,20 @@ export class PortfolioService { @LogPerformance private async getValueOfAccountsAndPlatforms({ + activities, filters = [], - orders, portfolioItemsNow, userCurrency, userId, withExcludedAccounts = false }: { + activities: Activity[]; filters?: Filter[]; - orders: Activity[]; portfolioItemsNow: { [p: string]: TimelinePosition }; userCurrency: string; userId: string; withExcludedAccounts?: boolean; }) { - const { activities: ordersOfTypeItemOrLiability } = - await this.orderService.getOrders({ - filters, - userCurrency, - userId, - withExcludedAccounts, - types: ['LIABILITY'] - }); - const accounts: PortfolioDetails['accounts'] = {}; const platforms: PortfolioDetails['platforms'] = {}; @@ -2302,7 +2017,7 @@ export class PortfolioService { }); } else { const accountIds = uniq( - orders + activities .filter(({ accountId }) => { return accountId; }) @@ -2322,19 +2037,10 @@ export class PortfolioService { }); for (const account of currentAccounts) { - let ordersByAccount = orders.filter(({ accountId }) => { + const ordersByAccount = activities.filter(({ accountId }) => { return accountId === account.id; }); - const ordersOfTypeItemOrLiabilityByAccount = - ordersOfTypeItemOrLiability.filter(({ accountId }) => { - return accountId === account.id; - }); - - ordersByAccount = ordersByAccount.concat( - ordersOfTypeItemOrLiabilityByAccount - ); - accounts[account.id] = { balance: account.balance, currency: account.currency, @@ -2373,13 +2079,10 @@ export class PortfolioService { type } of ordersByAccount) { let currentValueOfSymbolInBaseCurrency = + getFactor(type) * quantity * - portfolioItemsNow[SymbolProfile.symbol] - ?.marketPriceInBaseCurrency ?? 0; - - if (['LIABILITY', 'SELL'].includes(type)) { - currentValueOfSymbolInBaseCurrency *= -1; - } + (portfolioItemsNow[SymbolProfile.symbol]?.marketPriceInBaseCurrency ?? + 0); if (accounts[Account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { accounts[Account?.id || UNKNOWN_KEY].valueInBaseCurrency += @@ -2411,45 +2114,4 @@ export class PortfolioService { return { accounts, platforms }; } - - @LogPerformance - private mergeHistoricalDataItems( - accountBalanceItems: HistoricalDataItem[], - performanceChartItems: HistoricalDataItem[] - ): HistoricalDataItem[] { - const historicalDataItemsMap: { [date: string]: HistoricalDataItem } = {}; - let latestAccountBalance = 0; - - for (const item of accountBalanceItems.concat(performanceChartItems)) { - const isAccountBalanceItem = accountBalanceItems.includes(item); - - const totalAccountBalance = isAccountBalanceItem - ? item.value - : latestAccountBalance; - - if (isAccountBalanceItem && performanceChartItems.length > 0) { - latestAccountBalance = item.value; - } else { - historicalDataItemsMap[item.date] = { - ...item, - totalAccountBalance, - netWorth: - (isAccountBalanceItem ? 0 : item.value) + totalAccountBalance - }; - } - } - - // Convert to an array and sort by date in ascending order - const historicalDataItems = Object.keys(historicalDataItemsMap).map( - (date) => { - return historicalDataItemsMap[date]; - } - ); - - historicalDataItems.sort((a, b) => { - return new Date(a.date).getTime() - new Date(b.date).getTime(); - }); - - return historicalDataItems; - } } diff --git a/apps/api/src/app/portfolio/rules.service.ts b/apps/api/src/app/portfolio/rules.service.ts index 7dfcee56a..85f7ed55b 100644 --- a/apps/api/src/app/portfolio/rules.service.ts +++ b/apps/api/src/app/portfolio/rules.service.ts @@ -17,8 +17,16 @@ export class RulesService { return rule.getSettings(aUserSettings)?.isActive; }) .map((rule) => { - const evaluationResult = rule.evaluate(rule.getSettings(aUserSettings)); - return { ...evaluationResult, name: rule.getName() }; + const { evaluation, value } = rule.evaluate( + rule.getSettings(aUserSettings) + ); + + return { + evaluation, + value, + key: rule.getKey(), + name: rule.getName() + }; }); } } diff --git a/apps/api/src/app/redis-cache/redis-cache.service.mock.ts b/apps/api/src/app/redis-cache/redis-cache.service.mock.ts new file mode 100644 index 000000000..2422e88ab --- /dev/null +++ b/apps/api/src/app/redis-cache/redis-cache.service.mock.ts @@ -0,0 +1,13 @@ +import { RedisCacheService } from './redis-cache.service'; + +export const RedisCacheServiceMock = { + get: (key: string): Promise => { + return Promise.resolve(null); + }, + getPortfolioSnapshotKey: (userId: string): string => { + return `portfolio-snapshot-${userId}`; + }, + set: (key: string, value: string, ttlInSeconds?: number): Promise => { + return Promise.resolve(value); + } +}; diff --git a/apps/api/src/app/redis-cache/redis-cache.service.ts b/apps/api/src/app/redis-cache/redis-cache.service.ts index fe3fad13a..53b177b4f 100644 --- a/apps/api/src/app/redis-cache/redis-cache.service.ts +++ b/apps/api/src/app/redis-cache/redis-cache.service.ts @@ -21,7 +21,11 @@ export class RedisCacheService { } public async get(key: string): Promise { - return await this.cache.get(key); + return this.cache.get(key); + } + + public getPortfolioSnapshotKey({ userId }: { userId: string }) { + return `portfolio-snapshot-${userId}`; } public getQuoteKey({ dataSource, symbol }: UniqueAsset) { @@ -29,15 +33,15 @@ export class RedisCacheService { } public async remove(key: string) { - await this.cache.del(key); + return this.cache.del(key); } public async reset() { - await this.cache.reset(); + return this.cache.reset(); } public async set(key: string, value: string, ttlInSeconds?: number) { - await this.cache.set( + return this.cache.set( key, value, ttlInSeconds ?? this.configurationService.get('CACHE_TTL') diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts index 7aef70273..f4ca6d427 100644 --- a/apps/api/src/app/subscription/subscription.controller.ts +++ b/apps/api/src/app/subscription/subscription.controller.ts @@ -116,7 +116,7 @@ export class SubscriptionController { @Body() { couponId, priceId }: { couponId: string; priceId: string } ) { try { - return await this.subscriptionService.createCheckoutSession({ + return this.subscriptionService.createCheckoutSession({ couponId, priceId, user: this.request.user diff --git a/apps/api/src/app/symbol/symbol.controller.ts b/apps/api/src/app/symbol/symbol.controller.ts index e41267b79..17e0056d6 100644 --- a/apps/api/src/app/symbol/symbol.controller.ts +++ b/apps/api/src/app/symbol/symbol.controller.ts @@ -39,9 +39,11 @@ export class SymbolController { @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async lookupSymbol( - @Query('includeIndices') includeIndices = false, + @Query('includeIndices') includeIndicesParam = 'false', @Query('query') query = '' ): Promise<{ items: LookupItem[] }> { + const includeIndices = includeIndicesParam === 'true'; + try { return this.symbolService.lookup({ includeIndices, diff --git a/apps/api/src/app/symbol/symbol.service.ts b/apps/api/src/app/symbol/symbol.service.ts index 9a3f7a3a0..90259a776 100644 --- a/apps/api/src/app/symbol/symbol.service.ts +++ b/apps/api/src/app/symbol/symbol.service.ts @@ -74,11 +74,21 @@ export class SymbolService { date = new Date(), symbol }: IDataGatheringItem): Promise { - const historicalData = await this.dataProviderService.getHistoricalRaw( - [{ dataSource, symbol }], - date, - date - ); + let historicalData: { + [symbol: string]: { + [date: string]: IDataProviderHistoricalResponse; + }; + } = { + [symbol]: {} + }; + + try { + historicalData = await this.dataProviderService.getHistoricalRaw({ + dataGatheringItems: [{ dataSource, symbol }], + from: date, + to: date + }); + } catch {} return { marketPrice: diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index 152490d20..665f7822d 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -14,6 +14,7 @@ import { IsOptional, IsString } from 'class-validator'; +import { eachYearOfInterval, format } from 'date-fns'; export class UpdateUserSettingDto { @IsNumber() @@ -42,7 +43,12 @@ export class UpdateUserSettingDto { 'max', 'mtd', 'wtd', - 'ytd' + 'ytd', + ...eachYearOfInterval({ end: new Date(), start: new Date(0) }).map( + (date) => { + return format(date, 'yyyy'); + } + ) ]) @IsOptional() dateRange?: DateRange; diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 541c7b178..39e78dcdc 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -2,11 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { User, UserSettings } from '@ghostfolio/common/interfaces'; -import { - hasPermission, - hasRole, - permissions -} from '@ghostfolio/common/permissions'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { @@ -63,13 +59,6 @@ export class UserController { public async getUser( @Headers('accept-language') acceptLanguage: string ): Promise { - if (hasRole(this.request.user, 'INACTIVE')) { - throw new HttpException( - getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), - StatusCodes.TOO_MANY_REQUESTS - ); - } - return this.userService.getUser( this.request.user, acceptLanguage?.split(',')?.[0] @@ -135,7 +124,7 @@ export class UserController { } } - return await this.userService.updateUserSetting({ + return this.userService.updateUserSetting({ userSettings, userId: this.request.user.id }); diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index e20dea4af..3a370d88a 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -1,5 +1,6 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { environment } from '@ghostfolio/api/environments/environment'; +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; @@ -25,6 +26,7 @@ import { import { UserWithSettings } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { Prisma, Role, User } from '@prisma/client'; import { differenceInDays } from 'date-fns'; import { sortBy, without } from 'lodash'; @@ -37,6 +39,7 @@ export class UserService { public constructor( private readonly configurationService: ConfigurationService, + private readonly eventEmitter: EventEmitter2, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, private readonly subscriptionService: SubscriptionService, @@ -51,13 +54,22 @@ export class UserService { { Account, id, permissions, Settings, subscription }: UserWithSettings, aLocale = locale ): Promise { - const access = await this.prismaService.access.findMany({ - include: { - User: true - }, - orderBy: { alias: 'asc' }, - where: { GranteeUser: { id } } - }); + let [access, firstActivity, tags] = await Promise.all([ + this.prismaService.access.findMany({ + include: { + User: true + }, + orderBy: { alias: 'asc' }, + where: { GranteeUser: { id } } + }), + this.prismaService.order.findFirst({ + orderBy: { + date: 'asc' + }, + where: { userId: id } + }), + this.tagService.getByUser(id) + ]); let systemMessage: SystemMessage; @@ -69,8 +81,6 @@ export class UserService { systemMessage = systemMessageProperty; } - let tags = await this.tagService.getByUser(id); - if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && subscription.type === 'Basic' @@ -91,6 +101,7 @@ export class UserService { }; }), accounts: Account, + dateOfFirstActivity: firstActivity?.date ?? new Date(), settings: { ...(Settings.settings), locale: (Settings.settings)?.locale ?? aLocale @@ -429,11 +440,9 @@ export class UserService { userId: string; userSettings: UserSettings; }) { - const settings = userSettings as unknown as Prisma.JsonObject; - - await this.prismaService.settings.upsert({ + const { settings } = await this.prismaService.settings.upsert({ create: { - settings, + settings: userSettings as unknown as Prisma.JsonObject, User: { connect: { id: userId @@ -441,25 +450,33 @@ export class UserService { } }, update: { - settings + settings: userSettings as unknown as Prisma.JsonObject }, where: { userId } }); - return; + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId + }) + ); + + return settings; } private getRandomString(length: number) { + const bytes = crypto.randomBytes(length); const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; const result = []; for (let i = 0; i < length; i++) { - result.push( - characters.charAt(Math.floor(Math.random() * characters.length)) - ); + const randomByte = bytes[i]; + result.push(characters[randomByte % characters.length]); } + return result.join(''); } } diff --git a/apps/api/src/assets/cryptocurrencies/custom.json b/apps/api/src/assets/cryptocurrencies/custom.json index ffda9c526..814aeec34 100644 --- a/apps/api/src/assets/cryptocurrencies/custom.json +++ b/apps/api/src/assets/cryptocurrencies/custom.json @@ -4,6 +4,8 @@ "LUNA1": "Terra", "LUNA2": "Terra", "SGB1": "Songbird", + "SMURFCAT": "Real Smurf Cat", + "TON11419": "Toncoin", "UNI1": "Uniswap", "UNI7083": "Uniswap", "UST": "TerraUSD" diff --git a/apps/api/src/assets/sitemap.xml b/apps/api/src/assets/sitemap.xml index 3aeadc035..e527df8d0 100644 --- a/apps/api/src/assets/sitemap.xml +++ b/apps/api/src/assets/sitemap.xml @@ -1232,10 +1232,12 @@ https://ghostfol.io/nl/veelgestelde-vragen ${currentDate}T00:00:00+00:00 + https://ghostfol.io/pt ${currentDate}T00:00:00+00:00 @@ -1296,4 +1298,10 @@ https://ghostfol.io/tr ${currentDate}T00:00:00+00:00 + diff --git a/apps/api/src/environments/environment.prod.ts b/apps/api/src/environments/environment.prod.ts index bc8aa65a4..81b324963 100644 --- a/apps/api/src/environments/environment.prod.ts +++ b/apps/api/src/environments/environment.prod.ts @@ -1,4 +1,4 @@ export const environment = { production: true, - version: `v${require('../../../../package.json').version}` + version: `${require('../../../../package.json').version}` }; diff --git a/apps/api/src/events/events.module.ts b/apps/api/src/events/events.module.ts new file mode 100644 index 000000000..0e6b25ba4 --- /dev/null +++ b/apps/api/src/events/events.module.ts @@ -0,0 +1,11 @@ +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; + +import { Module } from '@nestjs/common'; + +import { PortfolioChangedListener } from './portfolio-changed.listener'; + +@Module({ + imports: [RedisCacheModule], + providers: [PortfolioChangedListener] +}) +export class EventsModule {} diff --git a/apps/api/src/events/portfolio-changed.event.ts b/apps/api/src/events/portfolio-changed.event.ts new file mode 100644 index 000000000..a3b0710fb --- /dev/null +++ b/apps/api/src/events/portfolio-changed.event.ts @@ -0,0 +1,15 @@ +export class PortfolioChangedEvent { + private userId: string; + + public constructor({ userId }: { userId: string }) { + this.userId = userId; + } + + public static getName() { + return 'portfolio.changed'; + } + + public getUserId() { + return this.userId; + } +} diff --git a/apps/api/src/events/portfolio-changed.listener.ts b/apps/api/src/events/portfolio-changed.listener.ts new file mode 100644 index 000000000..fcf47ce6c --- /dev/null +++ b/apps/api/src/events/portfolio-changed.listener.ts @@ -0,0 +1,25 @@ +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; + +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; + +import { PortfolioChangedEvent } from './portfolio-changed.event'; + +@Injectable() +export class PortfolioChangedListener { + public constructor(private readonly redisCacheService: RedisCacheService) {} + + @OnEvent(PortfolioChangedEvent.getName()) + handlePortfolioChangedEvent(event: PortfolioChangedEvent) { + Logger.log( + `Portfolio of user '${event.getUserId()}' has changed`, + 'PortfolioChangedListener' + ); + + this.redisCacheService.remove( + this.redisCacheService.getPortfolioSnapshotKey({ + userId: event.getUserId() + }) + ); + } +} diff --git a/apps/api/src/helper/object.helper.ts b/apps/api/src/helper/object.helper.ts index 1538228b8..c6d825598 100644 --- a/apps/api/src/helper/object.helper.ts +++ b/apps/api/src/helper/object.helper.ts @@ -1,4 +1,4 @@ -import Big from 'big.js'; +import { Big } from 'big.js'; import { cloneDeep, isArray, isObject } from 'lodash'; export function hasNotDefinedValuesInObject(aObject: Object): boolean { diff --git a/apps/api/src/helper/portfolio.helper.ts b/apps/api/src/helper/portfolio.helper.ts new file mode 100644 index 000000000..bd9c34a3d --- /dev/null +++ b/apps/api/src/helper/portfolio.helper.ts @@ -0,0 +1,91 @@ +import { resetHours } from '@ghostfolio/common/helper'; +import { DateRange } from '@ghostfolio/common/types'; + +import { Type as ActivityType } from '@prisma/client'; +import { + endOfDay, + max, + subDays, + startOfMonth, + startOfWeek, + startOfYear, + subYears, + endOfYear +} from 'date-fns'; + +export function getFactor(activityType: ActivityType) { + let factor: number; + + switch (activityType) { + case 'BUY': + case 'STAKE': + factor = 1; + break; + case 'SELL': + factor = -1; + break; + default: + factor = 0; + break; + } + + return factor; +} + +export function getInterval( + aDateRange: DateRange, + portfolioStart = new Date(0) +) { + let endDate = endOfDay(new Date(Date.now())); + let startDate = portfolioStart; + + switch (aDateRange) { + case '1d': + startDate = max([ + startDate, + subDays(resetHours(new Date(Date.now())), 1) + ]); + break; + case 'mtd': + startDate = max([ + startDate, + subDays(startOfMonth(resetHours(new Date(Date.now()))), 1) + ]); + break; + case 'wtd': + startDate = max([ + startDate, + subDays( + startOfWeek(resetHours(new Date(Date.now())), { weekStartsOn: 1 }), + 1 + ) + ]); + break; + case 'ytd': + startDate = max([ + startDate, + subDays(startOfYear(resetHours(new Date(Date.now()))), 1) + ]); + break; + case '1y': + startDate = max([ + startDate, + subYears(resetHours(new Date(Date.now())), 1) + ]); + break; + case '5y': + startDate = max([ + startDate, + subYears(resetHours(new Date(Date.now())), 5) + ]); + break; + case 'max': + break; + default: + // '2024', '2023', '2022', etc. + endDate = endOfYear(new Date(aDateRange)); + startDate = max([startDate, new Date(aDateRange)]); + } + + return { endDate, startDate }; +} diff --git a/apps/api/src/interceptors/redact-values-in-response.interceptor.ts b/apps/api/src/interceptors/redact-values-in-response.interceptor.ts index b1889cf9d..17d4e3c17 100644 --- a/apps/api/src/interceptors/redact-values-in-response.interceptor.ts +++ b/apps/api/src/interceptors/redact-values-in-response.interceptor.ts @@ -49,7 +49,6 @@ export class RedactValuesInResponseInterceptor 'dividendInBaseCurrency', 'fee', 'feeInBaseCurrency', - 'filteredValueInBaseCurrency', 'grossPerformance', 'grossPerformanceWithCurrencyEffect', 'investment', diff --git a/apps/api/src/models/order.ts b/apps/api/src/models/order.ts index 214f7e56a..6e6762101 100644 --- a/apps/api/src/models/order.ts +++ b/apps/api/src/models/order.ts @@ -1,6 +1,6 @@ import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces'; -import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client'; +import { Account, SymbolProfile, Type as ActivityType } from '@prisma/client'; import { v4 as uuidv4 } from 'uuid'; export class Order { @@ -14,7 +14,7 @@ export class Order { private symbol: string; private symbolProfile: SymbolProfile; private total: number; - private type: TypeOfOrder; + private type: ActivityType; private unitPrice: number; public constructor(data: IOrder) { diff --git a/apps/api/src/models/rule.ts b/apps/api/src/models/rule.ts index 171da810d..8397f3e46 100644 --- a/apps/api/src/models/rule.ts +++ b/apps/api/src/models/rule.ts @@ -1,25 +1,34 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { groupBy } from '@ghostfolio/common/helper'; -import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; +import { UserSettings } from '@ghostfolio/common/interfaces'; +import { TimelinePosition } from '@ghostfolio/common/models'; import { EvaluationResult } from './interfaces/evaluation-result.interface'; import { RuleInterface } from './interfaces/rule.interface'; export abstract class Rule implements RuleInterface { + private key: string; private name: string; public constructor( protected exchangeRateDataService: ExchangeRateDataService, { + key, name }: { + key: string; name: string; } ) { + this.key = key; this.name = name; } + public getKey() { + return this.key; + } + public getName() { return this.name; } diff --git a/apps/api/src/models/rules/account-cluster-risk/current-investment.ts b/apps/api/src/models/rules/account-cluster-risk/current-investment.ts index 23d3307de..a9a60f912 100644 --- a/apps/api/src/models/rules/account-cluster-risk/current-investment.ts +++ b/apps/api/src/models/rules/account-cluster-risk/current-investment.ts @@ -15,6 +15,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule { accounts: PortfolioDetails['accounts'] ) { super(exchangeRateDataService, { + key: AccountClusterRiskCurrentInvestment.name, name: 'Investment' }); @@ -35,7 +36,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule { }; } - let maxItem; + let maxItem: (typeof accounts)[0]; let totalInvestment = 0; for (const account of Object.values(accounts)) { @@ -52,7 +53,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule { } } - const maxInvestmentRatio = maxItem.investment / totalInvestment; + const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0; if (maxInvestmentRatio > ruleSettings.threshold) { return { diff --git a/apps/api/src/models/rules/account-cluster-risk/single-account.ts b/apps/api/src/models/rules/account-cluster-risk/single-account.ts index b5028228a..a47895c13 100644 --- a/apps/api/src/models/rules/account-cluster-risk/single-account.ts +++ b/apps/api/src/models/rules/account-cluster-risk/single-account.ts @@ -11,6 +11,7 @@ export class AccountClusterRiskSingleAccount extends Rule { accounts: PortfolioDetails['accounts'] ) { super(exchangeRateDataService, { + key: AccountClusterRiskSingleAccount.name, name: 'Single Account' }); diff --git a/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts index a23a208c3..372250dbc 100644 --- a/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts +++ b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts @@ -1,7 +1,8 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; +import { UserSettings } from '@ghostfolio/common/interfaces'; +import { TimelinePosition } from '@ghostfolio/common/models'; export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { private positions: TimelinePosition[]; @@ -11,6 +12,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { private positions: TimelinePosition[]; @@ -11,6 +12,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule { positions: TimelinePosition[] ) { super(exchangeRateDataService, { + key: CurrencyClusterRiskCurrentInvestment.name, name: 'Investment' }); @@ -37,7 +39,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule { } }); - const maxValueRatio = maxItem.value / totalValue; + const maxValueRatio = maxItem?.value / totalValue || 0; if (maxValueRatio > ruleSettings.threshold) { return { @@ -52,7 +54,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule { return { evaluation: `The major part of your current investment is in ${ - maxItem.groupKey + maxItem?.groupKey ?? ruleSettings.baseCurrency } (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${ ruleSettings.threshold * 100 }%`, diff --git a/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts b/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts index b6248ab51..20e9502bf 100644 --- a/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts +++ b/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts @@ -11,6 +11,7 @@ export class EmergencyFundSetup extends Rule { emergencyFund: number ) { super(exchangeRateDataService, { + key: EmergencyFundSetup.name, name: 'Emergency Fund: Set up' }); diff --git a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts index 0ba70d23c..69db9634d 100644 --- a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts +++ b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts @@ -13,6 +13,7 @@ export class FeeRatioInitialInvestment extends Rule { fees: number ) { super(exchangeRateDataService, { + key: FeeRatioInitialInvestment.name, name: 'Fee Ratio' }); diff --git a/apps/api/src/services/api/api.service.ts b/apps/api/src/services/api/api.service.ts index a469254f7..e961ec037 100644 --- a/apps/api/src/services/api/api.service.ts +++ b/apps/api/src/services/api/api.service.ts @@ -10,18 +10,21 @@ export class ApiService { filterByAccounts, filterByAssetClasses, filterByAssetSubClasses, + filterByHoldingType, filterBySearchQuery, filterByTags }: { filterByAccounts?: string; filterByAssetClasses?: string; filterByAssetSubClasses?: string; + filterByHoldingType?: string; filterBySearchQuery?: string; filterByTags?: string; }): Filter[] { const accountIds = filterByAccounts?.split(',') ?? []; const assetClasses = filterByAssetClasses?.split(',') ?? []; const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; + const holdingType = filterByHoldingType; const searchQuery = filterBySearchQuery?.toLowerCase(); const tagIds = filterByTags?.split(',') ?? []; @@ -52,6 +55,13 @@ export class ApiService { }) ]; + if (holdingType) { + filters.push({ + id: holdingType, + type: 'HOLDING_TYPE' + }); + } + if (searchQuery) { filters.push({ id: searchQuery, diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index 8110ecc53..c286ee185 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -3,7 +3,7 @@ import { DEFAULT_ROOT_URL } from '@ghostfolio/common/config'; import { Injectable } from '@nestjs/common'; import { DataSource } from '@prisma/client'; -import { bool, cleanEnv, host, json, num, port, str } from 'envalid'; +import { bool, cleanEnv, host, json, num, port, str, url } from 'envalid'; @Injectable() export class ConfigurationService { @@ -49,14 +49,13 @@ export class ConfigurationService { REDIS_PASSWORD: str({ default: '' }), REDIS_PORT: port({ default: 6379 }), REQUEST_TIMEOUT: num({ default: 2000 }), - ROOT_URL: str({ default: DEFAULT_ROOT_URL }), + ROOT_URL: url({ default: DEFAULT_ROOT_URL }), STRIPE_PUBLIC_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }), TWITTER_API_KEY: str({ default: 'dummyApiKey' }), - TWITTER_API_SECRET: str({ default: 'dummyApiSecret' }), - WEB_AUTH_RP_ID: host({ default: 'localhost' }) + TWITTER_API_SECRET: str({ default: 'dummyApiSecret' }) }); } diff --git a/apps/api/src/services/cron.service.ts b/apps/api/src/services/cron.service.ts index d74ad6a94..fc5d613a2 100644 --- a/apps/api/src/services/cron.service.ts +++ b/apps/api/src/services/cron.service.ts @@ -1,4 +1,5 @@ import { + DATA_GATHERING_QUEUE_PRIORITY_LOW, GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS_OPTIONS, PROPERTY_IS_DATA_GATHERING_ENABLED @@ -56,7 +57,8 @@ export class CronService { name: GATHER_ASSET_PROFILE_PROCESS, opts: { ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, - jobId: getAssetProfileIdentifier({ dataSource, symbol }) + jobId: getAssetProfileIdentifier({ dataSource, symbol }), + priority: DATA_GATHERING_QUEUE_PRIORITY_LOW } }; }) diff --git a/apps/api/src/services/data-gathering/data-gathering.processor.ts b/apps/api/src/services/data-gathering/data-gathering.processor.ts index bf960048c..11eda2e7a 100644 --- a/apps/api/src/services/data-gathering/data-gathering.processor.ts +++ b/apps/api/src/services/data-gathering/data-gathering.processor.ts @@ -37,7 +37,17 @@ export class DataGatheringProcessor { @Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS }) public async gatherAssetProfile(job: Job) { try { + Logger.log( + `Asset profile data gathering has been started for ${job.data.symbol} (${job.data.dataSource})`, + `DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS})` + ); + await this.dataGatheringService.gatherAssetProfiles([job.data]); + + Logger.log( + `Asset profile data gathering has been completed for ${job.data.symbol} (${job.data.dataSource})`, + `DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS})` + ); } catch (error) { Logger.error( error, @@ -62,11 +72,11 @@ export class DataGatheringProcessor { `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})` ); - const historicalData = await this.dataProviderService.getHistoricalRaw( - [{ dataSource, symbol }], - currentDate, - new Date() - ); + const historicalData = await this.dataProviderService.getHistoricalRaw({ + dataGatheringItems: [{ dataSource, symbol }], + from: currentDate, + to: new Date() + }); const data: Prisma.MarketDataUpdateInput[] = []; let lastMarketPrice: number; diff --git a/apps/api/src/services/data-gathering/data-gathering.service.ts b/apps/api/src/services/data-gathering/data-gathering.service.ts index 957c624d1..4d1cd8f2f 100644 --- a/apps/api/src/services/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/data-gathering/data-gathering.service.ts @@ -8,6 +8,8 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { DATA_GATHERING_QUEUE, + DATA_GATHERING_QUEUE_PRIORITY_HIGH, + DATA_GATHERING_QUEUE_PRIORITY_LOW, GATHER_HISTORICAL_MARKET_DATA_PROCESS, GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, PROPERTY_BENCHMARKS @@ -64,24 +66,35 @@ export class DataGatheringService { public async gather7Days() { const dataGatheringItems = await this.getSymbols7D(); - await this.gatherSymbols(dataGatheringItems); + await this.gatherSymbols({ + dataGatheringItems, + priority: DATA_GATHERING_QUEUE_PRIORITY_LOW + }); } public async gatherMax() { const dataGatheringItems = await this.getSymbolsMax(); - await this.gatherSymbols(dataGatheringItems); + await this.gatherSymbols({ + dataGatheringItems, + priority: DATA_GATHERING_QUEUE_PRIORITY_LOW + }); } public async gatherSymbol({ dataSource, symbol }: UniqueAsset) { await this.marketDataService.deleteMany({ dataSource, symbol }); - const symbols = (await this.getSymbolsMax()).filter((dataGatheringItem) => { - return ( - dataGatheringItem.dataSource === dataSource && - dataGatheringItem.symbol === symbol - ); + const dataGatheringItems = (await this.getSymbolsMax()).filter( + (dataGatheringItem) => { + return ( + dataGatheringItem.dataSource === dataSource && + dataGatheringItem.symbol === symbol + ); + } + ); + await this.gatherSymbols({ + dataGatheringItems, + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH }); - await this.gatherSymbols(symbols); } public async gatherSymbolForDate({ @@ -94,11 +107,11 @@ export class DataGatheringService { symbol: string; }) { try { - const historicalData = await this.dataProviderService.getHistoricalRaw( - [{ dataSource, symbol }], - date, - date - ); + const historicalData = await this.dataProviderService.getHistoricalRaw({ + dataGatheringItems: [{ dataSource, symbol }], + from: date, + to: date + }); const marketPrice = historicalData[symbol][format(date, DATE_FORMAT)].marketPrice; @@ -225,22 +238,23 @@ export class DataGatheringService { error, 'DataGatheringService' ); + + if (uniqueAssets.length === 1) { + throw error; + } } } - - Logger.log( - `Asset profile data gathering has been completed for ${uniqueAssets - .map(({ dataSource, symbol }) => { - return `${symbol} (${dataSource})`; - }) - .join(',')}.`, - 'DataGatheringService' - ); } - public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) { + public async gatherSymbols({ + dataGatheringItems, + priority + }: { + dataGatheringItems: IDataGatheringItem[]; + priority: number; + }) { await this.addJobsToQueue( - aSymbolsWithStartDate.map(({ dataSource, date, symbol }) => { + dataGatheringItems.map(({ dataSource, date, symbol }) => { return { data: { dataSource, @@ -250,6 +264,7 @@ export class DataGatheringService { name: GATHER_HISTORICAL_MARKET_DATA_PROCESS, opts: { ...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, + priority, jobId: `${getAssetProfileIdentifier({ dataSource, symbol diff --git a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts index d17ba4b7e..d673dd7aa 100644 --- a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts +++ b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts @@ -59,7 +59,7 @@ export class CoinGeckoService implements DataProviderInterface { }): Promise> { const response: Partial = { symbol, - assetClass: AssetClass.CASH, + assetClass: AssetClass.LIQUIDITY, assetSubClass: AssetSubClass.CRYPTOCURRENCY, currency: DEFAULT_CURRENCY, dataSource: this.getName() @@ -243,7 +243,7 @@ export class CoinGeckoService implements DataProviderInterface { return { name, symbol, - assetClass: AssetClass.CASH, + assetClass: AssetClass.LIQUIDITY, assetSubClass: AssetSubClass.CRYPTOCURRENCY, currency: DEFAULT_CURRENCY, dataProviderInfo: this.getDataProviderInfo(), diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts index 58c673be9..eda4542bc 100644 --- a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts @@ -5,12 +5,12 @@ import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Injectable } from '@nestjs/common'; import { SymbolProfile } from '@prisma/client'; +import { countries } from 'countries-list'; import got from 'got'; @Injectable() export class TrackinsightDataEnhancerService implements DataEnhancerInterface { private static baseUrl = 'https://www.trackinsight.com/data-api'; - private static countries = require('countries-list/dist/countries.json'); private static countriesMapping = { 'Russian Federation': 'Russia' }; @@ -127,20 +127,19 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { (response.countries as unknown as Country[]).length === 0 ) { response.countries = []; + for (const [name, value] of Object.entries( holdings?.countries ?? {} )) { let countryCode: string; - for (const [key, country] of Object.entries( - TrackinsightDataEnhancerService.countries - )) { + for (const [code, country] of Object.entries(countries)) { if ( country.name === name || country.name === TrackinsightDataEnhancerService.countriesMapping[name] ) { - countryCode = key; + countryCode = code; break; } } diff --git a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts index c6edef0ca..35fa9604a 100644 --- a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts @@ -266,7 +266,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { switch (quoteType?.toLowerCase()) { case 'cryptocurrency': - assetClass = AssetClass.CASH; + assetClass = AssetClass.LIQUIDITY; assetSubClass = AssetSubClass.CRYPTOCURRENCY; break; case 'equity': diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index 3700ddd84..b99ef20aa 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -21,7 +21,7 @@ import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import { Inject, Injectable, Logger } from '@nestjs/common'; import { DataSource, MarketData, SymbolProfile } from '@prisma/client'; -import Big from 'big.js'; +import { Big } from 'big.js'; import { eachDayOfInterval, format, isValid } from 'date-fns'; import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash'; import ms from 'ms'; @@ -205,13 +205,14 @@ export class DataProviderService { }); try { - const queryRaw = `SELECT * - FROM "MarketData" - WHERE "dataSource" IN ('${dataSources.join(`','`)}') - AND "symbol" IN ('${symbols.join( - `','` - )}') ${granularityQuery} ${rangeQuery} - ORDER BY date;`; + const queryRaw = ` + SELECT * + FROM "MarketData" + WHERE "dataSource" IN ('${dataSources.join(`','`)}') + AND "symbol" IN ('${symbols.join( + `','` + )}') ${granularityQuery} ${rangeQuery} + ORDER BY date;`; const marketDataByGranularity: MarketData[] = await this.prismaService.$queryRawUnsafe(queryRaw); @@ -233,15 +234,17 @@ export class DataProviderService { } } - public async getHistoricalRaw( - aDataGatheringItems: UniqueAsset[], - from: Date, - to: Date - ): Promise<{ + public async getHistoricalRaw({ + dataGatheringItems, + from, + to + }: { + dataGatheringItems: UniqueAsset[]; + from: Date; + to: Date; + }): Promise<{ [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; }> { - let dataGatheringItems = aDataGatheringItems; - for (const { currency, rootCurrency } of DERIVED_CURRENCIES) { if ( this.hasCurrency({ @@ -330,6 +333,8 @@ export class DataProviderService { } } catch (error) { Logger.error(error, 'DataProviderService'); + + throw error; } return result; @@ -396,7 +401,8 @@ export class DataProviderService { numberOfItemsInCache > 1 ? 's' : '' } from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed( 3 - )} seconds` + )} seconds`, + 'DataProviderService' ); } @@ -504,7 +510,8 @@ export class DataProviderService { } from ${dataSource} in ${( (performance.now() - startTimeDataSource) / 1000 - ).toFixed(3)} seconds` + ).toFixed(3)} seconds`, + 'DataProviderService' ); try { @@ -534,14 +541,15 @@ export class DataProviderService { await Promise.all(promises); - Logger.debug('------------------------------------------------'); + Logger.debug('--------------------------------------------------------'); Logger.debug( `Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${( (performance.now() - startTimeTotal) / 1000 - ).toFixed(3)} seconds` + ).toFixed(3)} seconds`, + 'DataProviderService' ); - Logger.debug('================================================'); + Logger.debug('========================================================'); return response; } diff --git a/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts index af67d62e9..1b6abd585 100644 --- a/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts +++ b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts @@ -241,37 +241,44 @@ export class EodHistoricalDataService implements DataProviderInterface { }) ); - response = quotes.reduce( - ( - result: { [symbol: string]: IDataProviderResponse }, - { close, code, timestamp } - ) => { - const currency = symbolProfiles.find(({ symbol }) => { + for (const { close, code, timestamp } of quotes) { + let currency: string; + + if (code.endsWith('.FOREX')) { + currency = this.convertFromEodSymbol(code)?.replace( + DEFAULT_CURRENCY, + '' + ); + } + + if (!currency) { + currency = symbolProfiles.find(({ symbol }) => { return symbol === code; })?.currency; + } - if (isNumber(close)) { - result[this.convertFromEodSymbol(code)] = { - currency: - currency ?? - this.convertFromEodSymbol(code)?.replace(DEFAULT_CURRENCY, ''), - dataSource: this.getName(), - marketPrice: close, - marketState: isToday(new Date(timestamp * 1000)) - ? 'open' - : 'closed' - }; - } else { - Logger.error( - `Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`, - 'EodHistoricalDataService' - ); + if (!currency) { + const { items } = await this.search({ query: code }); + + if (items.length === 1) { + currency = items[0].currency; } + } - return result; - }, - {} - ); + if (isNumber(close)) { + response[this.convertFromEodSymbol(code)] = { + currency, + dataSource: this.getName(), + marketPrice: close, + marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed' + }; + } else { + Logger.error( + `Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`, + 'EodHistoricalDataService' + ); + } + } return response; } catch (error) { @@ -461,7 +468,7 @@ export class EodHistoricalDataService implements DataProviderInterface { assetSubClass = AssetSubClass.STOCK; break; case 'currency': - assetClass = AssetClass.CASH; + assetClass = AssetClass.LIQUIDITY; if (Exchange?.toLowerCase() === 'cc') { assetSubClass = AssetSubClass.CRYPTOCURRENCY; diff --git a/apps/api/src/services/data-provider/manual/manual.service.ts b/apps/api/src/services/data-provider/manual/manual.service.ts index 3fdb06f59..986001b0c 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -92,7 +92,7 @@ export class ManualService implements DataProviderInterface { headers = {}, selector, url - } = symbolProfile.scraperConfiguration ?? {}; + } = symbolProfile?.scraperConfiguration ?? {}; if (defaultMarketPrice) { const historical: { diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts index e32af51d3..59f5144d8 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts @@ -22,6 +22,14 @@ export const ExchangeRateDataServiceMock = { '2023-07-10': 0.8854 } }); + } else if (targetCurrency === 'USD') { + return Promise.resolve({ + USDUSD: { + '2018-01-01': 1, + '2021-11-16': 1, + '2023-07-10': 1 + } + }); } return Promise.resolve({}); diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts index 148fac560..683002a10 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts @@ -73,7 +73,17 @@ export class ExchangeRateDataService { currencyTo: targetCurrency }); - let previousExchangeRate = 1; + const dateStrings = Object.keys( + exchangeRatesByCurrency[`${currency}${targetCurrency}`] + ); + const lastDateString = dateStrings.reduce((a, b) => { + return a > b ? a : b; + }); + + let previousExchangeRate = + exchangeRatesByCurrency[`${currency}${targetCurrency}`]?.[ + lastDateString + ] ?? 1; // Start from the most recent date and fill in missing exchange rates // using the latest available rate @@ -94,7 +104,7 @@ export class ExchangeRateDataService { exchangeRatesByCurrency[`${currency}${targetCurrency}`][dateString] = previousExchangeRate; - if (currency === DEFAULT_CURRENCY) { + if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) { Logger.error( `No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`, 'ExchangeRateDataService' @@ -433,15 +443,22 @@ export class ExchangeRateDataService { ]) * marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)]; - factors[format(date, DATE_FORMAT)] = factor; + if (isNaN(factor)) { + throw new Error('Exchange rate is not a number'); + } else { + factors[format(date, DATE_FORMAT)] = factor; + } } catch { - Logger.error( - `No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format( - date, - DATE_FORMAT - )}`, - 'ExchangeRateDataService' - ); + let errorMessage = `No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format( + date, + DATE_FORMAT + )}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom}`; + + if (DEFAULT_CURRENCY !== currencyTo) { + errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`; + } + + Logger.error(`${errorMessage}.`, 'ExchangeRateDataService'); } } } diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 09d0b0e5d..5d3145a28 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -42,5 +42,4 @@ export interface Environment extends CleanedEnvAccessors { TWITTER_ACCESS_TOKEN_SECRET: string; TWITTER_API_KEY: string; TWITTER_API_SECRET: string; - WEB_AUTH_RP_ID: string; } diff --git a/apps/api/src/services/interfaces/interfaces.ts b/apps/api/src/services/interfaces/interfaces.ts index bfd29d991..b945d0945 100644 --- a/apps/api/src/services/interfaces/interfaces.ts +++ b/apps/api/src/services/interfaces/interfaces.ts @@ -5,7 +5,7 @@ import { Account, DataSource, SymbolProfile, - Type as TypeOfOrder + Type as ActivityType } from '@prisma/client'; export interface IOrder { @@ -18,7 +18,7 @@ export interface IOrder { quantity: number; symbol: string; symbolProfile: SymbolProfile; - type: TypeOfOrder; + type: ActivityType; unitPrice: number; } diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts index abf973029..ec947dfef 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -107,7 +107,9 @@ export class SymbolProfileService { scraperConfiguration, sectors, symbol, - symbolMapping + symbolMapping, + SymbolProfileOverrides, + url }: Prisma.SymbolProfileUpdateInput & UniqueAsset) { return this.prismaService.symbolProfile.update({ data: { @@ -120,7 +122,9 @@ export class SymbolProfileService { tags, scraperConfiguration, sectors, - symbolMapping + symbolMapping, + SymbolProfileOverrides, + url }, where: { dataSource_symbol: { dataSource, symbol } } }); @@ -202,9 +206,8 @@ export class SymbolProfileService { return { code, weight, - continent: - continents[countries[code as string]?.continent] ?? UNKNOWN_KEY, - name: countries[code as string]?.name ?? UNKNOWN_KEY + continent: continents[countries[code]?.continent] ?? UNKNOWN_KEY, + name: countries[code]?.name ?? UNKNOWN_KEY }; }); } diff --git a/apps/client/project.json b/apps/client/project.json index 1945ee743..4c962bd8d 100644 --- a/apps/client/project.json +++ b/apps/client/project.json @@ -72,6 +72,10 @@ "baseHref": "/tr/", "localize": ["tr"] }, + "development-zh": { + "baseHref": "/zh/", + "localize": ["zh"] + }, "production": { "fileReplacements": [ { @@ -190,6 +194,9 @@ "development-tr": { "browserTarget": "client:build:development-tr" }, + "development-zh": { + "buildTarget": "client:build:development-zh" + }, "production": { "browserTarget": "client:build:production" } @@ -209,7 +216,8 @@ "messages.nl.xlf", "messages.pl.xlf", "messages.pt.xlf", - "messages.tr.xlf" + "messages.tr.xlf", + "messages.zh.xlf" ] } }, @@ -260,6 +268,10 @@ "tr": { "baseHref": "/tr/", "translation": "apps/client/src/locales/messages.tr.xlf" + }, + "zh": { + "baseHref": "/zh/", + "translation": "apps/client/src/locales/messages.zh.xlf" } }, "sourceLocale": "en" diff --git a/apps/client/src/app/app.component.html b/apps/client/src/app/app.component.html index 713eb1a94..29541962b 100644 --- a/apps/client/src/app/app.component.html +++ b/apps/client/src/app/app.component.html @@ -163,6 +163,11 @@ Türkçe --> + diff --git a/apps/client/src/app/app.component.scss b/apps/client/src/app/app.component.scss index 21d33e3c9..a23e94fbb 100644 --- a/apps/client/src/app/app.component.scss +++ b/apps/client/src/app/app.component.scss @@ -1,5 +1,3 @@ -@import 'apps/client/src/styles/ghostfolio-style'; - :host { display: block; min-height: 100svh; diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index 64b25ed79..1cf10bab6 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -1,3 +1,4 @@ +import { getCssVariable } from '@ghostfolio/common/helper'; import { InfoItem, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { ColorScheme } from '@ghostfolio/common/types'; @@ -187,20 +188,28 @@ export class AppComponent implements OnDestroy, OnInit { ? userPreferredColorScheme === 'DARK' : window.matchMedia('(prefers-color-scheme: dark)').matches; - this.toggleThemeStyleClass(isDarkTheme); + this.toggleTheme(isDarkTheme); window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => { if (!this.user?.settings.colorScheme) { - this.toggleThemeStyleClass(event.matches); + this.toggleTheme(event.matches); } }); } - private toggleThemeStyleClass(isDarkTheme: boolean) { + private toggleTheme(isDarkTheme: boolean) { + const themeColor = getCssVariable( + isDarkTheme ? '--dark-background' : '--light-background' + ); + if (isDarkTheme) { this.document.body.classList.add('is-dark-theme'); } else { this.document.body.classList.remove('is-dark-theme'); } + + this.document + .querySelector('meta[name="theme-color"]') + .setAttribute('content', themeColor); } } diff --git a/apps/client/src/app/app.module.ts b/apps/client/src/app/app.module.ts index f8b52faa9..fac59bf48 100644 --- a/apps/client/src/app/app.module.ts +++ b/apps/client/src/app/app.module.ts @@ -1,4 +1,4 @@ -import { GfLogoModule } from '@ghostfolio/ui/logo'; +import { GfLogoComponent } from '@ghostfolio/ui/logo'; import { Platform } from '@angular/cdk/platform'; import { HttpClientModule } from '@angular/common/http'; @@ -43,7 +43,7 @@ export function NgxStripeFactory(): string { BrowserAnimationsModule, BrowserModule, GfHeaderModule, - GfLogoModule, + GfLogoComponent, GfSubscriptionInterstitialDialogModule, HttpClientModule, MarkdownModule.forRoot(), diff --git a/apps/client/src/app/components/access-table/access-table.component.scss b/apps/client/src/app/components/access-table/access-table.component.scss index f506edfc6..22a5d6732 100644 --- a/apps/client/src/app/components/access-table/access-table.component.scss +++ b/apps/client/src/app/components/access-table/access-table.component.scss @@ -1,5 +1,3 @@ -@import 'apps/client/src/styles/ghostfolio-style'; - :host { display: block; diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index 2cd48a561..aeec7d2a5 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -1,3 +1,4 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { downloadAsFile } from '@ghostfolio/common/helper'; @@ -21,7 +22,7 @@ import { import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Sort, SortDirection } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; -import Big from 'big.js'; +import { Big } from 'big.js'; import { format, parseISO } from 'date-fns'; import { isNumber } from 'lodash'; import { Subject } from 'rxjs'; @@ -41,7 +42,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit { public activities: OrderWithAccount[]; public balance: number; public currency: string; - public dataSource: MatTableDataSource; + public dataSource: MatTableDataSource; public equity: number; public hasPermissionToDeleteAccountBalance: boolean; public historicalDataItems: HistoricalDataItem[]; @@ -83,59 +84,10 @@ export class AccountDetailDialog implements OnDestroy, OnInit { } public ngOnInit() { - this.dataService - .fetchAccount(this.data.accountId) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe( - ({ - balance, - currency, - name, - Platform, - transactionCount, - value, - valueInBaseCurrency - }) => { - this.balance = balance; - this.currency = currency; - - if (isNumber(balance) && isNumber(value)) { - this.equity = new Big(value).minus(balance).toNumber(); - } else { - this.equity = null; - } - - this.name = name; - this.platformName = Platform?.name ?? '-'; - this.transactionCount = transactionCount; - this.valueInBaseCurrency = valueInBaseCurrency; - - this.changeDetectorRef.markForCheck(); - } - ); - - this.dataService - .fetchPortfolioDetails({ - filters: [ - { - type: 'ACCOUNT', - id: this.data.accountId - } - ] - }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ holdings }) => { - this.holdings = []; - - for (const [symbol, holding] of Object.entries(holdings)) { - this.holdings.push(holding); - } - - this.changeDetectorRef.markForCheck(); - }); - + this.fetchAccount(); this.fetchAccountBalances(); this.fetchActivities(); + this.fetchPortfolioHoldings(); this.fetchPortfolioPerformance(); } @@ -143,15 +95,35 @@ export class AccountDetailDialog implements OnDestroy, OnInit { this.dialogRef.close(); } + public onAddAccountBalance({ + balance, + date + }: { + balance: number; + date: Date; + }) { + this.dataService + .postAccountBalance({ + balance, + date, + accountId: this.data.accountId + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.fetchAccount(); + this.fetchAccountBalances(); + this.fetchPortfolioPerformance(); + }); + } + public onDeleteAccountBalance(aId: string) { this.dataService .deleteAccountBalance(aId) .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe({ - next: () => { - this.fetchAccountBalances(); - this.fetchPortfolioPerformance(); - } + .subscribe(() => { + this.fetchAccount(); + this.fetchAccountBalances(); + this.fetchPortfolioPerformance(); }); } @@ -184,6 +156,39 @@ export class AccountDetailDialog implements OnDestroy, OnInit { this.fetchActivities(); } + private fetchAccount() { + this.dataService + .fetchAccount(this.data.accountId) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe( + ({ + balance, + currency, + name, + Platform, + transactionCount, + value, + valueInBaseCurrency + }) => { + this.balance = balance; + this.currency = currency; + + if (isNumber(balance) && isNumber(value)) { + this.equity = new Big(value).minus(balance).toNumber(); + } else { + this.equity = null; + } + + this.name = name; + this.platformName = Platform?.name ?? '-'; + this.transactionCount = transactionCount; + this.valueInBaseCurrency = valueInBaseCurrency; + + this.changeDetectorRef.markForCheck(); + } + ); + } + private fetchAccountBalances() { this.dataService .fetchAccountBalances(this.data.accountId) @@ -215,6 +220,24 @@ export class AccountDetailDialog implements OnDestroy, OnInit { }); } + private fetchPortfolioHoldings() { + this.dataService + .fetchPortfolioHoldings({ + filters: [ + { + type: 'ACCOUNT', + id: this.data.accountId + } + ] + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ holdings }) => { + this.holdings = holdings; + + this.changeDetectorRef.markForCheck(); + }); + } + private fetchPortfolioPerformance() { this.isLoadingChart = true; @@ -236,11 +259,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit { ({ date, netWorth, netWorthInPercentage }) => { return { date, - value: - this.data.hasImpersonationId || - this.user.settings.isRestrictedView - ? netWorthInPercentage - : netWorth + value: isNumber(netWorth) ? netWorth : netWorthInPercentage }; } ); diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html index 041a779c4..e092cce68 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html @@ -115,6 +115,7 @@ diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts index faba1d6d2..e404aaad1 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts @@ -1,10 +1,10 @@ import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module'; -import { GfAccountBalancesModule } from '@ghostfolio/ui/account-balances/account-balances.module'; -import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module'; -import { GfHoldingsTableModule } from '@ghostfolio/ui/holdings-table/holdings-table.module'; -import { GfValueModule } from '@ghostfolio/ui/value'; +import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances'; +import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; +import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table'; +import { GfValueComponent } from '@ghostfolio/ui/value'; import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; @@ -19,13 +19,13 @@ import { AccountDetailDialog } from './account-detail-dialog.component'; declarations: [AccountDetailDialog], imports: [ CommonModule, - GfAccountBalancesModule, - GfActivitiesTableModule, + GfAccountBalancesComponent, + GfActivitiesTableComponent, GfDialogFooterModule, GfDialogHeaderModule, - GfHoldingsTableModule, + GfHoldingsTableComponent, GfInvestmentChartModule, - GfValueModule, + GfValueComponent, MatButtonModule, MatDialogModule, MatTabsModule, diff --git a/apps/client/src/app/components/accounts-table/accounts-table.component.html b/apps/client/src/app/components/accounts-table/accounts-table.component.html index cd4f139c4..241b5d90a 100644 --- a/apps/client/src/app/components/accounts-table/accounts-table.component.html +++ b/apps/client/src/app/components/accounts-table/accounts-table.component.html @@ -34,7 +34,7 @@ Name -
- = @@ -33,6 +43,7 @@ export class AdminJobsComponent implements OnDestroy, OnInit { 'type', 'symbol', 'dataSource', + 'priority', 'attempts', 'created', 'finished', @@ -98,6 +109,15 @@ export class AdminJobsComponent implements OnDestroy, OnInit { }); } + public onExecuteJob(aId: string) { + this.adminService + .executeJob(aId) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.fetchJobs(); + }); + } + public onViewData(aData: AdminJobs['jobs'][0]['data']) { alert(JSON.stringify(aData, null, ' ')); } diff --git a/apps/client/src/app/components/admin-jobs/admin-jobs.html b/apps/client/src/app/components/admin-jobs/admin-jobs.html index 12b31dfc8..5da929fe1 100644 --- a/apps/client/src/app/components/admin-jobs/admin-jobs.html +++ b/apps/client/src/app/components/admin-jobs/admin-jobs.html @@ -58,6 +58,25 @@ + + + Priority + + + @if (element.opts.priority === DATA_GATHERING_QUEUE_PRIORITY_LOW) { + + } @else if ( + element.opts.priority === DATA_GATHERING_QUEUE_PRIORITY_MEDIUM + ) { + + } @else if ( + element.opts.priority === DATA_GATHERING_QUEUE_PRIORITY_HIGH + ) { + + } + + + Attempts @@ -90,24 +109,37 @@ Status - + - - + + @@ -147,6 +179,9 @@ > View Stacktrace + diff --git a/apps/client/src/app/components/admin-jobs/admin-jobs.scss b/apps/client/src/app/components/admin-jobs/admin-jobs.scss index b5b58f67e..5d4e87f30 100644 --- a/apps/client/src/app/components/admin-jobs/admin-jobs.scss +++ b/apps/client/src/app/components/admin-jobs/admin-jobs.scss @@ -1,5 +1,3 @@ -@import 'apps/client/src/styles/ghostfolio-style'; - :host { display: block; } diff --git a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html index f452dd1c9..00551bc99 100644 --- a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html +++ b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html @@ -9,11 +9,7 @@ [showYAxis]="true" [symbol]="symbol" /> -
+
{{ itemByMonth.key }}
{ - date, marketPrice, currency: this.currency, dataSource: this.dataSource, + dateString: `${yearMonth}-${day}`, symbol: this.symbol, user: this.user }, diff --git a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts index 9e742acba..9f4e1b3bc 100644 --- a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts +++ b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts @@ -1,4 +1,4 @@ -import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module'; +import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; @@ -9,7 +9,7 @@ import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/mark @NgModule({ declarations: [AdminMarketDataDetailComponent], exports: [AdminMarketDataDetailComponent], - imports: [CommonModule, GfLineChartModule, GfMarketDataDetailDialogModule], + imports: [CommonModule, GfLineChartComponent, GfMarketDataDetailDialogModule], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class GfAdminMarketDataDetailModule {} diff --git a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/interfaces/interfaces.ts index 8f5447f9c..81188cd1f 100644 --- a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/interfaces/interfaces.ts @@ -5,7 +5,7 @@ import { DataSource } from '@prisma/client'; export interface MarketDataDetailDialogParams { currency: string; dataSource: DataSource; - date: Date; + dateString: string; marketPrice: number; symbol: string; user: User; diff --git a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts index df8ac6067..6a44d0dfb 100644 --- a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts @@ -45,7 +45,7 @@ export class MarketDataDetailDialog implements OnDestroy { this.adminService .fetchSymbolForDate({ dataSource: this.data.dataSource, - date: this.data.date, + dateString: this.data.dateString, symbol: this.data.symbol }) .pipe(takeUntil(this.unsubscribeSubject)) @@ -63,7 +63,7 @@ export class MarketDataDetailDialog implements OnDestroy { marketData: { marketData: [ { - date: this.data.date.toISOString(), + date: this.data.dateString, marketPrice: this.data.marketPrice } ] diff --git a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html index 5e16fc702..8e7e30649 100644 --- a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html +++ b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html @@ -9,7 +9,7 @@ matInput name="date" [matDatepicker]="date" - [(ngModel)]="data.date" + [(ngModel)]="data.dateString" /> 'PRESET_ID' } ]); + public benchmarks: Partial[]; public currentDataSource: DataSource; public currentSymbol: string; public dataSource: MatTableDataSource = @@ -98,6 +105,7 @@ export class AdminMarketDataComponent 'actions' ]; public filters$ = new Subject(); + public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix; public isLoading = false; public isUUID = isUUID; public placeholder = ''; @@ -108,8 +116,10 @@ export class AdminMarketDataComponent private unsubscribeSubject = new Subject(); public constructor( + private adminMarketDataService: AdminMarketDataService, private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService, private deviceService: DeviceDetectorService, private dialog: MatDialog, private route: ActivatedRoute, @@ -169,6 +179,9 @@ export class AdminMarketDataComponent } public ngOnInit() { + const { benchmarks } = this.dataService.fetchInfo(); + + this.benchmarks = benchmarks; this.deviceType = this.deviceService.getDeviceInfo().deviceType; } @@ -181,20 +194,7 @@ export class AdminMarketDataComponent } public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) { - const confirmation = confirm( - $localize`Do you really want to delete this asset profile?` - ); - - if (confirmation) { - this.adminService - .deleteProfileData({ dataSource, symbol }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - setTimeout(() => { - window.location.reload(); - }, 300); - }); - } + this.adminMarketDataService.deleteProfileData({ dataSource, symbol }); } public onGather7Days() { @@ -293,7 +293,16 @@ export class AdminMarketDataComponent .subscribe(({ count, marketData }) => { this.totalItems = count; - this.dataSource = new MatTableDataSource(marketData); + this.dataSource = new MatTableDataSource( + marketData.map((marketDataItem) => { + return { + ...marketDataItem, + isBenchmark: this.benchmarks.some(({ id }) => { + return id === marketDataItem.id; + }) + }; + }) + ); this.dataSource.sort = this.sort; this.isLoading = false; @@ -320,6 +329,7 @@ export class AdminMarketDataComponent data: { dataSource, symbol, + colorScheme: this.user?.settings.colorScheme, deviceType: this.deviceType, locale: this.user?.settings?.locale }, diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.html b/apps/client/src/app/components/admin-market-data/admin-market-data.html index a91b05bcc..8cc8b65c6 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.html +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.html @@ -161,23 +161,27 @@ - +
@@ -140,34 +157,35 @@ >Asset Sub Class
- + @if ( + assetProfile?.countries?.length > 0 || assetProfile?.sectors?.length > 0 + ) { @if ( assetProfile?.countries?.length === 1 && assetProfile?.sectors?.length === 1 ) { -
- Sector -
-
- Country -
+ @if (assetProfile?.sectors?.length === 1) { +
+ Sector +
+ } + @if (assetProfile?.countries?.length === 1) { +
+ Country +
+ } } @else {
Sectors
@@ -190,23 +208,25 @@ />
} -
+ }
-
+
Name
-
- - Currency - - -
+ @if (assetProfile?.dataSource === 'MANUAL') { +
+ + Currency + + +
+ }
Asset Class @@ -296,54 +316,69 @@ >
-
- - Scraper Configuration -
+ @if (assetProfile?.dataSource === 'MANUAL') { +
+ + Scraper Configuration +
+ + +
+
+
+
+ + Sectors - -
- -
-
- - Sectors - - -
-
- - Countries - + +
+
+ + Countries + + +
+ } +
+ + Url + + @if (assetProfileForm.get('url').value) { + + }
-
+
Note