diff --git a/.env.dev b/.env.dev new file mode 100644 index 000000000..c4c8a0d35 --- /dev/null +++ b/.env.dev @@ -0,0 +1,25 @@ +COMPOSE_PROJECT_NAME=ghostfolio-development + +# CACHE +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= + +# POSTGRES +POSTGRES_DB=ghostfolio-db +POSTGRES_USER=user +POSTGRES_PASSWORD= + +# VARIOUS +ACCESS_TOKEN_SALT= +DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer +JWT_SECRET_KEY= + +# DEVELOPMENT + +# Nx 18 enables using plugins to infer targets by default +# This is disabled for existing workspaces to maintain compatibility +# For more info, see: https://nx.dev/concepts/inferred-tasks +NX_ADD_PLUGINS=false + +NX_NATIVE_COMMAND_RUNNER=false diff --git a/.env.example b/.env.example index 8df547e37..766894992 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -COMPOSE_PROJECT_NAME=ghostfolio-development +COMPOSE_PROJECT_NAME=ghostfolio # CACHE REDIS_HOST=localhost @@ -10,6 +10,7 @@ POSTGRES_DB=ghostfolio-db POSTGRES_USER=user POSTGRES_PASSWORD= +# VARIOUS ACCESS_TOKEN_SALT= DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer JWT_SECRET_KEY= diff --git a/CHANGELOG.md b/CHANGELOG.md index 51d86efa1..8e26c1369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,227 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### 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 + +## 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..234e2c941 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ **Open Source Wealth Management Software** [**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) | -[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**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) @@ -144,7 +144,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), [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 +154,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 +206,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/account.service.ts b/apps/api/src/app/account/account.service.ts index cb8467c18..697041645 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -5,7 +5,7 @@ import { Filter } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; import { Account, Order, Platform, Prisma } from '@prisma/client'; -import Big from 'big.js'; +import { Big } from 'big.js'; import { groupBy } from 'lodash'; import { CashDetails } from './interfaces/cash-details.interface'; diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 40ba85759..298a471c3 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 } }); } @@ -339,6 +341,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 320601667..2aac43a18 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -25,6 +25,7 @@ import { MarketDataPreset } from '@ghostfolio/common/types'; import { BadRequestException, Injectable } from '@nestjs/common'; import { + AssetClass, AssetSubClass, DataSource, Prisma, @@ -71,7 +72,7 @@ export class AdminService { ); } - return await this.symbolProfileService.add( + return this.symbolProfileService.add( assetProfiles[symbol] as Prisma.SymbolProfileCreateInput ); } catch (error) { @@ -212,6 +213,7 @@ export class AdminService { countries: true, currency: true, dataSource: true, + id: true, name: true, Order: { orderBy: [{ date: 'asc' }], @@ -235,6 +237,7 @@ export class AdminService { countries, currency, dataSource, + id, name, Order, sectors, @@ -257,6 +260,7 @@ export class AdminService { currency, countriesCount, dataSource, + id, name, symbol, marketDataItemCount, @@ -329,21 +333,39 @@ export class AdminService { scraperConfiguration, sectors, symbol, - symbolMapping + symbolMapping, + url }: Prisma.SymbolProfileUpdateInput & UniqueAsset) { - await this.symbolProfileService.updateSymbolProfile({ - assetClass, - assetSubClass, - comment, - countries, - currency, - dataSource, - name, - scraperConfiguration, - sectors, - symbol, - symbolMapping - }); + const symbolProfileOverrides = { + assetClass: assetClass as AssetClass, + assetSubClass: assetSubClass as AssetSubClass, + name: name as string, + url: url as string + }; + + const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput & UniqueAsset = + { + comment, + countries, + currency, + dataSource, + scraperConfiguration, + sectors, + symbol, + symbolMapping, + ...(dataSource === 'MANUAL' + ? { assetClass, assetSubClass, name, url } + : { + SymbolProfileOverrides: { + upsert: { + create: symbolProfileOverrides, + update: symbolProfileOverrides + } + } + }) + }; + + await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile); const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([ { @@ -397,6 +419,7 @@ export class AdminService { assetClass: 'CASH', countriesCount: 0, currency: symbol.replace(DEFAULT_CURRENCY, ''), + id: undefined, name: symbol, sectorsCount: 0 }; @@ -440,13 +463,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 @@ -466,6 +490,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 4a0457194..e3de3cab1 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 { @@ -46,4 +47,11 @@ export class UpdateAssetProfileDto { symbolMapping?: { [dataProvider: string]: string; }; + + @IsOptional() + @IsUrl({ + protocols: ['https'], + require_protocol: true + }) + url?: string; } 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..26df9d069 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: { @@ -420,14 +426,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 +452,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 +528,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 } }; } 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..3dadedcaf 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, @@ -84,6 +88,7 @@ export class OrderController { @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, + @Query('range') dateRange: DateRange = 'max', @Query('skip') skip?: number, @Query('sortColumn') sortColumn?: string, @Query('sortDirection') sortDirection?: Prisma.SortOrder, @@ -96,14 +101,18 @@ export class OrderController { filterByTags }); + const { endDate, startDate } = getInterval(dateRange); + const impersonationUserId = await this.impersonationService.validateImpersonationId(impersonationId); 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 +129,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 +163,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 +197,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 79738c27e..35bfa1bcf 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -4,6 +4,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- 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'; @@ -19,13 +20,14 @@ 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 { CreateOrderDto } from './create-order.dto'; import { Activities } from './interfaces/activities.interface'; @Injectable() @@ -65,20 +67,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 +81,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 +102,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 +116,6 @@ export class OrderService { delete data.comment; } - delete data.currency; delete data.dataSource; delete data.symbol; delete data.tags; @@ -130,13 +124,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: { @@ -157,15 +147,15 @@ 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 }); } @@ -180,12 +170,7 @@ 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); } @@ -212,24 +197,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 +228,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, @@ -334,17 +335,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, @@ -375,13 +404,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; } @@ -390,13 +416,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; @@ -404,19 +429,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; 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/mwr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts new file mode 100644 index 000000000..978f1f3aa --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts @@ -0,0 +1,37 @@ +import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface'; +import { + SymbolMetrics, + TimelinePosition, + UniqueAsset +} from '@ghostfolio/common/interfaces'; + +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..504b5b171 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts @@ -0,0 +1,26 @@ +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 +}; 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..e64c23942 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts @@ -0,0 +1,56 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { DateRange } from '@ghostfolio/common/types'; + +import { Injectable } from '@nestjs/common'; + +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 +} + +@Injectable() +export class PortfolioCalculatorFactory { + public constructor( + private readonly currentRateService: CurrentRateService, + private readonly exchangeRateDataService: ExchangeRateDataService + ) {} + + public createCalculator({ + activities, + calculationType, + currency, + dateRange = 'max' + }: { + activities: Activity[]; + calculationType: PerformanceCalculationType; + currency: string; + dateRange?: DateRange; + }): PortfolioCalculator { + switch (calculationType) { + case PerformanceCalculationType.MWR: + return new MWRPortfolioCalculator({ + activities, + currency, + dateRange, + currentRateService: this.currentRateService, + exchangeRateDataService: this.exchangeRateDataService + }); + case PerformanceCalculationType.TWR: + return new TWRPortfolioCalculator({ + activities, + currency, + currentRateService: this.currentRateService, + dateRange, + exchangeRateDataService: this.exchangeRateDataService + }); + 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..1d2eadfbf --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -0,0 +1,928 @@ +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 { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.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 { + getFactor, + getInterval +} from '@ghostfolio/api/helper/portfolio.helper'; +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, + TimelinePosition, + UniqueAsset +} from '@ghostfolio/common/interfaces'; +import { DateRange, GroupBy } from '@ghostfolio/common/types'; + +import { Big } from 'big.js'; +import { + differenceInDays, + eachDayOfInterval, + endOfDay, + format, + isBefore, + isSameDay, + max, + subDays +} from 'date-fns'; +import { last, uniq, uniqBy } from 'lodash'; + +export abstract class PortfolioCalculator { + protected static readonly ENABLE_LOGGING = false; + + protected orders: PortfolioOrder[]; + + private currency: string; + private currentRateService: CurrentRateService; + private dataProviderInfos: DataProviderInfo[]; + private endDate: Date; + private exchangeRateDataService: ExchangeRateDataService; + private snapshot: PortfolioSnapshot; + private snapshotPromise: Promise; + private startDate: Date; + private transactionPoints: TransactionPoint[]; + + public constructor({ + activities, + currency, + currentRateService, + dateRange, + exchangeRateDataService + }: { + activities: Activity[]; + currency: string; + currentRateService: CurrentRateService; + dateRange: DateRange; + exchangeRateDataService: ExchangeRateDataService; + }) { + this.currency = currency; + this.currentRateService = currentRateService; + this.exchangeRateDataService = exchangeRateDataService; + this.orders = activities.map( + ({ date, fee, quantity, SymbolProfile, tags = [], type, unitPrice }) => { + return { + SymbolProfile, + tags, + type, + date: format(date, DATE_FORMAT), + fee: new Big(fee), + quantity: new Big(quantity), + unitPrice: new Big(unitPrice) + }; + } + ); + + this.orders.sort((a, b) => { + return a.date?.localeCompare(b.date); + }); + + 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 + ); + } + } + + 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 { + if (this.getTransactionPoints().length === 0) { + return []; + } + + 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.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; + 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 + }; + } + + for (const currentDate of dates) { + const dateString = format(currentDate, DATE_FORMAT); + + 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 ?? 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) + }; + } + } + + return Object.entries(accumulatedValuesByDate).map(([date, values]) => { + const { + investmentValueWithCurrencyEffect, + 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(), + 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() { + return this.transactionPoints.length > 0 + ? parseDate(this.transactionPoints[0].date) + : new Date(); + } + + 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.orders) { + 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() { + 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..a11ae8896 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts @@ -0,0 +1,195 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData +} 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 { 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; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + + beforeEach(() => { + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + factory = new PortfolioCalculatorFactory( + currentRateService, + exchangeRateDataService + ); + }); + + 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' + }); + + 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 65% 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 d81393719..8d93d8b97 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,18 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData +} 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 { 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 { @@ -19,6 +26,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { describe('PortfolioCalculator', () => { let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; beforeEach(() => { currentRateService = new CurrentRateService(null, null, null, null); @@ -29,51 +37,63 @@ describe('PortfolioCalculator', () => { null, null ); + + factory = new PortfolioCalculatorFactory( + currentRateService, + exchangeRateDataService + ); }); 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' }); - 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 +106,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 +127,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,13 +149,19 @@ describe('PortfolioCalculator', () => { marketPriceInBaseCurrency: 148.9, quantity: new Big('0'), symbol: 'BALN.SW', + tags: [], timeWeightedInvestment: new Big('285.8'), 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 67% 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..f26331134 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,18 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData +} 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 { 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 { @@ -19,6 +26,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { describe('PortfolioCalculator', () => { let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; beforeEach(() => { currentRateService = new CurrentRateService(null, null, null, null); @@ -29,40 +37,48 @@ describe('PortfolioCalculator', () => { null, null ); + + factory = new PortfolioCalculatorFactory( + currentRateService, + exchangeRateDataService + ); }); 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' }); - 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 +91,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 +112,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 +134,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 75% 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..2a9ba0916 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,19 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData +} 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 { 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 { @@ -32,6 +39,7 @@ jest.mock( describe('PortfolioCalculator', () => { let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; beforeEach(() => { currentRateService = new CurrentRateService(null, null, null, null); @@ -42,51 +50,63 @@ describe('PortfolioCalculator', () => { null, null ); + + factory = new PortfolioCalculatorFactory( + currentRateService, + exchangeRateDataService + ); }); 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' }); - 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 +119,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 +140,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 +166,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..83f99e3cb --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts @@ -0,0 +1,134 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData +} 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 { 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; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + + beforeEach(() => { + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + factory = new PortfolioCalculatorFactory( + currentRateService, + exchangeRateDataService + ); + }); + + 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' + }); + + 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 71% 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..0642b28ed 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,19 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData +} 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 { 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 { @@ -32,6 +39,7 @@ jest.mock( describe('PortfolioCalculator', () => { let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; beforeEach(() => { currentRateService = new CurrentRateService(null, null, null, null); @@ -42,40 +50,48 @@ describe('PortfolioCalculator', () => { null, null ); + + factory = new PortfolioCalculatorFactory( + currentRateService, + exchangeRateDataService + ); }); 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' + }); 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 +104,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 +125,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 +147,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..b8ef6954e --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts @@ -0,0 +1,134 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData +} 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 { 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; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + + beforeEach(() => { + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + factory = new PortfolioCalculatorFactory( + currentRateService, + exchangeRateDataService + ); + }); + + 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' + }); + + 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..9ef369c8f --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts @@ -0,0 +1,134 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData +} 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 { 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; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + + beforeEach(() => { + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + factory = new PortfolioCalculatorFactory( + currentRateService, + exchangeRateDataService + ); + }); + + 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('2022-01-01'), + 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' + }); + + 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('3000'), + 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: 3000, + netPerformance: null, + netPerformancePercentage: null, + netPerformancePercentageWithCurrencyEffect: null, + netPerformanceWithCurrencyEffect: null, + quantity: new Big('0'), + symbol: '55196015-1365-4560-aa60-8751ae6d18f8', + 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-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..e50ce4194 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -0,0 +1,142 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData +} 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 { 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/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 currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + + beforeEach(() => { + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + factory = new PortfolioCalculatorFactory( + currentRateService, + exchangeRateDataService + ); + }); + + 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' + }); + + 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/portfolio-calculator-no-orders.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts similarity index 62% rename from apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts rename to apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts index ab7234822..1d69abfbf 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts @@ -1,11 +1,14 @@ +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 { 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'; +import { subDays } from 'date-fns'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -19,6 +22,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { describe('PortfolioCalculator', () => { let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; beforeEach(() => { currentRateService = new CurrentRateService(null, null, null, null); @@ -29,30 +33,31 @@ describe('PortfolioCalculator', () => { null, null ); + + factory = new PortfolioCalculatorFactory( + currentRateService, + exchangeRateDataService + ); }); 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 portfolioCalculator = factory.createCalculator({ + activities: [], + calculationType: PerformanceCalculationType.TWR, + currency: 'CHF' }); - const currentPositions = await portfolioCalculator.getCurrentPositions( - new Date() - ); + const start = subDays(new Date(Date.now()), 10); + + const chartData = await portfolioCalculator.getChartData({ start }); + + const portfolioSnapshot = + await portfolioCalculator.computeSnapshot(start); const investments = portfolioCalculator.getInvestments(); @@ -63,8 +68,8 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); - expect(currentPositions).toEqual({ - currentValue: new Big(0), + expect(portfolioSnapshot).toEqual({ + currentValueInBaseCurrency: new Big(0), grossPerformance: new Big(0), grossPerformancePercentage: new Big(0), grossPerformancePercentageWithCurrencyEffect: new Big(0), @@ -75,7 +80,12 @@ describe('PortfolioCalculator', () => { netPerformancePercentageWithCurrencyEffect: new Big(0), netPerformanceWithCurrencyEffect: new Big(0), positions: [], - totalInvestment: 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') }); expect(investments).toEqual([]); 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 65% 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 c46fd54d2..3d63f1a5d 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,18 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData +} 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 { 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 { @@ -19,6 +26,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { describe('PortfolioCalculator', () => { let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; beforeEach(() => { currentRateService = new CurrentRateService(null, null, null, null); @@ -29,51 +37,63 @@ describe('PortfolioCalculator', () => { null, null ); + + factory = new PortfolioCalculatorFactory( + currentRateService, + exchangeRateDataService + ); }); 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' }); - 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 +106,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 +127,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,15 +149,21 @@ describe('PortfolioCalculator', () => { marketPriceInBaseCurrency: 87.8, quantity: new Big('1'), symbol: 'NOVN.SW', + 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 70% 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 fa3ebac9b..6f0b03800 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,18 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData +} 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 { 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 { @@ -19,6 +26,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { describe('PortfolioCalculator', () => { let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; beforeEach(() => { currentRateService = new CurrentRateService(null, null, null, null); @@ -29,51 +37,63 @@ describe('PortfolioCalculator', () => { null, null ); + + factory = new PortfolioCalculatorFactory( + currentRateService, + exchangeRateDataService + ); }); 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' }); - 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') ); @@ -112,8 +132,8 @@ describe('PortfolioCalculator', () => { 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'), @@ -133,6 +153,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'), @@ -153,13 +175,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..365593846 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts @@ -0,0 +1,27 @@ +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; + +describe('PortfolioCalculator', () => { + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let factory: PortfolioCalculatorFactory; + + beforeEach(() => { + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + factory = new PortfolioCalculatorFactory( + currentRateService, + exchangeRateDataService + ); + }); + + 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..7dcef89cb --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts @@ -0,0 +1,900 @@ +import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface'; +import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface'; +import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { + SymbolMetrics, + TimelinePosition, + UniqueAsset +} from '@ghostfolio/common/interfaces'; + +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 totalDividend = new Big(0); + let totalDividendInBaseCurrency = new Big(0); + let totalInterest = new Big(0); + let totalInterestInBaseCurrency = new Big(0); + let totalInvestment = new Big(0); + let totalInvestmentFromBuyTransactions = new Big(0); + let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0); + let totalInvestmentWithCurrencyEffect = new Big(0); + let totalLiabilities = new Big(0); + let totalLiabilitiesInBaseCurrency = new Big(0); + let totalQuantityFromBuyTransactions = new Big(0); + let totalUnits = new Big(0); + let totalValuables = new Big(0); + let totalValuablesInBaseCurrency = new Big(0); + let valueAtStartDate: Big; + let valueAtStartDateWithCurrencyEffect: Big; + + // Clone orders to keep the original values in this.orders + let orders: PortfolioOrderItem[] = cloneDeep(this.orders).filter( + ({ SymbolProfile }) => { + return SymbolProfile.symbol === symbol; + } + ); + + if (orders.length <= 0) { + return { + currentValues: {}, + currentValuesWithCurrencyEffect: {}, + feesWithCurrencyEffect: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + hasErrors: false, + initialValue: new Big(0), + initialValueWithCurrencyEffect: new Big(0), + investmentValuesAccumulated: {}, + investmentValuesAccumulatedWithCurrencyEffect: {}, + investmentValuesWithCurrencyEffect: {}, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffect: new Big(0), + netPerformanceValues: {}, + netPerformanceValuesWithCurrencyEffect: {}, + netPerformanceWithCurrencyEffect: new Big(0), + timeWeightedInvestment: new Big(0), + timeWeightedInvestmentValues: {}, + timeWeightedInvestmentValuesWithCurrencyEffect: {}, + timeWeightedInvestmentWithCurrencyEffect: 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: {}, + 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: {}, + netPerformanceWithCurrencyEffect: new Big(0), + timeWeightedInvestment: new Big(0), + timeWeightedInvestmentValues: {}, + timeWeightedInvestmentValuesWithCurrencyEffect: {}, + timeWeightedInvestmentWithCurrencyEffect: 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 order of orders) { + datesWithOrders[order.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.type, order.itemType); + } + + const exchangeRateAtOrderDate = exchangeRates[order.date]; + + 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.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('totalInvestment', totalInvestment.toNumber()); + + console.log( + 'totalInvestmentWithCurrencyEffect', + totalInvestmentWithCurrencyEffect.toNumber() + ); + + 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))); + + 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) + ); + } + + 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'].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)}%` + ); + } + + return { + currentValues, + currentValuesWithCurrencyEffect, + feesWithCurrencyEffect, + grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, + initialValue, + initialValueWithCurrencyEffect, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffect, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect, + 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..ed9229691 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -43,6 +43,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 f4855329e..712d07e7a 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -34,7 +34,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)); @@ -43,7 +43,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/portfolio-calculator.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts similarity index 82% 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 357b454fd..b0543ce99 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 6955785f2..a32d47e21 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'; @@ -12,6 +12,8 @@ export interface PortfolioPositionDetail { averagePrice: number; dataProviderInfo: DataProviderInfo; dividendInBaseCurrency: number; + dividendYieldPercent: number; + dividendYieldPercentWithCurrencyEffect: number; feeInBaseCurrency: number; firstBuyDate: string; grossPerformance: number; @@ -27,16 +29,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/current-positions.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-snapshot.interface.ts similarity index 62% rename from apps/api/src/app/portfolio/interfaces/current-positions.interface.ts rename to apps/api/src/app/portfolio/interfaces/portfolio-snapshot.interface.ts index 5807d6b5e..d89734987 100644 --- a/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-snapshot.interface.ts @@ -1,9 +1,9 @@ import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces'; -import Big from 'big.js'; +import { Big } from 'big.js'; -export interface CurrentPositions extends ResponseError { - positions: TimelinePosition[]; +export interface PortfolioSnapshot extends ResponseError { + currentValueInBaseCurrency: Big; grossPerformance: Big; grossPerformanceWithCurrencyEffect: Big; grossPerformancePercentage: Big; @@ -14,6 +14,11 @@ export interface CurrentPositions extends ResponseError { netPerformanceWithCurrencyEffect: Big; netPerformancePercentage: Big; netPerformancePercentageWithCurrencyEffect: Big; - currentValue: Big; + positions: TimelinePosition[]; + totalFeesWithCurrencyEffect: Big; + totalInterestWithCurrencyEffect: Big; totalInvestment: Big; + totalInvestmentWithCurrencyEffect: Big; + totalLiabilitiesWithCurrencyEffect: Big; + totalValuablesWithCurrencyEffect: Big; } 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.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts deleted file mode 100644 index 9b76aa735..000000000 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ /dev/null @@ -1,1546 +0,0 @@ -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, - isSameDay, - subDays -} from 'date-fns'; -import { cloneDeep, first, isNumber, last, sortBy, uniq } from 'lodash'; - -import { CurrentRateService } from './current-rate.service'; -import { CurrentPositions } from './interfaces/current-positions.interface'; -import { PortfolioOrderItem } 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 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); - }); - } - - 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); - if (oldAccumulatedSymbol) { - const newQuantity = order.quantity - .mul(factor) - .plus(oldAccumulatedSymbol.quantity); - - let investment = new Big(0); - - if (newQuantity.gt(0)) { - if (order.type === 'BUY') { - 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 - }; - } 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 - }; - } - - symbols[order.symbol] = currentTransactionPointItem; - - const items = lastTransactionPoint?.items ?? []; - const newItems = items.filter( - (transactionPointItem) => transactionPointItem.symbol !== order.symbol - ); - newItems.push(currentTransactionPointItem); - newItems.sort((a, b) => { - return 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; - } - } - - 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; - } - - 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 dates: Date[] = []; - const dataGatheringItems: IDataGatheringItem[] = []; - const firstIndex = transactionPointsBeforeEndDate.length; - - let day = start; - - while (isBefore(day, end)) { - dates.push(resetHours(day)); - day = addDays(day, step); - } - - if (!isSameDay(last(dates), end)) { - dates.push(resetHours(end)); - } - - 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; - } - } - - const { dataProviderInfos, values: marketSymbols } = - await this.currentRateService.getValues({ - dataGatheringItems, - dateQuery: { - in: dates - } - }); - - 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: parseDate(this.transactionPoints?.[0]?.date), - 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; - 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, - exchangeRates: - exchangeRatesByCurrency[`${currencies[symbol]}${this.currency}`], - isChartMode: true - }); - - valuesBySymbol[symbol] = { - currentValues, - currentValuesWithCurrencyEffect, - investmentValuesAccumulated, - investmentValuesAccumulatedWithCurrencyEffect, - investmentValuesWithCurrencyEffect, - netPerformanceValues, - netPerformanceValuesWithCurrencyEffect, - timeWeightedInvestmentValues, - timeWeightedInvestmentValuesWithCurrencyEffect - }; - } - - for (const currentDate of dates) { - const dateString = format(currentDate, DATE_FORMAT); - - 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 ?? 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) - }; - } - } - - return Object.entries(accumulatedValuesByDate).map(([date, values]) => { - const { - investmentValueWithCurrencyEffect, - 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(), - totalInvestment: totalInvestmentValue.toNumber(), - totalInvestmentValueWithCurrencyEffect: - totalInvestmentValueWithCurrencyEffect.toNumber(), - value: totalCurrentValue.toNumber(), - valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber() - }; - }); - } - - public async getCurrentPositions( - start: Date, - end = new Date(Date.now()) - ): 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 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, - netPerformance, - netPerformancePercentage, - netPerformancePercentageWithCurrencyEffect, - netPerformanceWithCurrencyEffect, - timeWeightedInvestment, - timeWeightedInvestmentWithCurrencyEffect, - totalInvestment, - totalInvestmentWithCurrencyEffect - } = this.getSymbolMetrics({ - end, - marketSymbolMap, - start, - exchangeRates: - exchangeRatesByCurrency[`${item.currency}${this.currency}`], - symbol: item.symbol - }); - - hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; - - 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; - } - - 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() - })); - } - - 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.investment && - 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 'ITEM': - factor = 1; - break; - case 'SELL': - factor = -1; - break; - default: - factor = 0; - break; - } - - return factor; - } - - private getSymbolMetrics({ - 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; - symbol: string; - }): 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 totalInvestment = new Big(0); - let totalInvestmentWithCurrencyEffect = new Big(0); - let totalInvestmentWithGrossPerformanceFromSell = new Big(0); - - let totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect = new Big( - 0 - ); - - let totalUnits = new Big(0); - let valueAtStartDate: Big; - let valueAtStartDateWithCurrencyEffect: Big; - - // Clone orders to keep the original values in this.orders - let orders: PortfolioOrderItem[] = cloneDeep(this.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), - timeWeightedInvestmentValues: {}, - timeWeightedInvestmentValuesWithCurrencyEffect: {}, - timeWeightedInvestmentWithCurrencyEffect: new Big(0), - totalInvestment: new Big(0), - totalInvestmentWithCurrencyEffect: 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: {}, - 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) - }; - } - - // Add a synthetic order at the start and the end date - orders.push({ - symbol, - currency: null, - date: format(start, DATE_FORMAT), - dataSource: null, - fee: new Big(0), - feeInBaseCurrency: 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), - feeInBaseCurrency: new Big(0), - itemType: 'end', - name: '', - quantity: new Big(0), - type: TypeOfOrder.BUY, - unitPrice: unitPriceAtEndDate - }); - - let day = start; - let lastUnitPrice: Big; - - if (isChartMode) { - const datesWithOrders = {}; - - for (const order of orders) { - datesWithOrders[order.date] = true; - } - - while (isBefore(day, end)) { - 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), - feeInBaseCurrency: new Big(0), - name: '', - quantity: new Big(0), - type: TypeOfOrder.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 right - // position - orders = 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(); - }); - - const indexOfStartOrder = orders.findIndex((order) => { - return order.itemType === 'start'; - }); - - const indexOfEndOrder = orders.findIndex((order) => { - return order.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.type, order.itemType); - } - - const exchangeRateAtOrderDate = exchangeRates[order.date]; - - 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.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; - } - - const transactionInvestment = - order.type === 'BUY' - ? order.quantity - .mul(order.unitPriceInBaseCurrency) - .mul(this.getFactor(order.type)) - : totalUnits.gt(0) - ? totalInvestment - .div(totalUnits) - .mul(order.quantity) - .mul(this.getFactor(order.type)) - : new Big(0); - - const transactionInvestmentWithCurrencyEffect = - order.type === 'BUY' - ? order.quantity - .mul(order.unitPriceInBaseCurrencyWithCurrencyEffect) - .mul(this.getFactor(order.type)) - : totalUnits.gt(0) - ? totalInvestmentWithCurrencyEffect - .div(totalUnits) - .mul(order.quantity) - .mul(this.getFactor(order.type)) - : new Big(0); - - if (PortfolioCalculator.ENABLE_LOGGING) { - console.log('totalInvestment', totalInvestment.toNumber()); - - console.log( - 'totalInvestmentWithCurrencyEffect', - totalInvestmentWithCurrencyEffect.toNumber() - ); - - 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(this.getFactor(order.type)) - ); - - const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency); - - const valueOfInvestmentWithCurrencyEffect = totalUnits.mul( - order.unitPriceInBaseCurrencyWithCurrencyEffect - ); - - const grossPerformanceFromSell = - order.type === TypeOfOrder.SELL - ? order.unitPriceInBaseCurrency - .minus(lastAveragePrice) - .mul(order.quantity) - : new Big(0); - - const grossPerformanceFromSellWithCurrencyEffect = - order.type === TypeOfOrder.SELL - ? order.unitPriceInBaseCurrencyWithCurrencyEffect - .minus(lastAveragePriceWithCurrencyEffect) - .mul(order.quantity) - : new Big(0); - - grossPerformanceFromSells = grossPerformanceFromSells.plus( - grossPerformanceFromSell - ); - - grossPerformanceFromSellsWithCurrencyEffect = - grossPerformanceFromSellsWithCurrencyEffect.plus( - grossPerformanceFromSellWithCurrencyEffect - ); - - totalInvestmentWithGrossPerformanceFromSell = - totalInvestmentWithGrossPerformanceFromSell - .plus(transactionInvestment) - .plus(grossPerformanceFromSell); - - totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect = - totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect - .plus(transactionInvestmentWithCurrencyEffect) - .plus(grossPerformanceFromSellWithCurrencyEffect); - - lastAveragePrice = totalUnits.eq(0) - ? new Big(0) - : totalInvestmentWithGrossPerformanceFromSell.div(totalUnits); - - lastAveragePriceWithCurrencyEffect = totalUnits.eq(0) - ? new Big(0) - : totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect.div( - totalUnits - ); - - if (PortfolioCalculator.ENABLE_LOGGING) { - console.log( - 'totalInvestmentWithGrossPerformanceFromSell', - totalInvestmentWithGrossPerformanceFromSell.toNumber() - ); - console.log( - 'totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect', - totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect.toNumber() - ); - console.log( - 'grossPerformanceFromSells', - grossPerformanceFromSells.toNumber() - ); - console.log( - 'grossPerformanceFromSellWithCurrencyEffect', - grossPerformanceFromSellWithCurrencyEffect.toNumber() - ); - } - - const newGrossPerformance = valueOfInvestment - .minus(totalInvestment) - .plus(grossPerformanceFromSells); - - const newGrossPerformanceWithCurrencyEffect = - valueOfInvestmentWithCurrencyEffect - .minus(totalInvestmentWithCurrencyEffect) - .plus(grossPerformanceFromSellsWithCurrencyEffect); - - grossPerformance = newGrossPerformance; - - grossPerformanceWithCurrencyEffect = - newGrossPerformanceWithCurrencyEffect; - - if (order.itemType === 'start') { - feesAtStartDate = fees; - feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect; - grossPerformanceAtStartDate = grossPerformance; - - grossPerformanceAtStartDateWithCurrencyEffect = - grossPerformanceWithCurrencyEffect; - } - - if (i > indexOfStartOrder) { - // Only consider periods with an investment for the calculation of - // the time weighted investment - if (valueOfInvestmentBeforeTransaction.gt(0)) { - // 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 - ); - - // 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 = 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 - )} - 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)}%` - ); - } - - return { - currentValues, - currentValuesWithCurrencyEffect, - grossPerformancePercentage, - grossPerformancePercentageWithCurrencyEffect, - initialValue, - initialValueWithCurrencyEffect, - investmentValuesAccumulated, - investmentValuesAccumulatedWithCurrencyEffect, - investmentValuesWithCurrencyEffect, - netPerformancePercentage, - netPerformancePercentageWithCurrencyEffect, - netPerformanceValues, - netPerformanceValuesWithCurrencyEffect, - timeWeightedInvestmentValues, - timeWeightedInvestmentValuesWithCurrencyEffect, - totalInvestment, - totalInvestmentWithCurrencyEffect, - 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/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 8f4acc060..56c0a231c 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -1,16 +1,19 @@ 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 @@ -18,6 +21,7 @@ import { import { PortfolioDetails, PortfolioDividends, + PortfolioHoldingsResponse, PortfolioInvestments, PortfolioPerformanceResponse, PortfolioPublicDetails, @@ -43,7 +47,7 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import Big from 'big.js'; +import { Big } from 'big.js'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; @@ -57,6 +61,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 @@ -71,8 +77,11 @@ export class PortfolioController { @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, @Query('range') dateRange: DateRange = 'max', - @Query('tags') filterByTags?: string + @Query('tags') filterByTags?: string, + @Query('withMarkets') withMarketsParam = 'false' ): Promise { + const withMarkets = withMarketsParam === 'true'; + let hasDetails = true; let hasError = false; const hasReadRestrictedAccessPermission = @@ -91,21 +100,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 - }); + 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; @@ -160,19 +163,21 @@ export class PortfolioController { 'currentGrossPerformanceWithCurrencyEffect', 'currentNetPerformance', 'currentNetPerformanceWithCurrencyEffect', + 'currentNetWorth', 'currentValue', - 'dividend', + 'dividendInBaseCurrency', 'emergencyFund', 'excludedAccountsAndActivities', 'fees', + 'filteredValueInBaseCurrency', 'fireWealth', 'interest', 'items', 'liabilities', - 'netWorth', 'totalBuy', 'totalInvestment', - 'totalSell' + 'totalSell', + 'totalValueInBaseCurrency' ]); } @@ -199,12 +204,9 @@ export class PortfolioController { return { accounts, - filteredValueInBaseCurrency, - filteredValueInPercentage, hasError, holdings, platforms, - totalValueInBaseCurrency, summary: portfolioSummary }; } @@ -231,11 +233,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 ( @@ -265,6 +280,35 @@ 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('tags') filterByTags?: string + ): Promise { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByHoldingType, + filterBySearchQuery, + filterByTags + }); + + const { holdings } = await this.portfolioService.getDetails({ + filters, + impersonationId, + userId: this.request.user.id + }); + + return { holdings: Object.values(holdings) }; + } + @Get('investments') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getInvestments( @@ -342,9 +386,10 @@ export class PortfolioController { @Query('assetClasses') filterByAssetClasses?: string, @Query('range') dateRange: DateRange = 'max', @Query('tags') filterByTags?: string, - @Query('withExcludedAccounts') withExcludedAccounts = false, - @Query('withItems') withItems = false + @Query('withExcludedAccounts') withExcludedAccountsParam = 'false' ): Promise { + const withExcludedAccounts = withExcludedAccountsParam === 'true'; + const hasReadRestrictedAccessPermission = this.userService.hasReadRestrictedAccessPermission({ impersonationId, @@ -362,7 +407,6 @@ export class PortfolioController { filters, impersonationId, withExcludedAccounts, - withItems, userId: this.request.user.id }); @@ -375,6 +419,7 @@ export class PortfolioController { ({ date, netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, netWorth, totalInvestment, value @@ -382,6 +427,7 @@ export class PortfolioController { return { date, netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, netWorthInPercentage: performanceInformation.performance.currentNetWorth === 0 ? 0 @@ -485,10 +531,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..6b06bf02d 100644 --- a/apps/api/src/app/portfolio/portfolio.module.ts +++ b/apps/api/src/app/portfolio/portfolio.module.ts @@ -15,6 +15,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'; @@ -41,6 +42,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 63% rename from apps/api/src/app/portfolio/portfolio-calculator.spec.ts rename to apps/api/src/app/portfolio/portfolio.service.spec.ts index a59b877ab..7654b7df3 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,16 +22,9 @@ 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) @@ -39,7 +33,7 @@ describe('PortfolioCalculator', () => { ).toEqual(0); expect( - portfolioCalculator + portfolioService .getAnnualizedPerformancePercent({ daysInMarket: 0, netPerformancePercent: new Big(0) @@ -51,7 +45,7 @@ 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) @@ -60,7 +54,7 @@ describe('PortfolioCalculator', () => { ).toBeCloseTo(0.729705); expect( - portfolioCalculator + portfolioService .getAnnualizedPerformancePercent({ daysInMarket: 365, // 1 year netPerformancePercent: new Big(0.05) @@ -72,7 +66,7 @@ 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) diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index f268349c3..95a68eaae 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -3,10 +3,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'; @@ -20,7 +21,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'; @@ -44,7 +44,6 @@ import type { AccountWithValue, DateRange, GroupBy, - OrderWithAccount, RequestWithUser, UserWithSettings } from '@ghostfolio/common/types'; @@ -58,11 +57,9 @@ import { 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, @@ -70,24 +67,17 @@ import { isBefore, isSameMonth, isSameYear, - isValid, - max, - min, parseISO, - set, - startOfWeek, - startOfMonth, - startOfYear, - subDays, - subYears + set } from 'date-fns'; -import { isEmpty, last, uniq, uniqBy } from 'lodash'; +import { isEmpty, isNumber, last, uniq, uniqBy } from 'lodash'; +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'); @@ -100,7 +90,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, @@ -216,28 +206,31 @@ export class PortfolioService { }; } + 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 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), @@ -249,14 +242,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; } public async getInvestments({ @@ -274,37 +260,29 @@ 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({ - currency: this.request.user.Settings.settings.baseCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + const portfolioCalculator = this.calculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: this.request.user.Settings.settings.baseCurrency }); - portfolioCalculator.setTransactionPoints(transactionPoints); - - const { items } = await this.getChart({ + const items = await portfolioCalculator.getChart({ dateRange, - impersonationId, - portfolioOrders, - transactionPoints, - userId, - userCurrency: this.request.user.Settings.settings.baseCurrency, withDataDecimation: false }); @@ -344,13 +322,17 @@ export class PortfolioService { filters, impersonationId, userId, - withExcludedAccounts = false + withExcludedAccounts = false, + withMarkets = false, + withSummary = false }: { dateRange?: DateRange; filters?: Filter[]; impersonationId: string; userId: string; withExcludedAccounts?: boolean; + withMarkets?: boolean; + withSummary?: boolean; }): Promise { userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); @@ -360,28 +342,22 @@ export class PortfolioService { (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 ); - const { orders, portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ - filters, - userId, - withExcludedAccounts - }); - - const portfolioCalculator = new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + const { activities } = await this.orderService.getOrders({ + filters, + userCurrency, + userId, + withExcludedAccounts }); - portfolioCalculator.setTransactionPoints(transactionPoints); + const portfolioCalculator = this.calculatorFactory.createCalculator({ + activities, + dateRange, + calculationType: PerformanceCalculationType.TWR, + currency: userCurrency + }); - const portfolioStart = parseDate( - transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) - ); - const startDate = this.getStartDate(dateRange, portfolioStart); - const currentPositions = - await portfolioCalculator.getCurrentPositions(startDate); + const { currentValueInBaseCurrency, hasErrors, positions } = + await portfolioCalculator.getSnapshot(); const cashDetails = await this.accountService.getCashDetails({ filters, @@ -390,18 +366,28 @@ 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 === 'CASH' && 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 || @@ -414,14 +400,12 @@ export class PortfolioService { ); } - 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 }), @@ -434,133 +418,94 @@ export class PortfolioService { } const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; - for (const position of currentPositions.positions) { + for (const position of positions) { portfolioItemsNow[position.symbol] = position; } - for (const item of currentPositions.positions) { - 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 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 - }; - - if (symbolProfile.countries.length > 0) { - for (const country of symbolProfile.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(); - } + const assetProfile = symbolProfileMap[symbol]; + const dataProviderResponse = dataProviderResponses[symbol]; - 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 - ) - .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(value) - .toNumber(); + let markets: PortfolioPosition['markets']; + let marketsAdvanced: PortfolioPosition['marketsAdvanced']; - marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY]) - .plus(value) - .toNumber(); + 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() }; } - const isFilteredByCash = filters?.some((filter) => { - return filter.type === 'ASSET_CLASS' && filter.id === 'CASH'; - }); - if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) { const cashPositions = await this.getCashPositions({ cashDetails, @@ -574,8 +519,8 @@ export class PortfolioService { } const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({ + activities, filters, - orders, portfolioItemsNow, userCurrency, userId, @@ -617,29 +562,30 @@ export class PortfolioService { }; } - const summary = await this.getSummary({ - holdings, - impersonationId, - userCurrency, - userId, - balanceInBaseCurrency: cashDetails.balanceInBaseCurrency, - emergencyFundPositionsValueInBaseCurrency: - this.getEmergencyFundPositionsValueInBaseCurrency({ - holdings - }) - }); + 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, - filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(), - filteredValueInPercentage: summary.netWorth - ? filteredValueInBaseCurrency.div(summary.netWorth).toNumber() - : 0, - hasErrors: currentPositions.hasErrors, - totalValueInBaseCurrency: summary.netWorth + summary }; } @@ -665,15 +611,14 @@ export class PortfolioService { ); }); - let tags: Tag[] = []; - if (orders.length <= 0) { return { - tags, accounts: [], averagePrice: undefined, dataProviderInfo: undefined, dividendInBaseCurrency: undefined, + dividendYieldPercent: undefined, + dividendYieldPercentWithCurrencyEffect: undefined, feeInBaseCurrency: undefined, firstBuyDate: undefined, grossPerformance: undefined, @@ -692,6 +637,7 @@ export class PortfolioService { orders: [], quantity: undefined, SymbolProfile: undefined, + tags: [], transactionCount: undefined, value: undefined }; @@ -701,43 +647,20 @@ export class PortfolioService { { dataSource: aDataSource, symbol: aSymbol } ]); - const portfolioOrders: PortfolioOrder[] = orders - .filter((order) => { - tags = tags.concat(order.tags); - - return ['BUY', 'ITEM', 'SELL'].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({ - currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + const portfolioCalculator = this.calculatorFactory.createCalculator({ + activities: orders.filter((order) => { + return ['BUY', 'DIVIDEND', 'ITEM', 'SELL'].includes(order.type); + }), + calculationType: PerformanceCalculationType.TWR, + currency: userCurrency }); - portfolioCalculator.computeTransactionPoints(); + const portfolioStart = portfolioCalculator.getStartDate(); const transactionPoints = portfolioCalculator.getTransactionPoints(); - const portfolioStart = parseDate(transactionPoints[0].date); + const { positions } = await portfolioCalculator.getSnapshot(); - const currentPositions = - await portfolioCalculator.getCurrentPositions(portfolioStart); - - const position = currentPositions.positions.find(({ symbol }) => { + const position = positions.find(({ symbol }) => { return symbol === aSymbol; }); @@ -746,10 +669,14 @@ export class PortfolioService { averagePrice, currency, dataSource, + dividendInBaseCurrency, fee, firstBuyDate, marketPrice, quantity, + tags, + timeWeightedInvestment, + timeWeightedInvestmentWithCurrencyEffect, transactionCount } = position; @@ -762,15 +689,20 @@ 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)), + netPerformancePercent: dividendInBaseCurrency.div( + timeWeightedInvestment + ) + }); + + const dividendYieldPercentWithCurrencyEffect = + this.getAnnualizedPerformancePercent({ + daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), + netPerformancePercent: dividendInBaseCurrency.div( + timeWeightedInvestmentWithCurrencyEffect + ) + }); const historicalData = await this.dataProviderService.getHistorical( [{ dataSource, symbol: aSymbol }], @@ -805,9 +737,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(); } @@ -847,6 +777,9 @@ export class PortfolioService { averagePrice: averagePrice.toNumber(), dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), + dividendYieldPercent: dividendYieldPercent.toNumber(), + dividendYieldPercentWithCurrencyEffect: + dividendYieldPercentWithCurrencyEffect.toNumber(), feeInBaseCurrency: this.exchangeRateDataService.toCurrency( fee.toNumber(), SymbolProfile.currency, @@ -918,11 +851,12 @@ export class PortfolioService { minPrice, orders, SymbolProfile, - tags, accounts: [], averagePrice: 0, dataProviderInfo: undefined, dividendInBaseCurrency: 0, + dividendYieldPercent: 0, + dividendYieldPercentWithCurrencyEffect: 0, feeInBaseCurrency: 0, firstBuyDate: undefined, grossPerformance: undefined, @@ -936,6 +870,7 @@ export class PortfolioService { netPerformancePercentWithCurrencyEffect: undefined, netPerformanceWithCurrencyEffect: undefined, quantity: 0, + tags: [], transactionCount: undefined, value: 0 }; @@ -957,35 +892,32 @@ 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({ - currency: this.request.user.Settings.settings.baseCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + const portfolioCalculator = this.calculatorFactory.createCalculator({ + activities, + dateRange, + calculationType: PerformanceCalculationType.TWR, + currency: this.request.user.Settings.settings.baseCurrency }); - 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); }); @@ -1024,7 +956,7 @@ export class PortfolioService { } return { - hasErrors: currentPositions.hasErrors, + hasErrors, positions: positions.map( ({ averagePrice, @@ -1091,15 +1023,13 @@ export class PortfolioService { filters, impersonationId, userId, - withExcludedAccounts = false, - withItems = false + withExcludedAccounts = false }: { dateRange?: DateRange; filters?: Filter[]; impersonationId: string; userId: string; withExcludedAccounts?: boolean; - withItems?: boolean; }): Promise { userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); @@ -1130,22 +1060,17 @@ export class PortfolioService { ) ); - const { portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ - filters, - userId, - withExcludedAccounts, - types: withItems ? ['BUY', 'ITEM', 'SELL'] : ['BUY', 'SELL'] - }); + const { endDate, startDate } = 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, @@ -1166,20 +1091,15 @@ export class PortfolioService { }; } - portfolioCalculator.setTransactionPoints(transactionPoints); - - const portfolioStart = min( - [ - parseDate(accountBalanceItems[0]?.date), - parseDate(transactionPoints[0]?.date) - ].filter((date) => { - return isValid(date); - }) - ); + const portfolioCalculator = this.calculatorFactory.createCalculator({ + activities, + dateRange, + calculationType: PerformanceCalculationType.TWR, + currency: userCurrency + }); - const startDate = this.getStartDate(dateRange, portfolioStart); const { - currentValue, + currentValueInBaseCurrency, errors, grossPerformance, grossPerformancePercentage, @@ -1191,7 +1111,7 @@ export class PortfolioService { netPerformancePercentageWithCurrencyEffect, netPerformanceWithCurrencyEffect, totalInvestment - } = await portfolioCalculator.getCurrentPositions(startDate); + } = await portfolioCalculator.getSnapshot(); let currentNetPerformance = netPerformance; @@ -1203,13 +1123,8 @@ export class PortfolioService { let currentNetPerformanceWithCurrencyEffect = netPerformanceWithCurrencyEffect; - const { items } = await this.getChart({ - dateRange, - impersonationId, - portfolioOrders, - transactionPoints, - userCurrency, - userId + const items = await portfolioCalculator.getChart({ + dateRange }); const itemOfToday = items.find(({ date }) => { @@ -1274,7 +1189,7 @@ export class PortfolioService { currentNetPerformancePercentWithCurrencyEffect.toNumber(), currentNetPerformanceWithCurrencyEffect: currentNetPerformanceWithCurrencyEffect.toNumber(), - currentValue: currentValue.toNumber(), + currentValue: currentValueInBaseCurrency.toNumber(), totalInvestment: totalInvestment.toNumber() } }; @@ -1285,30 +1200,21 @@ export class PortfolioService { 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 { activities } = await this.orderService.getOrders({ + userCurrency, + userId + }); - const portfolioCalculator = new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: portfolioOrders + const portfolioCalculator = this.calculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + currency: this.request.user.Settings.settings.baseCurrency }); - portfolioCalculator.setTransactionPoints(transactionPoints); + let { totalFeesWithCurrencyEffect, positions, totalInvestment } = + await portfolioCalculator.getSnapshot(); - const portfolioStart = parseDate( - transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) - ); - const currentPositions = - await portfolioCalculator.getCurrentPositions(portfolioStart); - - const positions = currentPositions.positions.filter( - (item) => !item.quantity.eq(0) - ); + positions = positions.filter((item) => !item.quantity.eq(0)); const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; @@ -1317,7 +1223,7 @@ export class PortfolioService { } const { accounts } = await this.getValueOfAccountsAndPlatforms({ - orders, + activities, portfolioItemsNow, userCurrency, userId @@ -1327,7 +1233,7 @@ export class PortfolioService { return { rules: { - accountClusterRisk: isEmpty(orders) + accountClusterRisk: isEmpty(activities) ? undefined : await this.rulesService.evaluate( [ @@ -1342,7 +1248,7 @@ export class PortfolioService { ], userSettings ), - currencyClusterRisk: isEmpty(orders) + currencyClusterRisk: isEmpty(activities) ? undefined : await this.rulesService.evaluate( [ @@ -1370,8 +1276,8 @@ export class PortfolioService { [ new FeeRatioInitialInvestment( this.exchangeRateDataService, - currentPositions.totalInvestment.toNumber(), - this.getFees({ userCurrency, activities: orders }).toNumber() + totalInvestment.toNumber(), + totalFeesWithCurrencyEffect.toNumber() ) ], userSettings @@ -1430,67 +1336,6 @@ export class PortfolioService { return cashPositions; } - private async getChart({ - dateRange = 'max', - impersonationId, - portfolioOrders, - transactionPoints, - userCurrency, - userId, - withDataDecimation = true - }: { - dateRange?: DateRange; - impersonationId: string; - portfolioOrders: PortfolioOrder[]; - transactionPoints: TransactionPoint[]; - userCurrency: string; - userId: string; - 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({ - step, - end: endDate, - start: startDate - }); - - return { - items, - isAllTimeHigh: false, - isAllTimeLow: false - }; - } - private getDividendsByGroup({ dividends, groupBy @@ -1576,33 +1421,6 @@ export class PortfolioService { return valueInBaseCurrencyOfEmergencyFundPositions.toNumber(); } - 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) - ); - } - private getInitialCashPosition({ balance, currency @@ -1618,6 +1436,7 @@ export class PortfolioService { countries: [], dataSource: undefined, dateOfFirstActivity: undefined, + dividend: 0, grossPerformance: 0, grossPerformancePercent: 0, grossPerformancePercentWithCurrencyEffect: 0, @@ -1639,50 +1458,84 @@ export class PortfolioService { }; } - 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 '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 }; } private getStreaks({ @@ -1710,15 +1563,19 @@ export class PortfolioService { 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 { @@ -1747,11 +1604,8 @@ export class PortfolioService { } } - const dividend = this.getSumOfActivityType({ - activities, - userCurrency, - activityType: 'DIVIDEND' - }).toNumber(); + const dividendInBaseCurrency = + await portfolioCalculator.getDividendInBaseCurrency(); const emergencyFund = new Big( Math.max( @@ -1760,33 +1614,16 @@ export class PortfolioService { ) ); - const fees = this.getFees({ activities, userCurrency }).toNumber(); - const firstOrderDate = activities[0]?.date; + const fees = await portfolioCalculator.getFeesInBaseCurrency(); - const interest = this.getSumOfActivityType({ - activities, - userCurrency, - activityType: 'INTEREST' - }).toNumber(); + const firstOrderDate = portfolioCalculator.getStartDate(); - const items = Object.keys(holdings) - .filter((symbol) => { - return isUUID(symbol) && holdings[symbol].dataSource === 'MANUAL'; - }) - .map((symbol) => { - return holdings[symbol].valueInBaseCurrency; - }) - .reduce( - (previous, current) => new Big(previous).plus(current), - new Big(0) - ) - .toNumber(); + const interest = await portfolioCalculator.getInterestInBaseCurrency(); - const liabilities = this.getSumOfActivityType({ - activities, - userCurrency, - activityType: 'LIABILITY' - }).toNumber(); + const liabilities = + await portfolioCalculator.getLiabilitiesInBaseCurrency(); + + const valuables = await portfolioCalculator.getValuablesInBaseCurrency(); const totalBuy = this.getSumOfActivityType({ userCurrency, @@ -1836,58 +1673,39 @@ export class PortfolioService { const netWorth = new Big(balanceInBaseCurrency) .plus(performanceInformation.performance.currentValue) - .plus(items) + .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({ + const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercent: new Big( + performanceInformation.performance.currentNetPerformancePercent + ) + })?.toNumber(); + + const annualizedPerformancePercentWithCurrencyEffect = + this.getAnnualizedPerformancePercent({ daysInMarket, netPerformancePercent: new Big( - performanceInformation.performance.currentNetPerformancePercent + performanceInformation.performance.currentNetPerformancePercentWithCurrencyEffect ) - }) - ?.toNumber(); - - const annualizedPerformancePercentWithCurrencyEffect = - new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - exchangeRateDataService: this.exchangeRateDataService, - orders: [] - }) - .getAnnualizedPerformancePercent({ - daysInMarket, - netPerformancePercent: new Big( - performanceInformation.performance.currentNetPerformancePercentWithCurrencyEffect - ) - }) - ?.toNumber(); + })?.toNumber(); return { ...performanceInformation.performance, annualizedPerformancePercent, annualizedPerformancePercentWithCurrencyEffect, cash, - dividend, excludedAccountsAndActivities, - fees, firstOrderDate, - interest, - items, - liabilities, - netWorth, totalBuy, totalSell, committedFunds: committedFunds.toNumber(), + dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), emergencyFund: { assets: emergencyFundPositionsValueInBaseCurrency, cash: emergencyFund @@ -1895,113 +1713,53 @@ export class PortfolioService { .toNumber(), total: emergencyFund.toNumber() }, + fees: fees.toNumber(), + filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(), + filteredValueInPercentage: netWorth + ? filteredValueInBaseCurrency.div(netWorth).toNumber() + : undefined, fireWealth: new Big(performanceInformation.performance.currentValue) .minus(emergencyFundPositionsValueInBaseCurrency) .toNumber(), + interest: interest.toNumber(), + items: valuables.toNumber(), + liabilities: liabilities.toNumber(), ordersCount: activities.filter(({ type }) => { return type === 'BUY' || type === 'SELL'; - }).length + }).length, + totalValueInBaseCurrency: netWorth }; } 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) - ); - } - - private async getTransactionPoints({ - filters, - includeDrafts = false, - types = ['BUY', 'ITEM', 'SELL'], - 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 ); @@ -2015,29 +1773,20 @@ export class PortfolioService { } 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'] = {}; @@ -2055,7 +1804,7 @@ export class PortfolioService { }); } else { const accountIds = uniq( - orders + activities .filter(({ accountId }) => { return accountId; }) @@ -2075,19 +1824,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, @@ -2126,13 +1866,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 += 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.ts b/apps/api/src/app/redis-cache/redis-cache.service.ts index fe3fad13a..3891cc5ab 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,7 @@ export class RedisCacheService { } public async get(key: string): Promise { - return await this.cache.get(key); + return this.cache.get(key); } public getQuoteKey({ dataSource, symbol }: UniqueAsset) { @@ -29,15 +29,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/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index 7b09ced10..d260b3aaf 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() @@ -32,7 +33,20 @@ export class UpdateUserSettingDto { @IsOptional() colorScheme?: ColorScheme; - @IsIn(['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) + @IsIn([ + '1d', + '1y', + '5y', + 'max', + 'mtd', + 'wtd', + '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..14c545192 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -135,7 +135,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..4cc60770f 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -51,13 +51,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 +78,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 +98,7 @@ export class UserService { }; }), accounts: Account, + dateOfFirstActivity: firstActivity?.date ?? new Date(), settings: { ...(Settings.settings), locale: (Settings.settings)?.locale ?? aLocale @@ -452,14 +460,15 @@ export class UserService { } 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/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..21b111395 --- /dev/null +++ b/apps/api/src/helper/portfolio.helper.ts @@ -0,0 +1,90 @@ +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': + 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..78ae918d2 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', @@ -58,7 +57,6 @@ export class RedactValuesInResponseInterceptor 'quantity', 'symbolMapping', 'totalBalanceInBaseCurrency', - 'totalValueInBaseCurrency', 'unitPrice', 'value', 'valueInBaseCurrency' 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..ba37f4e94 100644 --- a/apps/api/src/models/rule.ts +++ b/apps/api/src/models/rule.ts @@ -7,19 +7,27 @@ 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..39406e6c2 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 @@ -11,6 +11,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { positions: TimelinePosition[] ) { super(exchangeRateDataService, { + key: CurrencyClusterRiskCurrentInvestment.name, name: 'Investment' }); @@ -37,7 +38,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule { } }); - const maxValueRatio = maxItem.value / totalValue; + const maxValueRatio = maxItem?.value / totalValue || 0; if (maxValueRatio > ruleSettings.threshold) { return { @@ -52,7 +53,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 eb82be418..507e4a375 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 { @@ -48,14 +48,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.service.ts b/apps/api/src/services/data-gathering/data-gathering.service.ts index 6dccd645e..b2b0c371c 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 @@ -61,24 +63,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({ @@ -230,9 +243,15 @@ export class 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, @@ -242,6 +261,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/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts index 63d785253..ddc3d79f8 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' }; @@ -131,20 +131,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-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index 1b52ca374..9afdab1dc 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -20,7 +20,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'; @@ -204,13 +204,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); 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..99104a78d 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) { 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..a02ddb597 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,13 +443,17 @@ 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 - )}`, + )}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom} and ${DEFAULT_CURRENCY}${currencyTo}.`, '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 a87b00d95..915b2f716 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -97,7 +97,9 @@ export class SymbolProfileService { scraperConfiguration, sectors, symbol, - symbolMapping + symbolMapping, + SymbolProfileOverrides, + url }: Prisma.SymbolProfileUpdateInput & UniqueAsset) { return this.prismaService.symbolProfile.update({ data: { @@ -109,7 +111,9 @@ export class SymbolProfileService { name, scraperConfiguration, sectors, - symbolMapping + symbolMapping, + SymbolProfileOverrides, + url }, where: { dataSource_symbol: { dataSource, symbol } } }); @@ -189,9 +193,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 8d778bef4..0cd576e16 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": { "buildTarget": "client:build:development-tr" }, + "development-zh": { + "buildTarget": "client:build:development-zh" + }, "production": { "buildTarget": "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/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..537adf1d1 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[]; @@ -115,7 +116,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit { ); this.dataService - .fetchPortfolioDetails({ + .fetchPortfolioHoldings({ filters: [ { type: 'ACCOUNT', @@ -125,11 +126,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit { }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ holdings }) => { - this.holdings = []; - - for (const [symbol, holding] of Object.entries(holdings)) { - this.holdings.push(holding); - } + this.holdings = holdings; this.changeDetectorRef.markForCheck(); }); 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/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 +104,7 @@ export class AdminMarketDataComponent 'actions' ]; public filters$ = new Subject(); + public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix; public isLoading = false; public isUUID = isUUID; public placeholder = ''; @@ -108,8 +115,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 +178,9 @@ export class AdminMarketDataComponent } public ngOnInit() { + const { benchmarks } = this.dataService.fetchInfo(); + + this.benchmarks = benchmarks; this.deviceType = this.deviceService.getDeviceInfo().deviceType; } @@ -181,20 +193,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 +292,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 +328,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,24 +208,26 @@ />
} -
+ }
-
+
Name
-
- - Currency - - -
-
+ @if (assetProfile?.dataSource === 'MANUAL') { +
+ + Currency + + +
+ } +
Asset Class @@ -220,7 +240,7 @@
-
+
Asset Sub Class @@ -265,54 +285,69 @@ >
-
- - Scraper Configuration -
+ @if (assetProfile?.dataSource === 'MANUAL') { +
+ + Scraper Configuration +
+ + +
+
+
+
+ + Sectors - -
- -
-
- - Sectors - - -
-
- - Countries - + +
+
+ + Countries + + +
+ } +
+ + Url + + @if (assetProfileForm.controls['url'].value) { + + }
-
+
Note