diff --git a/.admin.cred b/.admin.cred new file mode 100644 index 000000000..53cce50db --- /dev/null +++ b/.admin.cred @@ -0,0 +1 @@ +14d4daf73eefed7da7c32ec19bc37e678be0244fb46c8f4965bfe9ece7384706ed58222ad9b96323893c1d845bc33a308e7524c2c79636062cbb095e0780cb51 \ No newline at end of file diff --git a/.env.dev b/.env.dev deleted file mode 100644 index d0c9a1576..000000000 --- a/.env.dev +++ /dev/null @@ -1,24 +0,0 @@ -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 - diff --git a/.env.example b/.env.example index e4a935626..76b0825a0 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -COMPOSE_PROJECT_NAME=ghostfolio +COMPOSE_PROJECT_NAME=ghostfolio-development # CACHE REDIS_HOST=redis @@ -10,7 +10,6 @@ POSTGRES_DB=ghostfolio-db POSTGRES_USER=user POSTGRES_PASSWORD= -# VARIOUS ACCESS_TOKEN_SALT= DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer JWT_SECRET_KEY= diff --git a/.github/workflows/docker-image-branch.yml b/.github/workflows/docker-image-branch.yml new file mode 100644 index 000000000..988332c3f --- /dev/null +++ b/.github/workflows/docker-image-branch.yml @@ -0,0 +1,47 @@ +name: Docker image CD - Branch + +on: + push: + branches: + - '*' + +jobs: + build_and_push: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v4 + with: + images: dandevaud/ghostfolio + tags: | + type=semver,pattern={{major}} + type=semver,pattern={{version}} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to DockerHub + if: github.event_name != 'pull_request' + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64,linux/arm/v7,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: dandevaud/ghostfolio:${{ github.ref_name }} + labels: ${{ steps.meta.output.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/docker-image-dev.yml b/.github/workflows/docker-image-dev.yml new file mode 100644 index 000000000..01e4d9231 --- /dev/null +++ b/.github/workflows/docker-image-dev.yml @@ -0,0 +1,47 @@ +name: Docker image CD - DEV + +on: + push: + branches: + - 'dockerpush' + +jobs: + build_and_push: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v4 + with: + images: dandevaud/ghostfolio + tags: | + type=semver,pattern={{major}} + type=semver,pattern={{version}} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to DockerHub + if: github.event_name != 'pull_request' + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64,linux/arm/v7,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: dandevaud/ghostfolio:beta + labels: ${{ steps.meta.output.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 6b4601733..66638f680 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -4,9 +4,6 @@ on: push: tags: - '*.*.*' - pull_request: - branches: - - 'main' jobs: build_and_push: @@ -19,7 +16,7 @@ jobs: id: meta uses: docker/metadata-action@v4 with: - images: ${{ vars.DOCKER_REPOSITORY || 'ghostfolio/ghostfolio' }} + images: dandevaud/ghostfolio tags: | type=semver,pattern={{major}} type=semver,pattern={{version}} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..5142e6bff --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,47 @@ +name: Docker image CD - DEV + +on: + push: + branches: + - 'main' + +jobs: + build_and_push: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v4 + with: + images: dandevaud/ghostfolio + tags: | + type=semver,pattern={{major}} + type=semver,pattern={{version}} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to DockerHub + if: github.event_name != 'pull_request' + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64,linux/arm/v7,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: dandevaud/ghostfolio:main + labels: ${{ steps.meta.output.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.husky/pre-commit b/.husky/pre-commit index b82bf440f..8592824fa 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,6 +1,2 @@ -# Run linting and stop the commit process if any errors are found -# --quiet suppresses warnings (temporary until all warnings are fixed) -npm run affected:lint --base=main --head=HEAD --parallel=2 --quiet || exit 1 - # Check formatting on modified and uncommitted files, stop the commit if issues are found -npm run format:check --uncommitted || exit 1 +npm run format:write --uncommitted || exit 1 diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 000000000..b82bf440f --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,6 @@ +# Run linting and stop the commit process if any errors are found +# --quiet suppresses warnings (temporary until all warnings are fixed) +npm run affected:lint --base=main --head=HEAD --parallel=2 --quiet || exit 1 + +# Check formatting on modified and uncommitted files, stop the commit if issues are found +npm run format:check --uncommitted || exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5909cb3a1..e4844ffa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -415,6 +415,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 2.149.0 - 2025-03-30 +### Changed + +- Restructured the resources page +- Renamed the static portfolio analysis rule from _Allocation Cluster Risk_ to _Economic Market Cluster Risk_ (Developed Markets and Emerging Markets) +- Improved the language localization for German (`de`) +- Switched the `consistent-generic-constructors` rule from `warn` to `error` in the `eslint` configuration +- Switched the `consistent-indexed-object-style` rule from `warn` to `off` in the `eslint` configuration +- Switched the `consistent-type-assertions` rule from `warn` to `error` in the `eslint` configuration +- Switched the `prefer-optional-chain` rule from `warn` to `error` in the `eslint` configuration +- Upgraded `Nx` from version `20.0.3` to `20.0.6` + +## 2.119.0 - 2024-10-26 + +### Changed + +- Switched the `consistent-type-definitions` rule from `warn` to `error` in the `eslint` configuration +- Switched the `no-empty-function` rule from `warn` to `error` in the `eslint` configuration +- Switched the `prefer-function-type` rule from `warn` to `error` in the `eslint` configuration +- Upgraded `prisma` from version `5.20.0` to `5.21.1` + +### Fixed + +- Fixed an issue with the X-axis scale of the dividend timeline on the analysis page +- Fixed an issue with the X-axis scale of the investment timeline on the analysis page +- Fixed an issue with the X-axis scale of the portfolio evolution chart on the analysis page +- Fixed an issue in the calculation of the static portfolio analysis rule: _Allocation Cluster Risk_ (Developed Markets) +- Fixed an issue in the calculation of the static portfolio analysis rule: _Allocation Cluster Risk_ (Emerging Markets) + +## 2.118.0 - 2024-10-23 + +### Added + +- Added a new static portfolio analysis rule: _Allocation Cluster Risk_ (Developed Markets) +- Added a new static portfolio analysis rule: _Allocation Cluster Risk_ (Emerging Markets) +- Added support for mutual funds in the _EOD Historical Data_ service + +### Changed + +- Improved the font colors of the chart of the holdings tab on the home page (experimental) +- Optimized the dialog sizes for mobile (full screen) +- Optimized the git-hook via `husky` to lint only affected projects before a commit +- Upgraded `angular` from version `18.1.1` to `18.2.8` +- Upgraded `Nx` from version `19.5.6` to `20.0.3` + +### Fixed + +- Fixed the warning `export was not found` in connection with `GetValuesParams` +- Quoted the password for the _Redis_ service `healthcheck` in the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`) + +## 2.117.0 - 2024-10-19 + +### Added + +- Added the logotype to the footer +- Added the data providers management to the admin control panel + +### Changed + +- Improved the backgrounds of the chart of the holdings tab on the home page (experimental) +- Improved the language localization for German (`de`) + +### Fixed + +- Fixed an issue in the carousel component for the testimonial section on the landing page + +## 2.116.0 - 2024-10-17 + +### Added + +- Extended the content of the _Self-Hosting_ section by the benchmarks concept for _Compare with..._ on the Frequently Asked Questions (FAQ) page +- Extended the content of the _Self-Hosting_ section by the benchmarks concept for _Markets_ on the Frequently Asked Questions (FAQ) page +- Set the permissions (`chmod 0700`) on `entrypoint.sh` in the `Dockerfile` + +### Changed + +- Improved the empty state in the benchmarks of the markets overview +- Disabled the text hover effect in the chart of the holdings tab on the home page (experimental) +- Improved the usability to customize the rule thresholds in the _X-ray_ section by introducing units (experimental) +- Switched to adjusted market prices (splits and dividends) in the get historical functionality of the _EOD Historical Data_ service +- Improved the language localization for German (`de`) + +### Fixed + +- Fixed the usage of the environment variable `PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY` + +## 2.115.0 - 2024-10-14 + ### Added - Added support for changing the asset profile identifier (`dataSource` and `symbol`) in the asset profile details dialog of the admin control panel (experimental) @@ -2307,9 +2394,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Introduced the lazy-loaded activities table to the position detail dialog (experimental) - Improved the font weight in the value component - Improved the language localization for Türkçe (`tr`) -- Upgraded `angular` from version `17.0.4` to `17.0.7` - Upgraded to _Inter_ 4 font family -- Upgraded `Nx` from version `17.0.2` to `17.2.5` ### Fixed diff --git a/Dockerfile b/Dockerfile index c740413ea..152b89b36 100644 --- a/Dockerfile +++ b/Dockerfile @@ -62,6 +62,7 @@ RUN apt-get update && apt-get install -y --no-install-suggests \ COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/entrypoint.sh +RUN chmod 0700 /ghostfolio/entrypoint.sh WORKDIR /ghostfolio/apps/api EXPOSE ${PORT:-3333} USER node diff --git a/apps/api/project.json b/apps/api/project.json index 4e1affb13..e5016860e 100644 --- a/apps/api/project.json +++ b/apps/api/project.json @@ -4,6 +4,7 @@ "sourceRoot": "apps/api/src", "projectType": "application", "prefix": "api", + "tags": [], "generators": {}, "targets": { "build": { @@ -60,6 +61,13 @@ "buildTarget": "api:build" } }, + "profile": { + "executor": "@nx/js:node", + "options": { + "buildTarget": "api:build", + "runtimeArgs": ["--perf-basic-prof-only-functions"] + } + }, "lint": { "executor": "@nx/eslint:lint", "options": { @@ -73,6 +81,5 @@ }, "outputs": ["{workspaceRoot}/coverage/apps/api"] } - }, - "tags": [] + } } diff --git a/apps/api/src/app/account-balance/account-balance.service.ts b/apps/api/src/app/account-balance/account-balance.service.ts index 34d98d266..5fa3ac89e 100644 --- a/apps/api/src/app/account-balance/account-balance.service.ts +++ b/apps/api/src/app/account-balance/account-balance.service.ts @@ -95,6 +95,7 @@ export class AccountBalanceService { return accountBalance; } + @LogPerformance public async getAccountBalanceItems({ filters, userCurrency, diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index a10eecac5..56186b1be 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -1,5 +1,6 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; @@ -162,6 +163,7 @@ export class AccountService { }); } + @LogPerformance public async getCashDetails({ currency, filters = [], diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 82524ef9b..46dc064a1 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -109,6 +109,23 @@ export class AdminController { this.dataGatheringService.gatherMax(); } + @HasPermission(permissions.accessAdminControl) + @Post('gather/missing') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async gatherMissing(): Promise { + const assetProfileIdentifiers = + await this.dataGatheringService.getAllActiveAssetProfileIdentifiers(); + + const promises = assetProfileIdentifiers.map(({ dataSource, symbol }) => { + return this.dataGatheringService.gatherSymbolMissingOnly({ + dataSource, + symbol + }); + }); + + await Promise.all(promises); + } + @HasPermission(permissions.accessAdminControl) @Post('gather/profile-data') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -162,7 +179,22 @@ export class AdminController { @Param('dataSource') dataSource: DataSource, @Param('symbol') symbol: string ): Promise { - this.dataGatheringService.gatherSymbol({ dataSource, symbol }); + await this.dataGatheringService.gatherSymbol({ dataSource, symbol }); + + return; + } + + @Post('gatherMissing/:dataSource/:symbol') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @HasPermission(permissions.accessAdminControl) + public async gatherSymbolMissingOnly( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + await this.dataGatheringService.gatherSymbolMissingOnly({ + dataSource, + symbol + }); return; } @@ -279,7 +311,17 @@ export class AdminController { ): Promise { return this.adminService.patchAssetProfileData( { dataSource, symbol }, - assetProfile + { + ...assetProfile, + tags: { + connect: assetProfile.tags?.map(({ id }) => { + return { id }; + }), + disconnect: assetProfile.tagsDisconnected?.map(({ id }) => ({ + id + })) + } + } ); } diff --git a/apps/api/src/app/admin/admin.module.ts b/apps/api/src/app/admin/admin.module.ts index 598b68f17..54d3b375f 100644 --- a/apps/api/src/app/admin/admin.module.ts +++ b/apps/api/src/app/admin/admin.module.ts @@ -10,6 +10,7 @@ import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-da import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; +import { SymbolProfileOverwriteModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile-overwrite.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { Module } from '@nestjs/common'; @@ -33,6 +34,7 @@ import { QueueModule } from './queue/queue.module'; PropertyModule, QueueModule, SymbolProfileModule, + SymbolProfileOverwriteModule, TransformDataSourceInRequestModule ], controllers: [AdminController], diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index deb48b7bb..a3617a84a 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -40,11 +40,11 @@ import { import { AssetClass, AssetSubClass, - DataSource, Prisma, PrismaClient, Property, - SymbolProfile + SymbolProfile, + DataSource } from '@prisma/client'; import { differenceInDays } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @@ -286,7 +286,8 @@ export class AdminService { scraperConfiguration: true, sectors: true, symbol: true, - SymbolProfileOverrides: true + SymbolProfileOverrides: true, + tags: true } }), this.prismaService.symbolProfile.count({ where }) @@ -342,7 +343,8 @@ export class AdminService { name, sectors, symbol, - SymbolProfileOverrides + SymbolProfileOverrides, + tags }) => { let countriesCount = countries ? Object.keys(countries).length : 0; @@ -405,7 +407,8 @@ export class AdminService { date: activities?.[0]?.date, isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription, - watchedByCount: _count.watchedBy + watchedByCount: _count.watchedBy, + tags }; } ) @@ -514,6 +517,7 @@ export class AdminService { holdings, isActive, name, + tags, scraperConfiguration, sectors, symbol: newSymbol, @@ -595,6 +599,7 @@ export class AdminService { sectors, symbol, symbolMapping, + tags, ...(dataSource === 'MANUAL' ? { assetClass, assetSubClass, name, url } : { @@ -783,7 +788,8 @@ export class AdminService { isActive: true, name: symbol, sectorsCount: 0, - watchedByCount: 0 + watchedByCount: 0, + tags: [] }; } ); 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 5056dccdb..8d6dfd085 100644 --- a/apps/api/src/app/admin/update-asset-profile.dto.ts +++ b/apps/api/src/app/admin/update-asset-profile.dto.ts @@ -1,6 +1,12 @@ import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; -import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; +import { + AssetClass, + AssetSubClass, + DataSource, + Prisma, + Tag +} from '@prisma/client'; import { IsArray, IsBoolean, @@ -44,6 +50,14 @@ export class UpdateAssetProfileDto { @IsString() name?: string; + @IsArray() + @IsOptional() + tags?: Tag[]; + + @IsArray() + @IsOptional() + tagsDisconnected?: Tag[]; + @IsObject() @IsOptional() scraperConfiguration?: Prisma.InputJsonObject; diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 4981645f1..200881de7 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -589,7 +589,12 @@ export class ImportService { )?.[symbol] }; - if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') { + if ( + type === 'BUY' || + type === 'DIVIDEND' || + type === 'SELL' || + type === 'STAKE' + ) { if (!assetProfile?.name) { throw new Error( `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index ebd257324..b322e82ab 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -50,33 +50,55 @@ export class OrderService { public async assignTags({ dataSource, symbol, - tags, - userId + userId, + tags }: { tags: Tag[]; userId: string } & AssetProfileIdentifier) { - const orders = await this.prismaService.order.findMany({ - where: { - userId, - SymbolProfile: { - dataSource, - symbol - } + const promis = await this.symbolProfileService.getSymbolProfiles([ + { + dataSource, + symbol + } + ]); + const symbolProfile: EnhancedSymbolProfile = promis[0]; + const result = await this.symbolProfileService.updateSymbolProfile( + { dataSource, symbol }, + { + assetClass: symbolProfile.assetClass, + assetSubClass: symbolProfile.assetSubClass, + countries: symbolProfile.countries.reduce( + (all, v) => [...all, { code: v.code, weight: v.weight }], + [] + ), + currency: symbolProfile.currency, + dataSource, + holdings: symbolProfile.holdings.reduce( + (all, v) => [ + ...all, + { name: v.name, weight: v.allocationInPercentage } + ], + [] + ), + name: symbolProfile.name, + sectors: symbolProfile.sectors.reduce( + (all, v) => [...all, { name: v.name, weight: v.weight }], + [] + ), + symbol, + tags: { + connectOrCreate: tags.map(({ id, name }) => { + return { + create: { + id, + name + }, + where: { + id + } + }; + }) + }, + url: symbolProfile.url } - }); - - await Promise.all( - orders.map(({ id }) => - this.prismaService.order.update({ - data: { - tags: { - // The set operation replaces all existing connections with the provided ones - set: tags.map((tag) => { - return { id: tag.id }; - }) - } - }, - where: { id } - }) - ) ); this.eventEmitter.emit( @@ -85,6 +107,8 @@ export class OrderService { userId }) ); + + return result; } public async createOrder( @@ -302,6 +326,7 @@ export class OrderService { }); } + @LogPerformance public async getOrders({ endDate, filters, @@ -456,13 +481,34 @@ export class OrderService { } if (filtersByTag?.length > 0) { - where.tags = { - some: { - OR: filtersByTag.map(({ id }) => { - return { id }; - }) + where.AND = [ + { + OR: [ + { + tags: { + some: { + OR: filtersByTag.map(({ id }) => { + return { + id: id + }; + }) + } + } + }, + { + SymbolProfile: { + tags: { + some: { + OR: filtersByTag.map(({ id }) => { + return { id }; + }) + } + } + } + } + ] } - }; + ]; } if (sortColumn) { @@ -494,7 +540,11 @@ export class OrderService { } }, // eslint-disable-next-line @typescript-eslint/naming-convention - SymbolProfile: true, + SymbolProfile: { + include: { + tags: true + } + }, tags: true } }), diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts index 24fe2b2f3..0c13fb180 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts @@ -1,6 +1,7 @@ import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; @@ -9,9 +10,11 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance import { Injectable } from '@nestjs/common'; +import { OrderService } from '../../order/order.service'; import { MwrPortfolioCalculator } from './mwr/portfolio-calculator'; import { PortfolioCalculator } from './portfolio-calculator'; import { RoaiPortfolioCalculator } from './roai/portfolio-calculator'; +// import { RoaiPortfolioCalculator } from './roai/portfolio-calculator'; import { RoiPortfolioCalculator } from './roi/portfolio-calculator'; import { TwrPortfolioCalculator } from './twr/portfolio-calculator'; @@ -22,9 +25,11 @@ export class PortfolioCalculatorFactory { private readonly currentRateService: CurrentRateService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly portfolioSnapshotService: PortfolioSnapshotService, - private readonly redisCacheService: RedisCacheService + private readonly redisCacheService: RedisCacheService, + private readonly orderService: OrderService ) {} + @LogPerformance public createCalculator({ accountBalanceItems = [], activities, @@ -52,7 +57,8 @@ export class PortfolioCalculatorFactory { currentRateService: this.currentRateService, exchangeRateDataService: this.exchangeRateDataService, portfolioSnapshotService: this.portfolioSnapshotService, - redisCacheService: this.redisCacheService + redisCacheService: this.redisCacheService, + orderService: this.orderService }); case PerformanceCalculationType.ROAI: @@ -66,7 +72,8 @@ export class PortfolioCalculatorFactory { currentRateService: this.currentRateService, exchangeRateDataService: this.exchangeRateDataService, portfolioSnapshotService: this.portfolioSnapshotService, - redisCacheService: this.redisCacheService + redisCacheService: this.redisCacheService, + orderService: this.orderService }); case PerformanceCalculationType.ROI: @@ -80,7 +87,8 @@ export class PortfolioCalculatorFactory { currentRateService: this.currentRateService, exchangeRateDataService: this.exchangeRateDataService, portfolioSnapshotService: this.portfolioSnapshotService, - redisCacheService: this.redisCacheService + redisCacheService: this.redisCacheService, + orderService: this.orderService }); case PerformanceCalculationType.TWR: @@ -88,13 +96,14 @@ export class PortfolioCalculatorFactory { accountBalanceItems, activities, currency, - filters, + currentRateService: this.currentRateService, userId, configurationService: this.configurationService, - currentRateService: this.currentRateService, exchangeRateDataService: this.exchangeRateDataService, portfolioSnapshotService: this.portfolioSnapshotService, - redisCacheService: this.redisCacheService + redisCacheService: this.redisCacheService, + orderService: this.orderService, + filters }); default: diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 99aeb42f8..d576f642b 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -41,6 +41,7 @@ import { Logger } from '@nestjs/common'; import { Big } from 'big.js'; import { plainToClass } from 'class-transformer'; import { + addDays, differenceInDays, eachDayOfInterval, endOfDay, @@ -52,26 +53,32 @@ import { } from 'date-fns'; import { isNumber, sortBy, sum, uniqBy } from 'lodash'; +import { OrderService } from '../../order/order.service'; + export abstract class PortfolioCalculator { protected static readonly ENABLE_LOGGING = false; protected accountBalanceItems: HistoricalDataItem[]; protected activities: PortfolioOrder[]; - private configurationService: ConfigurationService; - private currency: string; - private currentRateService: CurrentRateService; + protected configurationService: ConfigurationService; + protected currency: string; + protected currentRateService: CurrentRateService; + protected exchangeRateDataService: ExchangeRateDataService; + protected orderService: OrderService; + protected snapshot: PortfolioSnapshot; + protected snapshotPromise: Promise; + protected userId: string; + protected marketMap: { [date: string]: { [symbol: string]: Big } } = {}; private dataProviderInfos: DataProviderInfo[]; private endDate: Date; - private exchangeRateDataService: ExchangeRateDataService; private filters: Filter[]; private portfolioSnapshotService: PortfolioSnapshotService; private redisCacheService: RedisCacheService; - private snapshot: PortfolioSnapshot; - private snapshotPromise: Promise; private startDate: Date; private transactionPoints: TransactionPoint[]; - private userId: string; + private holdings: { [date: string]: { [symbol: string]: Big } } = {}; + private holdingCurrencies: { [symbol: string]: string } = {}; public constructor({ accountBalanceItems, @@ -83,7 +90,8 @@ export abstract class PortfolioCalculator { filters, portfolioSnapshotService, redisCacheService, - userId + userId, + orderService }: { accountBalanceItems: HistoricalDataItem[]; activities: Activity[]; @@ -95,6 +103,7 @@ export abstract class PortfolioCalculator { portfolioSnapshotService: PortfolioSnapshotService; redisCacheService: RedisCacheService; userId: string; + orderService: OrderService; }) { this.accountBalanceItems = accountBalanceItems; this.configurationService = configurationService; @@ -102,6 +111,7 @@ export abstract class PortfolioCalculator { this.currentRateService = currentRateService; this.exchangeRateDataService = exchangeRateDataService; this.filters = filters; + this.orderService = orderService; let dateOfFirstActivity = new Date(); @@ -162,10 +172,6 @@ export abstract class PortfolioCalculator { this.snapshotPromise = this.initialize(); } - protected abstract calculateOverallPerformance( - positions: TimelinePosition[] - ): PortfolioSnapshot; - @LogPerformance public async computeSnapshot(): Promise { const lastTransactionPoint = this.transactionPoints.at(-1); @@ -203,10 +209,7 @@ export abstract class PortfolioCalculator { for (const { currency, dataSource, symbol } of transactionPoints[ firstIndex - 1 ].items) { - dataGatheringItems.push({ - dataSource, - symbol - }); + dataGatheringItems.push({ dataSource, symbol }); currencies[symbol] = currency; } @@ -235,17 +238,12 @@ export abstract class PortfolioCalculator { values: marketSymbols } = await this.currentRateService.getValues({ dataGatheringItems, - dateQuery: { - gte: this.startDate, - lt: this.endDate - } + dateQuery: { gte: this.startDate, lt: this.endDate } }); this.dataProviderInfos = dataProviderInfos; - const marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; - } = {}; + const marketSymbolMap: { [date: string]: { [symbol: string]: Big } } = {}; for (const marketSymbol of marketSymbols) { const date = format(marketSymbol.date, DATE_FORMAT); @@ -559,56 +557,9 @@ export abstract class PortfolioCalculator { } } - const historicalData: HistoricalDataItem[] = Object.entries( + const historicalData: HistoricalDataItem[] = this.getHistoricalDataItems( accumulatedValuesByDate - ).map(([date, values]) => { - const { - investmentValueWithCurrencyEffect, - totalAccountBalanceWithCurrencyEffect, - totalCurrentValue, - totalCurrentValueWithCurrencyEffect, - totalInvestmentValue, - totalInvestmentValueWithCurrencyEffect, - totalNetPerformanceValue, - totalNetPerformanceValueWithCurrencyEffect, - totalTimeWeightedInvestmentValue, - totalTimeWeightedInvestmentValueWithCurrencyEffect - } = values; - - const netPerformanceInPercentage = totalTimeWeightedInvestmentValue.eq(0) - ? 0 - : totalNetPerformanceValue - .div(totalTimeWeightedInvestmentValue) - .toNumber(); - - const netPerformanceInPercentageWithCurrencyEffect = - totalTimeWeightedInvestmentValueWithCurrencyEffect.eq(0) - ? 0 - : totalNetPerformanceValueWithCurrencyEffect - .div(totalTimeWeightedInvestmentValueWithCurrencyEffect) - .toNumber(); - - return { - date, - netPerformanceInPercentage, - netPerformanceInPercentageWithCurrencyEffect, - investmentValueWithCurrencyEffect: - investmentValueWithCurrencyEffect.toNumber(), - netPerformance: totalNetPerformanceValue.toNumber(), - netPerformanceWithCurrencyEffect: - totalNetPerformanceValueWithCurrencyEffect.toNumber(), - // TODO: Add valuables - netWorth: totalCurrentValueWithCurrencyEffect - .plus(totalAccountBalanceWithCurrencyEffect) - .toNumber(), - totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(), - totalInvestment: totalInvestmentValue.toNumber(), - totalInvestmentValueWithCurrencyEffect: - totalInvestmentValueWithCurrencyEffect.toNumber(), - value: totalCurrentValue.toNumber(), - valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber() - }; - }); + ); const overall = this.calculateOverallPerformance(positions); @@ -624,12 +575,80 @@ export abstract class PortfolioCalculator { }; } - protected abstract getPerformanceCalculationType(): PerformanceCalculationType; + @LogPerformance + public async getUnfilteredNetWorth(currency: string): Promise { + const activities = await this.orderService.getOrders({ + userId: this.userId, + userCurrency: currency, + types: ['BUY', 'SELL', 'STAKE'], + withExcludedAccounts: true + }); + const orders = this.activitiesToPortfolioOrder(activities.activities); + const start = orders.reduce( + (date, order) => + parseDate(date.date).getTime() < parseDate(order.date).getTime() + ? date + : order, + { date: orders[0].date } + ).date; + + const end = new Date(Date.now()); + + const holdings = await this.getHoldings(orders, parseDate(start), end); + const marketMap = await this.currentRateService.getValues({ + dataGatheringItems: this.mapToDataGatheringItems(orders), + dateQuery: { in: [end] } + }); + const endString = format(end, DATE_FORMAT); + const exchangeRates = await Promise.all( + Object.keys(holdings[endString]).map(async (holding) => { + const symbolCurrency = this.getCurrencyFromActivities(orders, holding); + const exchangeRate = + await this.exchangeRateDataService.toCurrencyAtDate( + 1, + symbolCurrency, + this.currency, + end + ); + return { symbolCurrency, exchangeRate }; + }) + ); + const currencyRates = exchangeRates.reduce<{ [currency: string]: number }>( + (all, currency): { [currency: string]: number } => { + all[currency.symbolCurrency] ??= currency.exchangeRate; + return all; + }, + {} + ); + + return Object.keys(holdings[endString]).reduce((sum, holding) => { + if (!holdings[endString][holding].toNumber()) { + return sum; + } + const symbol = marketMap.values.find((m) => m.symbol === holding); + if (symbol?.marketPrice === undefined) { + Logger.warn( + `Missing historical market data for ${holding} (${end})`, + 'PortfolioCalculator' + ); + return sum; + } else { + const symbolCurrency = this.getCurrency(holding); + const price = new Big(currencyRates[symbolCurrency]).mul( + symbol.marketPrice + ); + return sum.plus(new Big(price).mul(holdings[endString][holding])); + } + }, new Big(0)); + } + + @LogPerformance public getDataProviderInfos() { return this.dataProviderInfos; } + @LogPerformance public async getDividendInBaseCurrency() { await this.snapshotPromise; @@ -640,18 +659,21 @@ export abstract class PortfolioCalculator { ); } + @LogPerformance public async getFeesInBaseCurrency() { await this.snapshotPromise; return this.snapshot.totalFeesWithCurrencyEffect; } + @LogPerformance public async getInterestInBaseCurrency() { await this.snapshotPromise; return this.snapshot.totalInterestWithCurrencyEffect; } + @LogPerformance public getInvestments(): { date: string; investment: Big }[] { if (this.transactionPoints.length === 0) { return []; @@ -669,6 +691,7 @@ export abstract class PortfolioCalculator { }); } + @LogPerformance public getInvestmentsByGroup({ data, groupBy @@ -692,13 +715,24 @@ export abstract class PortfolioCalculator { })); } + @LogPerformance public async getLiabilitiesInBaseCurrency() { await this.snapshotPromise; return this.snapshot.totalLiabilitiesWithCurrencyEffect; } - public async getPerformance({ end, start }) { + @LogPerformance + public async getPerformance({ end, start }): Promise<{ + chart: HistoricalDataItem[]; + netPerformance: number; + netPerformanceInPercentage: number; + netPerformanceWithCurrencyEffect: number; + netPerformanceInPercentageWithCurrencyEffect: number; + netWorth: number; + totalInvestment: number; + valueWithCurrencyEffect: number; + }> { await this.snapshotPromise; const { historicalData } = this.snapshot; @@ -707,6 +741,10 @@ export abstract class PortfolioCalculator { let netPerformanceAtStartDate: number; let netPerformanceWithCurrencyEffectAtStartDate: number; + let lastTimeWeightedPerformancePercentage: number; + let lastTimeWeightedPerformancePercentageWithCurrencyEffect: number; + let timeWeightedPerformanceInPercentage: number; + let timeWeightedPerformanceInPercentageWithCurrencyEffect: number; const totalInvestmentValuesWithCurrencyEffect: number[] = []; for (const historicalDataItem of historicalData) { @@ -739,6 +777,19 @@ export abstract class PortfolioCalculator { totalInvestmentValuesWithCurrencyEffect.length : 0; + ({ + timeWeightedPerformanceInPercentage, + timeWeightedPerformanceInPercentageWithCurrencyEffect, + lastTimeWeightedPerformancePercentage, + lastTimeWeightedPerformancePercentageWithCurrencyEffect + } = this.calculateTimeWeightedPerformance( + lastTimeWeightedPerformancePercentage, + historicalDataItem, + lastTimeWeightedPerformancePercentageWithCurrencyEffect, + timeWeightedPerformanceInPercentage, + timeWeightedPerformanceInPercentageWithCurrencyEffect + )); + chart.push({ ...historicalDataItem, netPerformance: @@ -753,7 +804,9 @@ export abstract class PortfolioCalculator { timeWeightedInvestmentValue === 0 ? 0 : netPerformanceWithCurrencyEffectSinceStartDate / - timeWeightedInvestmentValue + timeWeightedInvestmentValue, + timeWeightedPerformanceInPercentage, + timeWeightedPerformanceInPercentageWithCurrencyEffect // TODO: Add net worth with valuables // netWorth: totalCurrentValueWithCurrencyEffect // .plus(totalAccountBalanceWithCurrencyEffect) @@ -763,138 +816,138 @@ export abstract class PortfolioCalculator { } } - return { chart }; - } + const last = chart.at(-1); - public async getSnapshot() { - await this.snapshotPromise; - - return this.snapshot; + return { + chart, + netPerformance: last?.netPerformance ?? 0, + netPerformanceInPercentage: last?.netPerformanceInPercentage ?? 0, + netPerformanceWithCurrencyEffect: + last?.netPerformanceWithCurrencyEffect ?? 0, + netPerformanceInPercentageWithCurrencyEffect: + last?.netPerformanceInPercentageWithCurrencyEffect ?? 0, + netWorth: last?.netWorth ?? 0, + totalInvestment: last?.totalInvestment ?? 0, + valueWithCurrencyEffect: last?.valueWithCurrencyEffect ?? 0 + }; } - public getStartDate() { - let firstAccountBalanceDate: Date; - let firstActivityDate: Date; - - try { - const firstAccountBalanceDateString = this.accountBalanceItems[0]?.date; - firstAccountBalanceDate = firstAccountBalanceDateString - ? parseDate(firstAccountBalanceDateString) - : new Date(); - } catch (error) { - firstAccountBalanceDate = new Date(); - } + @LogPerformance + protected getHistoricalDataItems(accumulatedValuesByDate: { + [date: string]: { + investmentValueWithCurrencyEffect: Big; + totalAccountBalanceWithCurrencyEffect: Big; + totalCurrentValue: Big; + totalCurrentValueWithCurrencyEffect: Big; + totalInvestmentValue: Big; + totalInvestmentValueWithCurrencyEffect: Big; + totalNetPerformanceValue: Big; + totalNetPerformanceValueWithCurrencyEffect: Big; + totalTimeWeightedInvestmentValue: Big; + totalTimeWeightedInvestmentValueWithCurrencyEffect: Big; + }; + }): HistoricalDataItem[] { + let previousDateString = ''; + let timeWeightedPerformancePreviousPeriod = new Big(0); + let timeWeightedPerformancePreviousPeriodWithCurrencyEffect = new Big(0); + return Object.entries(accumulatedValuesByDate).map(([date, values]) => { + const { + investmentValueWithCurrencyEffect, + totalAccountBalanceWithCurrencyEffect, + totalCurrentValue, + totalCurrentValueWithCurrencyEffect, + totalInvestmentValue, + totalInvestmentValueWithCurrencyEffect, + totalNetPerformanceValue, + totalNetPerformanceValueWithCurrencyEffect, + totalTimeWeightedInvestmentValue, + totalTimeWeightedInvestmentValueWithCurrencyEffect + } = values; - try { - const firstActivityDateString = this.transactionPoints[0].date; - firstActivityDate = firstActivityDateString - ? parseDate(firstActivityDateString) - : new Date(); - } catch (error) { - firstActivityDate = new Date(); - } + const netPerformanceInPercentage = totalTimeWeightedInvestmentValue.eq(0) + ? 0 + : totalNetPerformanceValue + .div(totalTimeWeightedInvestmentValue) + .toNumber(); - return min([firstAccountBalanceDate, firstActivityDate]); - } + const netPerformanceInPercentageWithCurrencyEffect = + totalTimeWeightedInvestmentValueWithCurrencyEffect.eq(0) + ? 0 + : totalNetPerformanceValueWithCurrencyEffect + .div(totalTimeWeightedInvestmentValueWithCurrencyEffect) + .toNumber(); - protected abstract getSymbolMetrics({ - chartDateMap, - dataSource, - end, - exchangeRates, - marketSymbolMap, - start, - symbol - }: { - chartDateMap: { [date: string]: boolean }; - end: Date; - exchangeRates: { [dateString: string]: number }; - marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; - }; - start: Date; - } & AssetProfileIdentifier): SymbolMetrics; + let timeWeightedPerformanceInPercentage: number; + let timeWeightedPerformanceInPercentageWithCurrencyEffect: number; + ({ + timeWeightedPerformanceInPercentage, + timeWeightedPerformanceInPercentageWithCurrencyEffect, + previousDateString, + timeWeightedPerformancePreviousPeriod, + timeWeightedPerformancePreviousPeriodWithCurrencyEffect + } = this.handleTimeWeightedPerformance( + accumulatedValuesByDate, + previousDateString, + totalNetPerformanceValue, + totalNetPerformanceValueWithCurrencyEffect, + timeWeightedPerformancePreviousPeriod, + timeWeightedPerformancePreviousPeriodWithCurrencyEffect, + date + )); - public getTransactionPoints() { - return this.transactionPoints; + return { + date, + netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, + investmentValueWithCurrencyEffect: + investmentValueWithCurrencyEffect.toNumber(), + netPerformance: totalNetPerformanceValue.toNumber(), + netPerformanceWithCurrencyEffect: + totalNetPerformanceValueWithCurrencyEffect.toNumber(), + // TODO: Add valuables + netWorth: totalCurrentValueWithCurrencyEffect + .plus(totalAccountBalanceWithCurrencyEffect) + .toNumber(), + totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(), + totalInvestment: totalInvestmentValue.toNumber(), + totalInvestmentValueWithCurrencyEffect: + totalInvestmentValueWithCurrencyEffect.toNumber(), + value: totalCurrentValue.toNumber(), + valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber(), + timeWeightedPerformanceInPercentage, + timeWeightedPerformanceInPercentageWithCurrencyEffect + }; + }); } - public async getValuablesInBaseCurrency() { + @LogPerformance + public async getSnapshot() { await this.snapshotPromise; - return this.snapshot.totalValuablesWithCurrencyEffect; + return this.snapshot; } - private getChartDateMap({ - endDate, - startDate, - step - }: { - endDate: Date; - startDate: Date; - step: number; - }): { [date: string]: true } { - // Create a map of all relevant chart dates: - // 1. Add transaction point dates - const chartDateMap = this.transactionPoints.reduce((result, { date }) => { - result[date] = true; - return result; - }, {}); - - // 2. Add dates between transactions respecting the specified step size - for (const date of eachDayOfInterval( - { end: endDate, start: startDate }, - { step } - )) { - chartDateMap[format(date, DATE_FORMAT)] = true; - } - - if (step > 1) { - // Reduce the step size of last 90 days - for (const date of eachDayOfInterval( - { end: endDate, start: subDays(endDate, 90) }, - { step: 3 } - )) { - chartDateMap[format(date, DATE_FORMAT)] = true; - } - - // Reduce the step size of last 30 days - for (const date of eachDayOfInterval( - { end: endDate, start: subDays(endDate, 30) }, - { step: 1 } - )) { - chartDateMap[format(date, DATE_FORMAT)] = true; - } - } - - // Make sure the end date is present - chartDateMap[format(endDate, DATE_FORMAT)] = true; - - // Make sure some key dates are present - for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) { - const { endDate: dateRangeEnd, startDate: dateRangeStart } = - getIntervalFromDateRange(dateRange); - - if ( - !isBefore(dateRangeStart, startDate) && - !isAfter(dateRangeStart, endDate) - ) { - chartDateMap[format(dateRangeStart, DATE_FORMAT)] = true; - } + @LogPerformance + protected getCurrency(symbol: string) { + return this.getCurrencyFromActivities(this.activities, symbol); + } - if ( - !isBefore(dateRangeEnd, startDate) && - !isAfter(dateRangeEnd, endDate) - ) { - chartDateMap[format(dateRangeEnd, DATE_FORMAT)] = true; - } + @LogPerformance + protected getCurrencyFromActivities( + activities: PortfolioOrder[], + symbol: string + ) { + if (!this.holdingCurrencies[symbol]) { + this.holdingCurrencies[symbol] = activities.find( + (a) => a.SymbolProfile.symbol === symbol + ).SymbolProfile.currency; } - return chartDateMap; + return this.holdingCurrencies[symbol]; } @LogPerformance - private computeTransactionPoints() { + protected computeTransactionPoints() { this.transactionPoints = []; const symbols: { [symbol: string]: TransactionPointSymbol } = {}; @@ -1033,7 +1086,7 @@ export abstract class PortfolioCalculator { } @LogPerformance - private async initialize() { + protected async initialize() { const startTimeTotal = performance.now(); let cachedPortfolioSnapshot: PortfolioSnapshot; @@ -1115,4 +1168,416 @@ export abstract class PortfolioCalculator { await this.initialize(); } } + + @LogPerformance + protected activitiesToPortfolioOrder( + activities: Activity[] + ): PortfolioOrder[] { + return activities + .map( + ({ + date, + fee, + quantity, + SymbolProfile, + tags = [], + type, + unitPrice + }) => { + if (isAfter(date, new Date(Date.now()))) { + // Adapt date to today if activity is in future (e.g. liability) + // to include it in the interval + date = endOfDay(new Date(Date.now())); + } + + return { + SymbolProfile, + tags, + type, + date: format(date, DATE_FORMAT), + fee: new Big(fee), + quantity: new Big(quantity), + unitPrice: new Big(unitPrice) + }; + } + ) + .sort((a, b) => { + return a.date?.localeCompare(b.date); + }); + } + + @LogPerformance + protected async getHoldings( + activities: PortfolioOrder[], + start: Date, + end: Date + ) { + if ( + this.holdings && + Object.keys(this.holdings).some((h) => + isAfter(parseDate(h), subDays(end, 1)) + ) && + Object.keys(this.holdings).some((h) => + isBefore(parseDate(h), addDays(start, 1)) + ) + ) { + return this.holdings; + } + + this.computeHoldings(activities, start, end); + return this.holdings; + } + + @LogPerformance + protected async computeHoldings( + activities: PortfolioOrder[], + start: Date, + end: Date + ) { + const investmentByDate = this.getInvestmentByDate(activities); + this.calculateHoldings(investmentByDate, start, end); + } + + @LogPerformance + protected calculateInitialHoldings( + investmentByDate: { [date: string]: PortfolioOrder[] }, + start: Date, + currentHoldings: { [date: string]: { [symbol: string]: Big } } + ) { + const preRangeTrades = Object.keys(investmentByDate) + .filter((date) => resetHours(new Date(date)) <= start) + .map((date) => investmentByDate[date]) + .reduce((a, b) => a.concat(b), []) + .reduce((groupBySymbol, trade) => { + if (!groupBySymbol[trade.SymbolProfile.symbol]) { + groupBySymbol[trade.SymbolProfile.symbol] = []; + } + + groupBySymbol[trade.SymbolProfile.symbol].push(trade); + + return groupBySymbol; + }, {}); + + currentHoldings[format(start, DATE_FORMAT)] = {}; + + for (const symbol of Object.keys(preRangeTrades)) { + const trades: PortfolioOrder[] = preRangeTrades[symbol]; + const startQuantity = trades.reduce((sum, trade) => { + return sum.plus(trade.quantity.mul(getFactor(trade.type))); + }, new Big(0)); + currentHoldings[format(start, DATE_FORMAT)][symbol] = startQuantity; + } + } + + @LogPerformance + protected getInvestmentByDate(activities: PortfolioOrder[]): { + [date: string]: PortfolioOrder[]; + } { + return activities.reduce((groupedByDate, order) => { + if (!groupedByDate[order.date]) { + groupedByDate[order.date] = []; + } + + groupedByDate[order.date].push(order); + + return groupedByDate; + }, {}); + } + + @LogPerformance + protected mapToDataGatheringItems( + orders: PortfolioOrder[] + ): IDataGatheringItem[] { + return orders + .map((activity) => { + return { + symbol: activity.SymbolProfile.symbol, + dataSource: activity.SymbolProfile.dataSource + }; + }) + .filter( + (gathering, i, arr) => + arr.findIndex((t) => t.symbol === gathering.symbol) === i + ); + } + + public getStartDate() { + let firstAccountBalanceDate: Date; + let firstActivityDate: Date; + + try { + const firstAccountBalanceDateString = this.accountBalanceItems[0]?.date; + firstAccountBalanceDate = firstAccountBalanceDateString + ? parseDate(firstAccountBalanceDateString) + : new Date(); + } catch (error) { + firstAccountBalanceDate = new Date(); + } + + try { + const firstActivityDateString = this.transactionPoints[0].date; + firstActivityDate = firstActivityDateString + ? parseDate(firstActivityDateString) + : new Date(); + } catch (error) { + firstActivityDate = new Date(); + } + + return min([firstAccountBalanceDate, firstActivityDate]); + } + + public getTransactionPoints() { + return this.transactionPoints; + } + + public async getValuablesInBaseCurrency() { + await this.snapshotPromise; + + return this.snapshot.totalValuablesWithCurrencyEffect; + } + private calculateTimeWeightedPerformance( + lastTimeWeightedPerformancePercentage: number, + historicalDataItem: HistoricalDataItem, + lastTimeWeightedPerformancePercentageWithCurrencyEffect: number, + timeWeightedPerformanceInPercentage: number, + timeWeightedPerformanceInPercentageWithCurrencyEffect: number + ): { + timeWeightedPerformanceInPercentage: number; + timeWeightedPerformanceInPercentageWithCurrencyEffect: number; + lastTimeWeightedPerformancePercentage: number; + lastTimeWeightedPerformancePercentageWithCurrencyEffect: number; + } { + timeWeightedPerformanceInPercentage = lastTimeWeightedPerformancePercentage + ? (1 + timeWeightedPerformanceInPercentage) * + ((1 + historicalDataItem.timeWeightedPerformanceInPercentage) / + (1 + lastTimeWeightedPerformancePercentage)) - + 1 + : 0; + timeWeightedPerformanceInPercentageWithCurrencyEffect = + lastTimeWeightedPerformancePercentageWithCurrencyEffect + ? (1 + timeWeightedPerformanceInPercentageWithCurrencyEffect) * + ((1 + + historicalDataItem.timeWeightedPerformanceInPercentageWithCurrencyEffect) / + (1 + lastTimeWeightedPerformancePercentageWithCurrencyEffect)) - + 1 + : 0; + return { + timeWeightedPerformanceInPercentage, + timeWeightedPerformanceInPercentageWithCurrencyEffect, + lastTimeWeightedPerformancePercentage: + historicalDataItem.timeWeightedPerformanceInPercentage, + lastTimeWeightedPerformancePercentageWithCurrencyEffect: + historicalDataItem.timeWeightedPerformanceInPercentageWithCurrencyEffect + }; + } + + private calculateHoldings( + investmentByDate: { [date: string]: PortfolioOrder[] }, + start: Date, + end: Date + ) { + const transactionDates = Object.keys(investmentByDate).sort(); + const dates = eachDayOfInterval({ start, end }, { step: 1 }) + .map((date) => { + return resetHours(date); + }) + .sort((a, b) => a.getTime() - b.getTime()); + const currentHoldings: { [date: string]: { [symbol: string]: Big } } = {}; + + this.calculateInitialHoldings(investmentByDate, start, currentHoldings); + + for (let i = 1; i < dates.length; i++) { + const dateString = format(dates[i], DATE_FORMAT); + const previousDateString = format(dates[i - 1], DATE_FORMAT); + if (transactionDates.some((d) => d === dateString)) { + const holdings = { ...currentHoldings[previousDateString] }; + investmentByDate[dateString].forEach((trade) => { + holdings[trade.SymbolProfile.symbol] ??= new Big(0); + holdings[trade.SymbolProfile.symbol] = holdings[ + trade.SymbolProfile.symbol + ].plus(trade.quantity.mul(getFactor(trade.type))); + }); + currentHoldings[dateString] = holdings; + } else { + currentHoldings[dateString] = currentHoldings[previousDateString]; + } + } + + this.holdings = currentHoldings; + } + + private getChartDateMap({ + endDate, + startDate, + step + }: { + endDate: Date; + startDate: Date; + step: number; + }): { [date: string]: true } { + // Create a map of all relevant chart dates: + // 1. Add transaction point dates + const chartDateMap = this.transactionPoints.reduce((result, { date }) => { + result[date] = true; + return result; + }, {}); + + // 2. Add dates between transactions respecting the specified step size + for (const date of eachDayOfInterval( + { end: endDate, start: startDate }, + { step } + )) { + chartDateMap[format(date, DATE_FORMAT)] = true; + } + + if (step > 1) { + // Reduce the step size of last 90 days + for (const date of eachDayOfInterval( + { end: endDate, start: subDays(endDate, 90) }, + { step: 3 } + )) { + chartDateMap[format(date, DATE_FORMAT)] = true; + } + + // Reduce the step size of last 30 days + for (const date of eachDayOfInterval( + { end: endDate, start: subDays(endDate, 30) }, + { step: 1 } + )) { + chartDateMap[format(date, DATE_FORMAT)] = true; + } + } + + // Make sure the end date is present + chartDateMap[format(endDate, DATE_FORMAT)] = true; + + // Make sure some key dates are present + for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) { + const { endDate: dateRangeEnd, startDate: dateRangeStart } = + getIntervalFromDateRange(dateRange); + + if ( + !isBefore(dateRangeStart, startDate) && + !isAfter(dateRangeStart, endDate) + ) { + chartDateMap[format(dateRangeStart, DATE_FORMAT)] = true; + } + + if ( + !isBefore(dateRangeEnd, startDate) && + !isAfter(dateRangeEnd, endDate) + ) { + chartDateMap[format(dateRangeEnd, DATE_FORMAT)] = true; + } + } + + return chartDateMap; + } + + private handleTimeWeightedPerformance( + accumulatedValuesByDate: { + [date: string]: { + investmentValueWithCurrencyEffect: Big; + totalAccountBalanceWithCurrencyEffect: Big; + totalCurrentValue: Big; + totalCurrentValueWithCurrencyEffect: Big; + totalInvestmentValue: Big; + totalInvestmentValueWithCurrencyEffect: Big; + totalNetPerformanceValue: Big; + totalNetPerformanceValueWithCurrencyEffect: Big; + totalTimeWeightedInvestmentValue: Big; + totalTimeWeightedInvestmentValueWithCurrencyEffect: Big; + }; + }, + previousDateString: string, + totalNetPerformanceValue: Big, + totalNetPerformanceValueWithCurrencyEffect: Big, + timeWeightedPerformancePreviousPeriod: Big, + timeWeightedPerformancePreviousPeriodWithCurrencyEffect: Big, + date: string + ): { + timeWeightedPerformanceInPercentage: number; + timeWeightedPerformanceInPercentageWithCurrencyEffect: number; + previousDateString: string; + timeWeightedPerformancePreviousPeriod: Big; + timeWeightedPerformancePreviousPeriodWithCurrencyEffect: Big; + } { + const previousValues = accumulatedValuesByDate[previousDateString] ?? { + totalNetPerformanceValue: new Big(0), + totalNetPerformanceValueWithCurrencyEffect: new Big(0), + totalTimeWeightedInvestmentValue: new Big(0), + totalTimeWeightedInvestmentValueWithCurrencyEffect: new Big(0), + totalCurrentValue: new Big(0), + totalCurrentValueWithCurrencyEffect: new Big(0) + }; + + const timeWeightedPerformanceCurrentPeriod = this.divideByOrZero( + (div) => + totalNetPerformanceValue + .minus(previousValues.totalNetPerformanceValue) + .div(div), + previousValues.totalCurrentValue + ); + const timeWeightedPerformanceCurrentPeriodWithCurrencyEffect = + this.divideByOrZero( + (div) => + totalNetPerformanceValueWithCurrencyEffect + .minus(previousValues.totalNetPerformanceValueWithCurrencyEffect) + .div(div), + previousValues.totalCurrentValueWithCurrencyEffect + ); + + const timeWeightedPerformanceInPercentage = new Big(1) + .plus(timeWeightedPerformancePreviousPeriod) + .mul(new Big(1).plus(timeWeightedPerformanceCurrentPeriod)) + .minus(1); + const timeWeightedPerformanceInPercentageWithCurrencyEffect = new Big(1) + .plus(timeWeightedPerformancePreviousPeriodWithCurrencyEffect) + .mul( + new Big(1).plus(timeWeightedPerformanceCurrentPeriodWithCurrencyEffect) + ) + .minus(1); + + return { + timeWeightedPerformanceInPercentage: + timeWeightedPerformanceInPercentage.toNumber(), + timeWeightedPerformanceInPercentageWithCurrencyEffect: + timeWeightedPerformanceInPercentageWithCurrencyEffect.toNumber(), + previousDateString: date, + timeWeightedPerformancePreviousPeriod: + timeWeightedPerformanceInPercentage, + timeWeightedPerformancePreviousPeriodWithCurrencyEffect: + timeWeightedPerformanceInPercentageWithCurrencyEffect + }; + } + + private divideByOrZero(fn: (big: Big) => Big, divisor: Big): Big { + if (divisor.eq(0)) { + return new Big(0); + } else { + return fn(divisor); + } + } + + protected abstract getSymbolMetrics({ + chartDateMap, + dataSource, + end, + exchangeRates, + marketSymbolMap, + start, + symbol + }: { + chartDateMap: { [date: string]: boolean }; + end: Date; + exchangeRates: { [dateString: string]: number }; + marketSymbolMap: { [date: string]: { [symbol: string]: Big } }; + start: Date; + } & AssetProfileIdentifier): SymbolMetrics; + + protected abstract calculateOverallPerformance( + positions: TimelinePosition[] + ): PortfolioSnapshot; + + protected abstract getPerformanceCalculationType(): PerformanceCalculationType; } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts index 589c2dcee..4c46ae6d1 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts @@ -77,7 +77,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); @@ -174,9 +175,7 @@ describe('PortfolioCalculator', () => { netPerformancePercentageWithCurrencyEffectMap: { max: new Big('-0.0552834149755073478') }, - netPerformanceWithCurrencyEffectMap: { - max: new Big('-15.8') - }, + netPerformanceWithCurrencyEffectMap: { max: new Big('-15.8') }, marketPrice: 148.9, marketPriceInBaseCurrency: 148.9, quantity: new Big('0'), diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts index 033f2622d..a65ee9c90 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -77,7 +77,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); @@ -161,9 +162,7 @@ describe('PortfolioCalculator', () => { netPerformancePercentageWithCurrencyEffectMap: { max: new Big('-0.0552834149755073478') }, - netPerformanceWithCurrencyEffectMap: { - max: new Big('-15.8') - }, + netPerformanceWithCurrencyEffectMap: { max: new Big('-15.8') }, marketPrice: 148.9, marketPriceInBaseCurrency: 148.9, quantity: new Big('0'), diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts index e7f95aea8..1c16ca49d 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts @@ -77,7 +77,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts index ba818eb40..e6ff2fea3 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts @@ -88,7 +88,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); @@ -135,6 +136,8 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0, netWorth: 0, + timeWeightedPerformanceInPercentage: 0, + timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, totalAccountBalance: 0, totalInvestment: 0, totalInvestmentValueWithCurrencyEffect: 0, @@ -153,6 +156,8 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412 netPerformanceWithCurrencyEffect: 5535.42, netWorth: 50098.3, // 1 * 50098.3 = 50098.3 + timeWeightedPerformanceInPercentage: 0, + timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, totalAccountBalance: 0, totalInvestment: 44558.42, totalInvestmentValueWithCurrencyEffect: 44558.42, @@ -172,6 +177,9 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712, netPerformanceWithCurrencyEffect: -1463.18, netWorth: 43099.7, + timeWeightedPerformanceInPercentage: -0.13969735500006986, + timeWeightedPerformanceInPercentageWithCurrencyEffect: + -0.13969735500006986, totalAccountBalance: 0, totalInvestment: 44558.42, totalInvestmentValueWithCurrencyEffect: 44558.42, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index acd07344e..2ad0f84bc 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -90,7 +90,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts index cf07eff97..f5199bbfa 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts @@ -1,5 +1,6 @@ import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { activityDummyData, loadActivityExportFile, @@ -60,6 +61,7 @@ describe('PortfolioCalculator', () => { let portfolioCalculatorFactory: PortfolioCalculatorFactory; let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; + let orderService: OrderService; beforeAll(() => { activityDtos = loadActivityExportFile( @@ -83,12 +85,15 @@ describe('PortfolioCalculator', () => { redisCacheService = new RedisCacheService(null, null); + orderService = new OrderService(null, null, null, null, null, null); + portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + orderService ); }); @@ -135,6 +140,8 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0, netWorth: 0, + timeWeightedPerformanceInPercentage: 0, + timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, totalAccountBalance: 0, totalInvestment: 0, totalInvestmentValueWithCurrencyEffect: 0, @@ -153,6 +160,8 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412 netPerformanceWithCurrencyEffect: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42 netWorth: 50098.3, // 1 * 50098.3 = 50098.3 + timeWeightedPerformanceInPercentage: 0, + timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, totalAccountBalance: 0, totalInvestment: 44558.42, totalInvestmentValueWithCurrencyEffect: 44558.42, @@ -172,6 +181,9 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712, netPerformanceWithCurrencyEffect: -1463.18, netWorth: 43099.7, + timeWeightedPerformanceInPercentage: -0.13969735500006986, + timeWeightedPerformanceInPercentageWithCurrencyEffect: + -0.13969735500006986, totalAccountBalance: 0, totalInvestment: 44558.42, totalInvestmentValueWithCurrencyEffect: 44558.42, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts index a8aea0841..fb8f81c41 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts @@ -77,7 +77,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts index 18e8dd5df..8ed8b4830 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts @@ -90,7 +90,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); @@ -159,9 +160,7 @@ describe('PortfolioCalculator', () => { netPerformancePercentageWithCurrencyEffectMap: { max: new Big('0.24112962014285697628') }, - netPerformanceWithCurrencyEffectMap: { - max: new Big('19.851974') - }, + netPerformanceWithCurrencyEffectMap: { max: new Big('19.851974') }, marketPrice: 116.45, marketPriceInBaseCurrency: 103.10483, quantity: new Big('1'), @@ -197,30 +196,12 @@ describe('PortfolioCalculator', () => { expect(investmentsByMonth).toEqual([ { date: '2023-01-01', investment: 82.329056 }, - { - date: '2023-02-01', - investment: 0 - }, - { - date: '2023-03-01', - investment: 0 - }, - { - date: '2023-04-01', - investment: 0 - }, - { - date: '2023-05-01', - investment: 0 - }, - { - date: '2023-06-01', - investment: 0 - }, - { - date: '2023-07-01', - investment: 0 - } + { date: '2023-02-01', investment: 0 }, + { date: '2023-03-01', investment: 0 }, + { date: '2023-04-01', investment: 0 }, + { date: '2023-05-01', investment: 0 }, + { date: '2023-06-01', investment: 0 }, + { date: '2023-07-01', investment: 0 } ]); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-item.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-item.spec.ts index 21d208d9f..47ffa7d32 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-item.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-item.spec.ts @@ -77,7 +77,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts index 2cb3899e9..50b701415 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts @@ -1,4 +1,5 @@ import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { activityDummyData, symbolProfileDummyData, @@ -55,6 +56,7 @@ describe('PortfolioCalculator', () => { let portfolioCalculatorFactory: PortfolioCalculatorFactory; let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; + let orderServiceMock: OrderService; beforeEach(() => { configurationService = new ConfigurationService(); @@ -72,12 +74,15 @@ describe('PortfolioCalculator', () => { redisCacheService = new RedisCacheService(null, null); + orderServiceMock = new OrderService(null, null, null, null, null, null); + portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + orderServiceMock ); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts index 4c1962c1b..a4095cde7 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -77,7 +77,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts index 9d5b74bb2..ad33e6b82 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts @@ -72,7 +72,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts index 92a3e33ed..9399f93dd 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -91,7 +91,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); @@ -157,9 +158,7 @@ describe('PortfolioCalculator', () => { netPerformancePercentageWithCurrencyEffectMap: { max: new Big('0.12348284960422163588') }, - netPerformanceWithCurrencyEffectMap: { - max: new Big('17.68') - }, + netPerformanceWithCurrencyEffectMap: { max: new Big('17.68') }, marketPrice: 87.8, marketPriceInBaseCurrency: 87.8, quantity: new Big('1'), diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts index 3d4760be7..cb28d7460 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -1,256 +1,264 @@ -import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; -import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; -import { - activityDummyData, - loadActivityExportFile, - symbolProfileDummyData, - userDummyData -} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; -import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; -import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; -import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; -import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; -import { parseDate } from '@ghostfolio/common/helper'; -import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; - -import { Big } from 'big.js'; -import { join } from 'path'; - -jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - CurrentRateService: jest.fn().mockImplementation(() => { - return CurrentRateServiceMock; - }) - }; -}); - -jest.mock( - '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', - () => { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - PortfolioSnapshotService: jest.fn().mockImplementation(() => { - return PortfolioSnapshotServiceMock; - }) - }; - } -); - -jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - RedisCacheService: jest.fn().mockImplementation(() => { - return RedisCacheServiceMock; - }) - }; -}); - -describe('PortfolioCalculator', () => { - let activityDtos: CreateOrderDto[]; - - let configurationService: ConfigurationService; - let currentRateService: CurrentRateService; - let exchangeRateDataService: ExchangeRateDataService; - let portfolioCalculatorFactory: PortfolioCalculatorFactory; - let portfolioSnapshotService: PortfolioSnapshotService; - let redisCacheService: RedisCacheService; - - beforeAll(() => { - activityDtos = loadActivityExportFile( - join( - __dirname, - '../../../../../../../test/import/ok-novn-buy-and-sell.json' - ) - ); - }); - - beforeEach(() => { - configurationService = new ConfigurationService(); - - currentRateService = new CurrentRateService(null, null, null, null); - - exchangeRateDataService = new ExchangeRateDataService( - null, - null, - null, - null - ); - - portfolioSnapshotService = new PortfolioSnapshotService(null); - - redisCacheService = new RedisCacheService(null, null); - - portfolioCalculatorFactory = new PortfolioCalculatorFactory( - configurationService, - currentRateService, - exchangeRateDataService, - portfolioSnapshotService, - redisCacheService - ); - }); - - describe('get current positions', () => { - it.only('with NOVN.SW buy and sell', async () => { - jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); - - const activities: Activity[] = activityDtos.map((activity) => ({ - ...activityDummyData, - ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - SymbolProfile: { - ...symbolProfileDummyData, - currency: activity.currency, - dataSource: activity.dataSource, - name: 'Novartis AG', - symbol: activity.symbol - }, - unitPriceInAssetProfileCurrency: activity.unitPrice - })); - - const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ - activities, - calculationType: PerformanceCalculationType.ROAI, - currency: 'CHF', - userId: userDummyData.id - }); - - const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); - - const investments = portfolioCalculator.getInvestments(); - - const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ - data: portfolioSnapshot.historicalData, - groupBy: 'month' - }); - - expect(portfolioSnapshot.historicalData[0]).toEqual({ - date: '2022-03-06', - investmentValueWithCurrencyEffect: 0, - netPerformance: 0, - netPerformanceInPercentage: 0, - netPerformanceInPercentageWithCurrencyEffect: 0, - netPerformanceWithCurrencyEffect: 0, - netWorth: 0, - totalAccountBalance: 0, - totalInvestment: 0, - totalInvestmentValueWithCurrencyEffect: 0, - value: 0, - valueWithCurrencyEffect: 0 - }); - - /** - * Closing price on 2022-03-07 is unknown, - * hence it uses the last unit price (2022-04-11): 87.8 - */ - expect(portfolioSnapshot.historicalData[1]).toEqual({ - date: '2022-03-07', - investmentValueWithCurrencyEffect: 151.6, - netPerformance: 24, // 2 * (87.8 - 75.8) = 24 - netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438 - netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438 - netPerformanceWithCurrencyEffect: 24, - netWorth: 175.6, // 2 * 87.8 = 175.6 - totalAccountBalance: 0, - totalInvestment: 151.6, - totalInvestmentValueWithCurrencyEffect: 151.6, - value: 175.6, // 2 * 87.8 = 175.6 - valueWithCurrencyEffect: 175.6 - }); - - expect( - portfolioSnapshot.historicalData[ - portfolioSnapshot.historicalData.length - 1 - ] - ).toEqual({ - date: '2022-04-11', - investmentValueWithCurrencyEffect: 0, - netPerformance: 19.86, - netPerformanceInPercentage: 0.13100263852242744, - netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, - netPerformanceWithCurrencyEffect: 19.86, - netWorth: 0, - totalAccountBalance: 0, - totalInvestment: 0, - totalInvestmentValueWithCurrencyEffect: 0, - value: 0, - valueWithCurrencyEffect: 0 - }); - - expect(portfolioSnapshot).toMatchObject({ - currentValueInBaseCurrency: new Big('0'), - errors: [], - hasErrors: false, - positions: [ - { - averagePrice: new Big('0'), - currency: 'CHF', - dataSource: 'YAHOO', - dividend: new Big('0'), - dividendInBaseCurrency: new Big('0'), - fee: new Big('0'), - feeInBaseCurrency: new Big('0'), - firstBuyDate: '2022-03-07', - grossPerformance: new Big('19.86'), - grossPerformancePercentage: new Big('0.13100263852242744063'), - grossPerformancePercentageWithCurrencyEffect: new Big( - '0.13100263852242744063' - ), - grossPerformanceWithCurrencyEffect: new Big('19.86'), - investment: new Big('0'), - investmentWithCurrencyEffect: new Big('0'), - netPerformance: new Big('19.86'), - netPerformancePercentage: new Big('0.13100263852242744063'), - netPerformancePercentageWithCurrencyEffectMap: { - max: new Big('0.13100263852242744063') - }, - netPerformanceWithCurrencyEffectMap: { - max: new Big('19.86') - }, - marketPrice: 87.8, - marketPriceInBaseCurrency: 87.8, - quantity: new Big('0'), - symbol: 'NOVN.SW', - tags: [], - timeWeightedInvestment: new Big('151.6'), - timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), - transactionCount: 2, - 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') - }); - - expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( - expect.objectContaining({ - netPerformance: 19.86, - netPerformanceInPercentage: 0.13100263852242744063, - netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, - netPerformanceWithCurrencyEffect: 19.86, - totalInvestmentValueWithCurrencyEffect: 0 - }) - ); - - expect(investments).toEqual([ - { date: '2022-03-07', investment: new Big('151.6') }, - { date: '2022-04-08', investment: new Big('0') } - ]); - - expect(investmentsByMonth).toEqual([ - { date: '2022-03-01', investment: 151.6 }, - { date: '2022-04-01', investment: -151.6 } - ]); - }); - }); -}); +import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + loadActivityExportFile, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; +import { join } from 'path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let activityDtos: CreateOrderDto[]; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + activityDtos = loadActivityExportFile( + join( + __dirname, + '../../../../../../../test/import/ok-novn-buy-and-sell.json' + ) + ); + }); + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService, + null + ); + }); + + describe('get current positions', () => { + it.only('with NOVN.SW buy and sell', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); + + const activities: Activity[] = activityDtos.map((activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + SymbolProfile: { + ...symbolProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: 'Novartis AG', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: activity.unitPrice + })); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + expect(portfolioSnapshot.historicalData[0]).toEqual({ + date: '2022-03-06', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 0, + timeWeightedPerformanceInPercentage: 0, + timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + /** + * Closing price on 2022-03-07 is unknown, + * hence it uses the last unit price (2022-04-11): 87.8 + */ + expect(portfolioSnapshot.historicalData[1]).toEqual({ + date: '2022-03-07', + investmentValueWithCurrencyEffect: 151.6, + netPerformance: 24, + netPerformanceInPercentage: 0.158311345646438, + netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, + netPerformanceWithCurrencyEffect: 24, + timeWeightedPerformanceInPercentage: 0, + timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, + netWorth: 175.6, + totalAccountBalance: 0, + totalInvestment: 151.6, + totalInvestmentValueWithCurrencyEffect: 151.6, + value: 175.6, // 2 * 87.8 = 175.6 + valueWithCurrencyEffect: 175.6 + }); + + expect( + portfolioSnapshot.historicalData[ + portfolioSnapshot.historicalData.length - 1 + ] + ).toEqual({ + date: '2022-04-11', + investmentValueWithCurrencyEffect: 0, + netPerformance: 19.86, + netPerformanceInPercentage: 0.13100263852242744, + netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, + netPerformanceWithCurrencyEffect: 19.86, + timeWeightedPerformanceInPercentage: -0.02357630979498861, + timeWeightedPerformanceInPercentageWithCurrencyEffect: + -0.02357630979498861, + netWorth: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('0'), + currency: 'CHF', + dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('0'), + feeInBaseCurrency: new Big('0'), + firstBuyDate: '2022-03-07', + grossPerformance: new Big('19.86'), + grossPerformancePercentage: new Big('0.13100263852242744063'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.13100263852242744063' + ), + grossPerformanceWithCurrencyEffect: new Big('19.86'), + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + netPerformance: new Big('19.86'), + netPerformancePercentage: new Big('0.13100263852242744063'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.13100263852242744063') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('19.86') + }, + marketPrice: 87.8, + marketPriceInBaseCurrency: 87.8, + quantity: new Big('0'), + symbol: 'NOVN.SW', + tags: [], + timeWeightedInvestment: new Big('151.6'), + timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), + transactionCount: 2, + 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') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: 19.86, + netPerformanceInPercentage: 0.13100263852242744063, + netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, + netPerformanceWithCurrencyEffect: 19.86, + totalInvestmentValueWithCurrencyEffect: 0 + }) + ); + + expect(investments).toEqual([ + { date: '2022-03-07', investment: new Big('151.6') }, + { date: '2022-04-08', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2022-03-01', investment: 151.6 }, + { date: '2022-04-01', investment: -151.6 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index d9da465f9..f0f01425a 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -226,7 +226,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { totalLiabilities: new Big(0), totalLiabilitiesInBaseCurrency: new Big(0), totalValuables: new Big(0), - totalValuablesInBaseCurrency: new Big(0) + totalValuablesInBaseCurrency: new Big(0), + unitPrices: {} }; } @@ -276,7 +277,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { totalLiabilities: new Big(0), totalLiabilitiesInBaseCurrency: new Big(0), totalValuables: new Big(0), - totalValuablesInBaseCurrency: new Big(0) + totalValuablesInBaseCurrency: new Big(0), + unitPrices: {} }; } @@ -821,6 +823,9 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { for (const dateRange of [ '1d', + '1w', + '1m', + '3m', '1y', '5y', 'max', @@ -981,7 +986,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { timeWeightedInvestment: timeWeightedAverageInvestmentBetweenStartAndEndDate, timeWeightedInvestmentWithCurrencyEffect: - timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect, + unitPrices: marketSymbolMap[endDateString] }; } } diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-googl-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-googl-buy.spec.ts new file mode 100644 index 000000000..7034d86eb --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-googl-buy.spec.ts @@ -0,0 +1,208 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return ExchangeRateDataServiceMock; + }) + }; + } +); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService, + null + ); + }); + + describe('get current positions', () => { + it.only('with GOOGL buy', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2023-01-03'), + feeInAssetProfileCurrency: 1, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Alphabet Inc.', + symbol: 'GOOGL' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 89.12 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('103.10483'), + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('89.12'), + currency: 'USD', + dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('1'), + feeInBaseCurrency: new Big('0.9238'), + firstBuyDate: '2023-01-03', + grossPerformance: new Big('27.33').mul(0.8854), + grossPerformancePercentage: new Big('0.3066651705565529623'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.25235044599563974109' + ), + grossPerformanceWithCurrencyEffect: new Big('20.775774'), + investment: new Big('89.12').mul(0.8854), + investmentWithCurrencyEffect: new Big('82.329056'), + netPerformance: new Big('26.33').mul(0.8854), + netPerformancePercentage: new Big('0.29544434470377019749'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.24112962014285697628') + }, + netPerformanceWithCurrencyEffectMap: { max: new Big('19.851974') }, + marketPrice: 116.45, + marketPriceInBaseCurrency: 103.10483, + quantity: new Big('1'), + symbol: 'GOOGL', + tags: [], + timeWeightedInvestment: new Big('89.12').mul(0.8854), + timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'), + transactionCount: 1, + valueInBaseCurrency: new Big('103.10483') + } + ], + totalFeesWithCurrencyEffect: new Big('0.9238'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('89.12').mul(0.8854), + totalInvestmentWithCurrencyEffect: new Big('82.329056'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: new Big('26.33').mul(0.8854).toNumber(), + netPerformanceInPercentage: 0.29544434470377019749, + netPerformanceInPercentageWithCurrencyEffect: 0.24112962014285697628, + netPerformanceWithCurrencyEffect: 19.851974, + totalInvestmentValueWithCurrencyEffect: 82.329056 + }) + ); + + expect(investments).toEqual([ + { date: '2023-01-03', investment: new Big('89.12') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2023-01-01', investment: 82.329056 }, + { date: '2023-02-01', investment: 0 }, + { date: '2023-03-01', investment: 0 }, + { date: '2023-04-01', investment: 0 }, + { date: '2023-05-01', investment: 0 }, + { date: '2023-06-01', investment: 0 }, + { date: '2023-07-01', investment: 0 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-helper-object.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-helper-object.ts new file mode 100644 index 000000000..52d631e46 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-helper-object.ts @@ -0,0 +1,39 @@ +import { SymbolMetrics } from '@ghostfolio/common/interfaces'; + +import { Big } from 'big.js'; + +import { PortfolioOrderItem } from '../../interfaces/portfolio-order-item.interface'; + +export class PortfolioCalculatorSymbolMetricsHelperObject { + currentExchangeRate: number; + endDateString: string; + exchangeRateAtOrderDate: number; + fees: Big = new Big(0); + feesWithCurrencyEffect: Big = new Big(0); + feesAtStartDate: Big = new Big(0); + feesAtStartDateWithCurrencyEffect: Big = new Big(0); + grossPerformanceAtStartDate: Big = new Big(0); + grossPerformanceAtStartDateWithCurrencyEffect: Big = new Big(0); + indexOfEndOrder: number; + indexOfStartOrder: number; + initialValue: Big; + initialValueWithCurrencyEffect: Big; + investmentAtStartDate: Big; + investmentAtStartDateWithCurrencyEffect: Big; + investmentValueBeforeTransaction: Big = new Big(0); + investmentValueBeforeTransactionWithCurrencyEffect: Big = new Big(0); + ordersByDate: { [date: string]: PortfolioOrderItem[] } = {}; + startDateString: string; + symbolMetrics: SymbolMetrics; + totalUnits: Big = new Big(0); + totalInvestmentFromBuyTransactions: Big = new Big(0); + totalInvestmentFromBuyTransactionsWithCurrencyEffect: Big = new Big(0); + totalQuantityFromBuyTransactions: Big = new Big(0); + totalValueOfPositionsSold: Big = new Big(0); + totalValueOfPositionsSoldWithCurrencyEffect: Big = new Big(0); + unitPrice: Big; + unitPriceAtEndDate: Big = new Big(0); + unitPriceAtStartDate: Big = new Big(0); + valueAtStartDate: Big = new Big(0); + valueAtStartDateWithCurrencyEffect: Big = new Big(0); +} diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts new file mode 100644 index 000000000..f57df03cd --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -0,0 +1,198 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return ExchangeRateDataServiceMock; + }) + }; + } +); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService, + null + ); + }); + + describe('get current positions', () => { + it.only('with MSFT buy', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-09-16'), + feeInAssetProfileCurrency: 19, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 298.58 + }, + { + ...activityDummyData, + date: new Date('2021-11-16'), + feeInAssetProfileCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 0.62 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + 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', + grossPerformance: new Big('33.87'), + grossPerformancePercentage: new Big('0.11343693482483756447'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.11343693482483756447' + ), + grossPerformanceWithCurrencyEffect: new Big('33.87'), + investment: new Big('298.58'), + investmentWithCurrencyEffect: new Big('298.58'), + marketPrice: 331.83, + marketPriceInBaseCurrency: 331.83, + netPerformance: new Big('14.87'), + netPerformancePercentage: new Big('0.04980239801728180052'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.04980239801728180052') + }, + netPerformanceWithCurrencyEffectMap: { + '1d': new Big('-5.39'), + '5y': new Big('14.87'), + max: new Big('14.87'), + wtd: new Big('-5.39') + }, + 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') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + totalInvestmentValueWithCurrencyEffect: 298.58 + }) + ); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell-partially.spec.ts new file mode 100644 index 000000000..a2e10df70 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -0,0 +1,202 @@ +import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + loadActivityExportFile, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; +import { join } from 'path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let activityDtos: CreateOrderDto[]; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + activityDtos = loadActivityExportFile( + join( + __dirname, + '../../../../../../../test/import/ok-novn-buy-and-sell-partially.json' + ) + ); + }); + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService, + null + ); + }); + + describe('get current positions', () => { + it.only('with NOVN.SW buy and sell partially', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); + + const activities: Activity[] = activityDtos.map((activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + SymbolProfile: { + ...symbolProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: 'Novartis AG', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: activity.unitPrice + })); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('87.8'), + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('75.80'), + currency: 'CHF', + dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('4.25'), + feeInBaseCurrency: new Big('4.25'), + firstBuyDate: '2022-03-07', + grossPerformance: new Big('21.93'), + grossPerformancePercentage: new Big('0.14465699208443271768'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.14465699208443271768' + ), + grossPerformanceWithCurrencyEffect: new Big('21.93'), + investment: new Big('75.80'), + investmentWithCurrencyEffect: new Big('75.80'), + netPerformance: new Big('17.68'), + netPerformancePercentage: new Big('0.11662269129287598945'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.11662269129287598945') + }, + netPerformanceWithCurrencyEffectMap: { max: new Big('17.68') }, + marketPrice: 87.8, + marketPriceInBaseCurrency: 87.8, + quantity: new Big('1'), + symbol: 'NOVN.SW', + tags: [], + timeWeightedInvestment: new Big('151.6'), + timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), + 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'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: 17.68, + netPerformanceInPercentage: 0.11662269129287598945, + netPerformanceInPercentageWithCurrencyEffect: 0.11662269129287598945, + netPerformanceWithCurrencyEffect: 17.68, + totalInvestmentValueWithCurrencyEffect: 75.8 + }) + ); + + expect(investments).toEqual([ + { date: '2022-03-07', investment: new Big('151.6') }, + { date: '2022-04-08', investment: new Big('75.8') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2022-03-01', investment: 151.6 }, + { date: '2022-04-01', investment: -75.8 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell.spec.ts new file mode 100644 index 000000000..dc0939f23 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -0,0 +1,259 @@ +import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + loadActivityExportFile, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; +import { join } from 'path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let activityDtos: CreateOrderDto[]; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + activityDtos = loadActivityExportFile( + join( + __dirname, + '../../../../../../../test/import/ok-novn-buy-and-sell.json' + ) + ); + }); + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService, + null + ); + }); + + describe('get current positions', () => { + it.only('with NOVN.SW buy and sell', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); + + const activities: Activity[] = activityDtos.map((activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + SymbolProfile: { + ...symbolProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: 'Novartis AG', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: activity.unitPrice + })); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + expect(portfolioSnapshot.historicalData[0]).toEqual({ + date: '2022-03-06', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 0, + timeWeightedPerformanceInPercentage: 0, + timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + expect(portfolioSnapshot.historicalData[1]).toEqual({ + date: '2022-03-07', + investmentValueWithCurrencyEffect: 151.6, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + timeWeightedPerformanceInPercentage: 0, + timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, + netWorth: 151.6, + totalAccountBalance: 0, + totalInvestment: 151.6, + totalInvestmentValueWithCurrencyEffect: 151.6, + value: 151.6, + valueWithCurrencyEffect: 151.6 + }); + + expect( + portfolioSnapshot.historicalData[ + portfolioSnapshot.historicalData.length - 1 + ] + ).toEqual({ + date: '2022-04-11', + investmentValueWithCurrencyEffect: 0, + netPerformance: 19.86, + netPerformanceInPercentage: 0.13100263852242744, + netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, + timeWeightedPerformanceInPercentage: 0.13100263852242744, + timeWeightedPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, + netPerformanceWithCurrencyEffect: 19.86, + netWorth: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('0'), + currency: 'CHF', + dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('0'), + feeInBaseCurrency: new Big('0'), + firstBuyDate: '2022-03-07', + grossPerformance: new Big('19.86'), + grossPerformancePercentage: new Big('0.13100263852242744063'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.13100263852242744063' + ), + grossPerformanceWithCurrencyEffect: new Big('19.86'), + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + netPerformance: new Big('19.86'), + netPerformancePercentage: new Big('0.13100263852242744063'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.13100263852242744063') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('19.86') + }, + marketPrice: 87.8, + marketPriceInBaseCurrency: 87.8, + quantity: new Big('0'), + symbol: 'NOVN.SW', + tags: [], + timeWeightedInvestment: new Big('151.6'), + timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), + transactionCount: 2, + 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') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: 19.86, + netPerformanceInPercentage: 0.13100263852242744063, + netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, + netPerformanceWithCurrencyEffect: 19.86, + totalInvestmentValueWithCurrencyEffect: 0 + }) + ); + + expect(investments).toEqual([ + { date: '2022-03-07', investment: new Big('151.6') }, + { date: '2022-04-08', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2022-03-01', investment: 151.6 }, + { date: '2022-04-01', investment: -151.6 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts new file mode 100644 index 000000000..848ce68a0 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts @@ -0,0 +1,894 @@ +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { SymbolMetrics } from '@ghostfolio/common/interfaces'; +import { DateRangeTypes } from '@ghostfolio/common/types/date-range.type'; + +import { DataSource } from '@prisma/client'; +import { Big } from 'big.js'; +import { isBefore, addMilliseconds, format } from 'date-fns'; +import { sortBy } from 'lodash'; + +import { getFactor } from '../../../../helper/portfolio.helper'; +import { PortfolioOrderItem } from '../../interfaces/portfolio-order-item.interface'; +import { PortfolioCalculatorSymbolMetricsHelperObject } from './portfolio-calculator-helper-object'; + +export class RoiPortfolioCalculatorSymbolMetricsHelper { + private ENABLE_LOGGING: boolean; + private baseCurrencySuffix = 'InBaseCurrency'; + private chartDates: string[]; + private marketSymbolMap: { [date: string]: { [symbol: string]: Big } }; + public constructor( + ENABLE_LOGGING: boolean, + marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, + chartDates: string[] + ) { + this.ENABLE_LOGGING = ENABLE_LOGGING; + this.marketSymbolMap = marketSymbolMap; + this.chartDates = chartDates; + } + + public calculateNetPerformanceByDateRange( + start: Date, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject + ) { + for (const dateRange of DateRangeTypes) { + const dateInterval = getIntervalFromDateRange(dateRange); + const endDate = dateInterval.endDate; + let startDate = dateInterval.startDate; + + if (isBefore(startDate, start)) { + startDate = start; + } + + const rangeEndDateString = format(endDate, DATE_FORMAT); + const rangeStartDateString = format(startDate, DATE_FORMAT); + + symbolMetricsHelper.symbolMetrics.netPerformanceWithCurrencyEffectMap[ + dateRange + ] = + symbolMetricsHelper.symbolMetrics.netPerformanceValuesWithCurrencyEffect[ + rangeEndDateString + ]?.minus( + // If the date range is 'max', take 0 as a start value. Otherwise, + // the value of the end of the day of the start date is taken which + // differs from the buying price. + dateRange === 'max' + ? new Big(0) + : (symbolMetricsHelper.symbolMetrics + .netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ?? + new Big(0)) + ) ?? new Big(0); + + const investmentBasis = this.calculateInvestmentBasis( + symbolMetricsHelper, + rangeStartDateString, + rangeEndDateString + ); + + symbolMetricsHelper.symbolMetrics.netPerformancePercentageWithCurrencyEffectMap[ + dateRange + ] = investmentBasis.gt(0) + ? symbolMetricsHelper.symbolMetrics.netPerformanceWithCurrencyEffectMap[ + dateRange + ].div(investmentBasis) + : new Big(0); + } + } + + public handleOverallPerformanceCalculation( + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject + ) { + symbolMetricsHelper.symbolMetrics.grossPerformance = + symbolMetricsHelper.symbolMetrics.grossPerformance.minus( + symbolMetricsHelper.grossPerformanceAtStartDate + ); + symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect = + symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect.minus( + symbolMetricsHelper.grossPerformanceAtStartDateWithCurrencyEffect + ); + + symbolMetricsHelper.symbolMetrics.netPerformance = + symbolMetricsHelper.symbolMetrics.grossPerformance.minus( + symbolMetricsHelper.fees.minus(symbolMetricsHelper.feesAtStartDate) + ); + + symbolMetricsHelper.symbolMetrics.timeWeightedInvestment = new Big( + symbolMetricsHelper.totalInvestmentFromBuyTransactions + ); + symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentWithCurrencyEffect = + new Big( + symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect + ); + + if (symbolMetricsHelper.symbolMetrics.timeWeightedInvestment.gt(0)) { + symbolMetricsHelper.symbolMetrics.netPerformancePercentage = + symbolMetricsHelper.symbolMetrics.netPerformance.div( + symbolMetricsHelper.symbolMetrics.timeWeightedInvestment + ); + symbolMetricsHelper.symbolMetrics.grossPerformancePercentage = + symbolMetricsHelper.symbolMetrics.grossPerformance.div( + symbolMetricsHelper.symbolMetrics.timeWeightedInvestment + ); + symbolMetricsHelper.symbolMetrics.grossPerformancePercentageWithCurrencyEffect = + symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect.div( + symbolMetricsHelper.symbolMetrics + .timeWeightedInvestmentWithCurrencyEffect + ); + } + } + + public processOrderMetrics( + orders: PortfolioOrderItem[], + i: number, + exchangeRates: { [dateString: string]: number }, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject + ) { + const order = orders[i]; + this.writeOrderToLogIfNecessary(i, order); + + symbolMetricsHelper.exchangeRateAtOrderDate = exchangeRates[order.date]; + const value = order.quantity.gt(0) + ? order.quantity.mul(order.unitPrice) + : new Big(0); + + this.handleNoneBuyAndSellOrders(order, value, symbolMetricsHelper); + this.handleStartOrder( + order, + i, + orders, + symbolMetricsHelper.unitPriceAtStartDate + ); + this.handleOrderFee(order, symbolMetricsHelper); + symbolMetricsHelper.unitPrice = this.getUnitPriceAndFillCurrencyDeviations( + order, + symbolMetricsHelper + ); + + if (order.unitPriceInBaseCurrency) { + symbolMetricsHelper.investmentValueBeforeTransaction = + symbolMetricsHelper.totalUnits.mul(order.unitPriceInBaseCurrency); + symbolMetricsHelper.investmentValueBeforeTransactionWithCurrencyEffect = + symbolMetricsHelper.totalUnits.mul( + order.unitPriceInBaseCurrencyWithCurrencyEffect + ); + } + + this.handleInitialInvestmentValues(symbolMetricsHelper, i, order); + + const { transactionInvestment, transactionInvestmentWithCurrencyEffect } = + this.handleBuyAndSellTranscation(order, symbolMetricsHelper); + + this.logTransactionValuesIfRequested( + order, + transactionInvestment, + transactionInvestmentWithCurrencyEffect + ); + + this.updateTotalInvestments( + symbolMetricsHelper, + transactionInvestment, + transactionInvestmentWithCurrencyEffect + ); + + this.setInitialValueIfNecessary( + symbolMetricsHelper, + transactionInvestment, + transactionInvestmentWithCurrencyEffect + ); + + this.accumulateFees(symbolMetricsHelper, order); + + symbolMetricsHelper.totalUnits = symbolMetricsHelper.totalUnits.plus( + order.quantity.mul(getFactor(order.type)) + ); + + this.fillOrderUnitPricesIfMissing(order, symbolMetricsHelper); + + const valueOfInvestment = symbolMetricsHelper.totalUnits.mul( + order.unitPriceInBaseCurrency + ); + + const valueOfInvestmentWithCurrencyEffect = + symbolMetricsHelper.totalUnits.mul( + order.unitPriceInBaseCurrencyWithCurrencyEffect + ); + + const valueOfPositionsSold = + order.type === 'SELL' + ? order.unitPriceInBaseCurrency.mul(order.quantity) + : new Big(0); + + const valueOfPositionsSoldWithCurrencyEffect = + order.type === 'SELL' + ? order.unitPriceInBaseCurrencyWithCurrencyEffect.mul(order.quantity) + : new Big(0); + + symbolMetricsHelper.totalValueOfPositionsSold = + symbolMetricsHelper.totalValueOfPositionsSold.plus(valueOfPositionsSold); + symbolMetricsHelper.totalValueOfPositionsSoldWithCurrencyEffect = + symbolMetricsHelper.totalValueOfPositionsSoldWithCurrencyEffect.plus( + valueOfPositionsSoldWithCurrencyEffect + ); + + this.handlePerformanceCalculation( + valueOfInvestment, + symbolMetricsHelper, + valueOfInvestmentWithCurrencyEffect, + order + ); + + symbolMetricsHelper.symbolMetrics.investmentValuesAccumulated[order.date] = + new Big(symbolMetricsHelper.symbolMetrics.totalInvestment.toNumber()); + + symbolMetricsHelper.symbolMetrics.investmentValuesAccumulatedWithCurrencyEffect[ + order.date + ] = new Big( + symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.toNumber() + ); + + symbolMetricsHelper.symbolMetrics.investmentValuesWithCurrencyEffect[ + order.date + ] = ( + symbolMetricsHelper.symbolMetrics.investmentValuesWithCurrencyEffect[ + order.date + ] ?? new Big(0) + ).add(transactionInvestmentWithCurrencyEffect); + } + + public handlePerformanceCalculation( + valueOfInvestment: Big, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + valueOfInvestmentWithCurrencyEffect: Big, + order: PortfolioOrderItem + ) { + this.calculateGrossPerformance( + valueOfInvestment, + symbolMetricsHelper, + valueOfInvestmentWithCurrencyEffect + ); + + this.calculateNetPerformance( + symbolMetricsHelper, + order, + valueOfInvestment, + valueOfInvestmentWithCurrencyEffect + ); + } + + public calculateNetPerformance( + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + order: PortfolioOrderItem, + valueOfInvestment: Big, + valueOfInvestmentWithCurrencyEffect: Big + ) { + symbolMetricsHelper.symbolMetrics.currentValues[order.date] = new Big( + valueOfInvestment + ); + symbolMetricsHelper.symbolMetrics.currentValuesWithCurrencyEffect[ + order.date + ] = new Big(valueOfInvestmentWithCurrencyEffect); + + symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentValues[order.date] = + new Big(symbolMetricsHelper.totalInvestmentFromBuyTransactions); + symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentValuesWithCurrencyEffect[ + order.date + ] = new Big( + symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect + ); + + symbolMetricsHelper.symbolMetrics.netPerformanceValues[order.date] = + symbolMetricsHelper.symbolMetrics.grossPerformance + .minus(symbolMetricsHelper.grossPerformanceAtStartDate) + .minus( + symbolMetricsHelper.fees.minus(symbolMetricsHelper.feesAtStartDate) + ); + + symbolMetricsHelper.symbolMetrics.netPerformanceValuesWithCurrencyEffect[ + order.date + ] = symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect + .minus(symbolMetricsHelper.grossPerformanceAtStartDateWithCurrencyEffect) + .minus( + symbolMetricsHelper.feesWithCurrencyEffect.minus( + symbolMetricsHelper.feesAtStartDateWithCurrencyEffect + ) + ); + } + + public calculateGrossPerformance( + valueOfInvestment: Big, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + valueOfInvestmentWithCurrencyEffect: Big + ) { + const newGrossPerformance = valueOfInvestment + .minus(symbolMetricsHelper.totalInvestmentFromBuyTransactions) + .plus(symbolMetricsHelper.totalValueOfPositionsSold) + .plus( + symbolMetricsHelper.symbolMetrics.totalDividend.mul( + symbolMetricsHelper.currentExchangeRate + ) + ) + .plus( + symbolMetricsHelper.symbolMetrics.totalInterest.mul( + symbolMetricsHelper.currentExchangeRate + ) + ); + + const newGrossPerformanceWithCurrencyEffect = + valueOfInvestmentWithCurrencyEffect + .minus( + symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect + ) + .plus(symbolMetricsHelper.totalValueOfPositionsSoldWithCurrencyEffect) + .plus(symbolMetricsHelper.symbolMetrics.totalDividendInBaseCurrency) + .plus(symbolMetricsHelper.symbolMetrics.totalInterestInBaseCurrency); + + symbolMetricsHelper.symbolMetrics.grossPerformance = newGrossPerformance; + symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect = + newGrossPerformanceWithCurrencyEffect; + } + + public accumulateFees( + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + order: PortfolioOrderItem + ) { + symbolMetricsHelper.fees = symbolMetricsHelper.fees.plus( + order.feeInBaseCurrency ?? 0 + ); + + symbolMetricsHelper.feesWithCurrencyEffect = + symbolMetricsHelper.feesWithCurrencyEffect.plus( + order.feeInBaseCurrencyWithCurrencyEffect ?? 0 + ); + } + + public updateTotalInvestments( + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + transactionInvestment: Big, + transactionInvestmentWithCurrencyEffect: Big + ) { + symbolMetricsHelper.symbolMetrics.totalInvestment = + symbolMetricsHelper.symbolMetrics.totalInvestment.plus( + transactionInvestment + ); + + symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect = + symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.plus( + transactionInvestmentWithCurrencyEffect + ); + } + + public setInitialValueIfNecessary( + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + transactionInvestment: Big, + transactionInvestmentWithCurrencyEffect: Big + ) { + if (!symbolMetricsHelper.initialValue && transactionInvestment.gt(0)) { + symbolMetricsHelper.initialValue = transactionInvestment; + symbolMetricsHelper.initialValueWithCurrencyEffect = + transactionInvestmentWithCurrencyEffect; + } + } + + public logTransactionValuesIfRequested( + order: PortfolioOrderItem, + transactionInvestment: Big, + transactionInvestmentWithCurrencyEffect: Big + ) { + if (this.ENABLE_LOGGING) { + console.log('order.quantity', order.quantity.toNumber()); + console.log('transactionInvestment', transactionInvestment.toNumber()); + + console.log( + 'transactionInvestmentWithCurrencyEffect', + transactionInvestmentWithCurrencyEffect.toNumber() + ); + } + } + + public handleBuyAndSellTranscation( + order: PortfolioOrderItem, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject + ) { + switch (order.type) { + case 'BUY': + return this.handleBuyTransaction(order, symbolMetricsHelper); + case 'SELL': + return this.handleSellTransaction(symbolMetricsHelper, order); + default: + return { + transactionInvestment: new Big(0), + transactionInvestmentWithCurrencyEffect: new Big(0) + }; + } + } + + public handleSellTransaction( + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + order: PortfolioOrderItem + ) { + let transactionInvestment = new Big(0); + let transactionInvestmentWithCurrencyEffect = new Big(0); + if (symbolMetricsHelper.totalUnits.gt(0)) { + transactionInvestment = symbolMetricsHelper.symbolMetrics.totalInvestment + .div(symbolMetricsHelper.totalUnits) + .mul(order.quantity) + .mul(getFactor(order.type)); + transactionInvestmentWithCurrencyEffect = + symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect + .div(symbolMetricsHelper.totalUnits) + .mul(order.quantity) + .mul(getFactor(order.type)); + } + return { transactionInvestment, transactionInvestmentWithCurrencyEffect }; + } + + public handleBuyTransaction( + order: PortfolioOrderItem, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject + ) { + const transactionInvestment = order.quantity + .mul(order.unitPriceInBaseCurrency) + .mul(getFactor(order.type)); + + const transactionInvestmentWithCurrencyEffect = order.quantity + .mul(order.unitPriceInBaseCurrencyWithCurrencyEffect) + .mul(getFactor(order.type)); + + symbolMetricsHelper.totalQuantityFromBuyTransactions = + symbolMetricsHelper.totalQuantityFromBuyTransactions.plus(order.quantity); + + symbolMetricsHelper.totalInvestmentFromBuyTransactions = + symbolMetricsHelper.totalInvestmentFromBuyTransactions.plus( + transactionInvestment + ); + + symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect = + symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus( + transactionInvestmentWithCurrencyEffect + ); + return { transactionInvestment, transactionInvestmentWithCurrencyEffect }; + } + + public handleInitialInvestmentValues( + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + i: number, + order: PortfolioOrderItem + ) { + if ( + !symbolMetricsHelper.investmentAtStartDate && + i >= symbolMetricsHelper.indexOfStartOrder + ) { + symbolMetricsHelper.investmentAtStartDate = new Big( + symbolMetricsHelper.symbolMetrics.totalInvestment.toNumber() + ); + symbolMetricsHelper.investmentAtStartDateWithCurrencyEffect = new Big( + symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.toNumber() + ); + + symbolMetricsHelper.valueAtStartDate = new Big( + symbolMetricsHelper.investmentValueBeforeTransaction.toNumber() + ); + + symbolMetricsHelper.valueAtStartDateWithCurrencyEffect = new Big( + symbolMetricsHelper.investmentValueBeforeTransactionWithCurrencyEffect.toNumber() + ); + } + if (order.itemType === 'start') { + symbolMetricsHelper.feesAtStartDate = symbolMetricsHelper.fees; + symbolMetricsHelper.feesAtStartDateWithCurrencyEffect = + symbolMetricsHelper.feesWithCurrencyEffect; + symbolMetricsHelper.grossPerformanceAtStartDate = + symbolMetricsHelper.symbolMetrics.grossPerformance; + + symbolMetricsHelper.grossPerformanceAtStartDateWithCurrencyEffect = + symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect; + } + + if ( + i >= symbolMetricsHelper.indexOfStartOrder && + !symbolMetricsHelper.initialValue + ) { + if ( + i === symbolMetricsHelper.indexOfStartOrder && + !symbolMetricsHelper.symbolMetrics.totalInvestment.eq(0) + ) { + symbolMetricsHelper.initialValue = new Big( + symbolMetricsHelper.symbolMetrics.totalInvestment.toNumber() + ); + + symbolMetricsHelper.initialValueWithCurrencyEffect = new Big( + symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.toNumber() + ); + } + } + } + + public getSymbolMetricHelperObject( + exchangeRates: { [dateString: string]: number }, + start: Date, + end: Date, + marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, + symbol: string + ): PortfolioCalculatorSymbolMetricsHelperObject { + const symbolMetricsHelper = + new PortfolioCalculatorSymbolMetricsHelperObject(); + symbolMetricsHelper.symbolMetrics = this.createEmptySymbolMetrics(); + symbolMetricsHelper.currentExchangeRate = + exchangeRates[format(new Date(), DATE_FORMAT)]; + symbolMetricsHelper.startDateString = format(start, DATE_FORMAT); + symbolMetricsHelper.endDateString = format(end, DATE_FORMAT); + symbolMetricsHelper.unitPriceAtStartDate = + marketSymbolMap[symbolMetricsHelper.startDateString]?.[symbol]; + symbolMetricsHelper.unitPriceAtEndDate = + marketSymbolMap[symbolMetricsHelper.endDateString]?.[symbol]; + + symbolMetricsHelper.totalUnits = new Big(0); + + return symbolMetricsHelper; + } + + public getUnitPriceAndFillCurrencyDeviations( + order: PortfolioOrderItem, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject + ) { + const unitprice = ['BUY', 'SELL'].includes(order.type) + ? order.unitPrice + : order.unitPriceFromMarketData; + if (unitprice) { + order.unitPriceInBaseCurrency = unitprice.mul( + symbolMetricsHelper.currentExchangeRate ?? 1 + ); + + order.unitPriceInBaseCurrencyWithCurrencyEffect = unitprice.mul( + symbolMetricsHelper.exchangeRateAtOrderDate ?? 1 + ); + } + return unitprice; + } + + public handleOrderFee( + order: PortfolioOrderItem, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject + ) { + if (order.fee) { + order.feeInBaseCurrency = order.fee.mul( + symbolMetricsHelper.currentExchangeRate ?? 1 + ); + order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul( + symbolMetricsHelper.exchangeRateAtOrderDate ?? 1 + ); + } + } + + public handleStartOrder( + order: PortfolioOrderItem, + i: number, + orders: PortfolioOrderItem[], + unitPriceAtStartDate: Big.Big + ) { + if (order.itemType === 'start') { + // Take the unit price of the order as the market price if there are no + // orders of this symbol before the start date + order.unitPrice = + i === 0 ? orders[i + 1]?.unitPrice : unitPriceAtStartDate; + } + } + + public handleNoneBuyAndSellOrders( + order: PortfolioOrderItem, + value: Big.Big, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject + ) { + const symbolMetricsKey = this.getSymbolMetricsKeyFromOrderType(order.type); + if (symbolMetricsKey) { + this.calculateMetrics(value, symbolMetricsHelper, symbolMetricsKey); + } + } + + public getSymbolMetricsKeyFromOrderType( + orderType: PortfolioOrderItem['type'] + ): keyof SymbolMetrics { + switch (orderType) { + case 'DIVIDEND': + return 'totalDividend'; + case 'INTEREST': + return 'totalInterest'; + case 'ITEM': + return 'totalValuables'; + case 'LIABILITY': + return 'totalLiabilities'; + default: + return undefined; + } + } + + public calculateMetrics( + value: Big, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + key: keyof SymbolMetrics + ) { + const stringKey = key.toString(); + symbolMetricsHelper.symbolMetrics[stringKey] = ( + symbolMetricsHelper.symbolMetrics[stringKey] as Big + ).plus(value); + + if ( + Object.keys(symbolMetricsHelper.symbolMetrics).includes( + stringKey + this.baseCurrencySuffix + ) + ) { + symbolMetricsHelper.symbolMetrics[stringKey + this.baseCurrencySuffix] = ( + symbolMetricsHelper.symbolMetrics[ + stringKey + this.baseCurrencySuffix + ] as Big + ).plus(value.mul(symbolMetricsHelper.exchangeRateAtOrderDate ?? 1)); + } else { + throw new Error( + `Key ${stringKey + this.baseCurrencySuffix} not found in symbolMetrics` + ); + } + } + + public writeOrderToLogIfNecessary(i: number, order: PortfolioOrderItem) { + if (this.ENABLE_LOGGING) { + console.log(); + console.log(); + console.log( + i + 1, + order.date, + order.type, + order.itemType ? `(${order.itemType})` : '' + ); + } + } + + public fillOrdersAndSortByTime( + orders: PortfolioOrderItem[], + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + chartDateMap: { [date: string]: boolean }, + marketSymbolMap: { [date: string]: { [symbol: string]: Big.Big } }, + symbol: string, + dataSource: DataSource + ) { + this.fillOrdersByDate(orders, symbolMetricsHelper.ordersByDate); + + this.chartDates ??= Object.keys(chartDateMap).sort(); + + this.fillOrdersWithDatesFromChartDate( + symbolMetricsHelper, + marketSymbolMap, + symbol, + orders, + dataSource + ); + + // Sort orders so that the start and end placeholder order are at the correct + // position + orders = this.sortOrdersByTime(orders); + return orders; + } + + public sortOrdersByTime(orders: PortfolioOrderItem[]) { + 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(); + }); + return orders; + } + + public fillOrdersWithDatesFromChartDate( + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + marketSymbolMap: { [date: string]: { [symbol: string]: Big.Big } }, + symbol: string, + orders: PortfolioOrderItem[], + dataSource: DataSource + ) { + let lastUnitPrice: Big; + for (const dateString of this.chartDates) { + if (dateString < symbolMetricsHelper.startDateString) { + continue; + } else if (dateString > symbolMetricsHelper.endDateString) { + break; + } + + if (symbolMetricsHelper.ordersByDate[dateString]?.length > 0) { + for (const order of symbolMetricsHelper.ordersByDate[dateString]) { + order.unitPriceFromMarketData = + marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice; + } + } else { + orders.push( + this.getFakeOrder( + dateString, + dataSource, + symbol, + marketSymbolMap, + lastUnitPrice + ) + ); + } + + const lastOrder = orders.at(-1); + + lastUnitPrice = lastOrder.unitPriceFromMarketData ?? lastOrder.unitPrice; + } + return lastUnitPrice; + } + + public getFakeOrder( + dateString: string, + dataSource: DataSource, + symbol: string, + marketSymbolMap: { [date: string]: { [symbol: string]: Big.Big } }, + lastUnitPrice: Big.Big + ): PortfolioOrderItem { + return { + date: dateString, + fee: new Big(0), + feeInBaseCurrency: new Big(0), + quantity: new Big(0), + SymbolProfile: { + dataSource, + symbol + }, + type: 'BUY', + unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice, + unitPriceFromMarketData: + marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice + }; + } + + public fillOrdersByDate( + orders: PortfolioOrderItem[], + ordersByDate: { [date: string]: PortfolioOrderItem[] } + ) { + for (const order of orders) { + ordersByDate[order.date] = ordersByDate[order.date] ?? []; + ordersByDate[order.date].push(order); + } + } + + public addSyntheticStartAndEndOrder( + orders: PortfolioOrderItem[], + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + dataSource: DataSource, + symbol: string + ) { + orders.push({ + date: symbolMetricsHelper.startDateString, + fee: new Big(0), + feeInBaseCurrency: new Big(0), + itemType: 'start', + quantity: new Big(0), + SymbolProfile: { + dataSource, + symbol + }, + type: 'BUY', + unitPrice: symbolMetricsHelper.unitPriceAtStartDate + }); + + orders.push({ + date: symbolMetricsHelper.endDateString, + fee: new Big(0), + feeInBaseCurrency: new Big(0), + itemType: 'end', + SymbolProfile: { + dataSource, + symbol + }, + quantity: new Big(0), + type: 'BUY', + unitPrice: symbolMetricsHelper.unitPriceAtEndDate + }); + } + + public hasNoUnitPriceAtEndOrStartDate( + unitPriceAtEndDate: Big.Big, + unitPriceAtStartDate: Big.Big, + orders: PortfolioOrderItem[], + start: Date + ) { + return ( + !unitPriceAtEndDate || + (!unitPriceAtStartDate && isBefore(new Date(orders[0].date), start)) + ); + } + + public createEmptySymbolMetrics(): SymbolMetrics { + return { + currentValues: {}, + currentValuesWithCurrencyEffect: {}, + feesWithCurrencyEffect: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + hasErrors: false, + initialValue: new Big(0), + initialValueWithCurrencyEffect: new Big(0), + investmentValuesAccumulated: {}, + investmentValuesAccumulatedWithCurrencyEffect: {}, + investmentValuesWithCurrencyEffect: {}, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffectMap: {}, + netPerformanceValues: {}, + netPerformanceValuesWithCurrencyEffect: {}, + netPerformanceWithCurrencyEffectMap: {}, + timeWeightedInvestment: new Big(0), + timeWeightedInvestmentValues: {}, + timeWeightedInvestmentValuesWithCurrencyEffect: {}, + timeWeightedInvestmentWithCurrencyEffect: new Big(0), + totalAccountBalanceInBaseCurrency: new Big(0), + totalDividend: new Big(0), + totalDividendInBaseCurrency: new Big(0), + totalInterest: new Big(0), + totalInterestInBaseCurrency: new Big(0), + totalInvestment: new Big(0), + totalInvestmentWithCurrencyEffect: new Big(0), + unitPrices: {}, + totalLiabilities: new Big(0), + totalLiabilitiesInBaseCurrency: new Big(0), + totalValuables: new Big(0), + totalValuablesInBaseCurrency: new Big(0) + }; + } + + private fillOrderUnitPricesIfMissing( + order: PortfolioOrderItem, + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject + ) { + order.unitPriceInBaseCurrency ??= this.marketSymbolMap[order.date]?.[ + order.SymbolProfile.symbol + ].mul(symbolMetricsHelper.currentExchangeRate); + + order.unitPriceInBaseCurrencyWithCurrencyEffect ??= this.marketSymbolMap[ + order.date + ]?.[order.SymbolProfile.symbol].mul( + symbolMetricsHelper.exchangeRateAtOrderDate + ); + } + + private calculateInvestmentBasis( + symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, + rangeStartDateString: string, + rangeEndDateString: string + ) { + let investmentBasis = this.getValueOrZero( + symbolMetricsHelper.symbolMetrics.currentValuesWithCurrencyEffect[ + rangeStartDateString + ] + ).plus( + this.getValueOrZero( + symbolMetricsHelper.symbolMetrics + .timeWeightedInvestmentValuesWithCurrencyEffect[rangeEndDateString] + )?.minus( + this.getValueOrZero( + symbolMetricsHelper.symbolMetrics + .timeWeightedInvestmentValuesWithCurrencyEffect[ + rangeStartDateString + ] + ) + ) + ); + + if (!investmentBasis.gt(0)) { + investmentBasis = + symbolMetricsHelper.symbolMetrics + .timeWeightedInvestmentValuesWithCurrencyEffect[rangeEndDateString]; + } + return investmentBasis; + } + + private getValueOrZero(value: Big | undefined) { + return value ?? new Big(0); + } +} diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts index b4929c570..faef77712 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts @@ -1,29 +1,272 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { AssetProfileIdentifier, SymbolMetrics } from '@ghostfolio/common/interfaces'; -import { PortfolioSnapshot } from '@ghostfolio/common/models'; +import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; +import { Logger } from '@nestjs/common'; +import { Big } from 'big.js'; +import { cloneDeep } from 'lodash'; + +import { PortfolioOrderItem } from '../../interfaces/portfolio-order-item.interface'; +import { RoiPortfolioCalculatorSymbolMetricsHelper } from './portfolio-calculator-symbolmetrics-helper'; + export class RoiPortfolioCalculator extends PortfolioCalculator { - protected calculateOverallPerformance(): PortfolioSnapshot { - throw new Error('Method not implemented.'); - } + private chartDates: string[]; - protected getPerformanceCalculationType() { - return PerformanceCalculationType.ROI; + @LogPerformance + protected calculateOverallPerformance( + positions: TimelinePosition[] + ): PortfolioSnapshot { + let currentValueInBaseCurrency = new Big(0); + let grossPerformance = new Big(0); + let grossPerformanceWithCurrencyEffect = new Big(0); + let hasErrors = false; + let netPerformance = new Big(0); + let totalFeesWithCurrencyEffect = new Big(0); + const totalInterestWithCurrencyEffect = new Big(0); + let totalInvestment = new Big(0); + let totalInvestmentWithCurrencyEffect = new Big(0); + let totalTimeWeightedInvestment = new Big(0); + let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); + + for (const currentPosition of positions) { + ({ + totalFeesWithCurrencyEffect, + currentValueInBaseCurrency, + hasErrors, + totalInvestment, + totalInvestmentWithCurrencyEffect, + grossPerformance, + grossPerformanceWithCurrencyEffect, + netPerformance, + totalTimeWeightedInvestment, + totalTimeWeightedInvestmentWithCurrencyEffect + } = this.calculatePositionMetrics( + currentPosition, + totalFeesWithCurrencyEffect, + currentValueInBaseCurrency, + hasErrors, + totalInvestment, + totalInvestmentWithCurrencyEffect, + grossPerformance, + grossPerformanceWithCurrencyEffect, + netPerformance, + totalTimeWeightedInvestment, + totalTimeWeightedInvestmentWithCurrencyEffect + )); + } + + return { + currentValueInBaseCurrency, + hasErrors, + positions, + totalFeesWithCurrencyEffect, + totalInterestWithCurrencyEffect, + totalInvestment, + totalInvestmentWithCurrencyEffect, + activitiesCount: this.activities.filter(({ type }) => { + return ['BUY', 'SELL', 'STAKE'].includes(type); + }).length, + createdAt: new Date(), + errors: [], + historicalData: [], + totalLiabilitiesWithCurrencyEffect: new Big(0), + totalValuablesWithCurrencyEffect: new Big(0) + }; } - protected getSymbolMetrics({}: { + protected getSymbolMetrics({ + chartDateMap, + dataSource, + end, + exchangeRates, + marketSymbolMap, + start, + symbol + }: { + chartDateMap?: { [date: string]: boolean }; end: Date; exchangeRates: { [dateString: string]: number }; marketSymbolMap: { [date: string]: { [symbol: string]: Big }; }; start: Date; - step?: number; } & AssetProfileIdentifier): SymbolMetrics { - throw new Error('Method not implemented.'); + if (!this.chartDates) { + this.chartDates = Object.keys(chartDateMap).sort(); + } + const symbolMetricsHelperClass = + new RoiPortfolioCalculatorSymbolMetricsHelper( + PortfolioCalculator.ENABLE_LOGGING, + marketSymbolMap, + this.chartDates + ); + const symbolMetricsHelper = + symbolMetricsHelperClass.getSymbolMetricHelperObject( + exchangeRates, + start, + end, + marketSymbolMap, + symbol + ); + + let orders: PortfolioOrderItem[] = cloneDeep( + this.activities.filter(({ SymbolProfile }) => { + return SymbolProfile.symbol === symbol; + }) + ); + + if (!orders.length) { + return symbolMetricsHelper.symbolMetrics; + } + + if ( + symbolMetricsHelperClass.hasNoUnitPriceAtEndOrStartDate( + symbolMetricsHelper.unitPriceAtEndDate, + symbolMetricsHelper.unitPriceAtStartDate, + orders, + start + ) + ) { + symbolMetricsHelper.symbolMetrics.hasErrors = true; + return symbolMetricsHelper.symbolMetrics; + } + + symbolMetricsHelperClass.addSyntheticStartAndEndOrder( + orders, + symbolMetricsHelper, + dataSource, + symbol + ); + + orders = symbolMetricsHelperClass.fillOrdersAndSortByTime( + orders, + symbolMetricsHelper, + chartDateMap, + marketSymbolMap, + symbol, + dataSource + ); + + symbolMetricsHelper.indexOfStartOrder = orders.findIndex(({ itemType }) => { + return itemType === 'start'; + }); + symbolMetricsHelper.indexOfEndOrder = orders.findIndex(({ itemType }) => { + return itemType === 'end'; + }); + + for (let i = 0; i < orders.length; i++) { + symbolMetricsHelperClass.processOrderMetrics( + orders, + i, + exchangeRates, + symbolMetricsHelper + ); + if (i === symbolMetricsHelper.indexOfEndOrder) { + break; + } + } + + symbolMetricsHelperClass.handleOverallPerformanceCalculation( + symbolMetricsHelper + ); + symbolMetricsHelperClass.calculateNetPerformanceByDateRange( + start, + symbolMetricsHelper + ); + + return symbolMetricsHelper.symbolMetrics; + } + + protected getPerformanceCalculationType() { + return PerformanceCalculationType.ROI; + } + + private calculatePositionMetrics( + currentPosition: TimelinePosition, + totalFeesWithCurrencyEffect: Big, + currentValueInBaseCurrency: Big, + hasErrors: boolean, + totalInvestment: Big, + totalInvestmentWithCurrencyEffect: Big, + grossPerformance: Big, + grossPerformanceWithCurrencyEffect: Big, + netPerformance: Big, + totalTimeWeightedInvestment: Big, + totalTimeWeightedInvestmentWithCurrencyEffect: Big + ) { + if (currentPosition.feeInBaseCurrency) { + totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( + currentPosition.feeInBaseCurrency + ); + } + + if (currentPosition.valueInBaseCurrency) { + currentValueInBaseCurrency = currentValueInBaseCurrency.plus( + currentPosition.valueInBaseCurrency + ); + } else { + hasErrors = true; + } + + if (currentPosition.investment) { + totalInvestment = totalInvestment.plus(currentPosition.investment); + + totalInvestmentWithCurrencyEffect = + totalInvestmentWithCurrencyEffect.plus( + currentPosition.investmentWithCurrencyEffect + ); + } else { + hasErrors = true; + } + + if (currentPosition.grossPerformance) { + grossPerformance = grossPerformance.plus( + currentPosition.grossPerformance + ); + + grossPerformanceWithCurrencyEffect = + grossPerformanceWithCurrencyEffect.plus( + currentPosition.grossPerformanceWithCurrencyEffect + ); + + netPerformance = netPerformance.plus(currentPosition.netPerformance); + } else if (!currentPosition.quantity.eq(0)) { + hasErrors = true; + } + + if (currentPosition.timeWeightedInvestment) { + totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus( + currentPosition.timeWeightedInvestment + ); + + totalTimeWeightedInvestmentWithCurrencyEffect = + totalTimeWeightedInvestmentWithCurrencyEffect.plus( + currentPosition.timeWeightedInvestmentWithCurrencyEffect + ); + } else if (!currentPosition.quantity.eq(0)) { + Logger.warn( + `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, + 'PortfolioCalculator' + ); + + hasErrors = true; + } + return { + totalFeesWithCurrencyEffect, + currentValueInBaseCurrency, + hasErrors, + totalInvestment, + totalInvestmentWithCurrencyEffect, + grossPerformance, + grossPerformanceWithCurrencyEffect, + netPerformance, + totalTimeWeightedInvestment, + totalTimeWeightedInvestmentWithCurrencyEffect + }; } } diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts index 06e471d67..5048e8f9e 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts @@ -10,3 +10,8 @@ export interface PortfolioOrderItem extends PortfolioOrder { unitPriceInBaseCurrency?: Big; unitPriceInBaseCurrencyWithCurrencyEffect?: Big; } + +export interface WithCurrencyEffect { + Value: T; + WithCurrencyEffect: T; +} diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 5b68f58e0..c8547600b 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -5,7 +5,10 @@ import { hasNotDefinedValuesInObject, nullifyValuesInObject } from '@ghostfolio/api/helper/object.helper'; -import { PerformanceLoggingInterceptor } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; +import { + LogPerformance, + PerformanceLoggingInterceptor +} from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; @@ -159,6 +162,23 @@ export class PortfolioController { portfolioPosition.investment / totalInvestment; portfolioPosition.valueInPercentage = portfolioPosition.valueInBaseCurrency / totalValue; + portfolioPosition.assetClass = hasDetails + ? portfolioPosition.assetClass + : undefined; + portfolioPosition.assetSubClass = hasDetails + ? portfolioPosition.assetSubClass + : undefined; + portfolioPosition.countries = hasDetails + ? portfolioPosition.countries + : []; + portfolioPosition.currency = hasDetails + ? portfolioPosition.currency + : undefined; + portfolioPosition.markets = hasDetails + ? portfolioPosition.markets + : undefined; + portfolioPosition.sectors = hasDetails ? portfolioPosition.sectors : []; + portfolioPosition.tags = hasDetails ? portfolioPosition.tags : []; } for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) { @@ -225,6 +245,7 @@ export class PortfolioController { currency: hasDetails ? portfolioPosition.currency : undefined, holdings: hasDetails ? portfolioPosition.holdings : [], markets: hasDetails ? portfolioPosition.markets : undefined, + tags: hasDetails ? portfolioPosition.tags : [], marketsAdvanced: hasDetails ? portfolioPosition.marketsAdvanced : undefined, @@ -417,6 +438,14 @@ export class PortfolioController { filterByTags }); + const { performance } = await this.portfolioService.getPerformance({ + dateRange, + filters, + impersonationId, + withExcludedAccounts: false, + userId: this.request.user.id + }); + const { holdings } = await this.portfolioService.getDetails({ dateRange, filters, @@ -424,7 +453,7 @@ export class PortfolioController { userId: this.request.user.id }); - return { holdings: Object.values(holdings) }; + return { holdings: Object.values(holdings), performance }; } @Get('investments') @@ -502,6 +531,7 @@ export class PortfolioController { @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @Version('2') + @LogPerformance public async getPerformanceV2( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Query('accounts') filterByAccounts?: string, @@ -510,10 +540,8 @@ export class PortfolioController { @Query('range') dateRange: DateRange = 'max', @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string, - @Query('withExcludedAccounts') withExcludedAccountsParam = 'false' + @Query('withExcludedAccounts') withExcludedAccounts = false ): Promise { - const withExcludedAccounts = withExcludedAccountsParam === 'true'; - const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index a43499e5b..f37949870 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -5,6 +5,7 @@ import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interf import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; 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 { AssetClassClusterRiskEquity } from '@ghostfolio/api/models/rules/asset-class-cluster-risk/equity'; @@ -86,7 +87,7 @@ import { parseISO, set } from 'date-fns'; -import { isEmpty } from 'lodash'; +import { isEmpty, uniqBy } from 'lodash'; import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; @@ -115,6 +116,7 @@ export class PortfolioService { private readonly userService: UserService ) {} + @LogPerformance public async getAccounts({ filters, userId, @@ -205,6 +207,7 @@ export class PortfolioService { }); } + @LogPerformance public async getAccountsWithAggregations({ filters, userId, @@ -241,6 +244,7 @@ export class PortfolioService { }; } + @LogPerformance public async getDividends({ activities, groupBy @@ -248,16 +252,18 @@ export class PortfolioService { activities: Activity[]; groupBy?: GroupBy; }): Promise { - let dividends = activities.map(({ currency, date, value }) => { - return { - date: format(date, DATE_FORMAT), - investment: this.exchangeRateDataService.toCurrency( - value, - currency, - this.getUserCurrency() - ) - }; - }); + let dividends = activities.map( + ({ currency, date, value, SymbolProfile }) => { + return { + date: format(date, DATE_FORMAT), + investment: this.exchangeRateDataService.toCurrency( + value, + currency ?? SymbolProfile.currency, + this.getUserCurrency() + ) + }; + } + ); if (groupBy) { dividends = this.getDividendsByGroup({ dividends, groupBy }); @@ -266,6 +272,7 @@ export class PortfolioService { return dividends; } + @LogPerformance public async getInvestments({ dateRange, filters, @@ -344,6 +351,7 @@ export class PortfolioService { }; } + @LogPerformance public async getDetails({ dateRange = 'max', filters, @@ -492,13 +500,17 @@ export class PortfolioService { })); } + const tagsInternal = tags.concat( + symbolProfiles.find((sp) => sp.symbol === symbol)?.tags ?? [] + ); + holdings[symbol] = { currency, markets, marketsAdvanced, marketPrice, symbol, - tags, + tags: uniqBy(tagsInternal, 'id'), transactionCount, allocationInPercentage: filteredValueInBaseCurrency.eq(0) ? 0 @@ -635,6 +647,7 @@ export class PortfolioService { }; } + @LogPerformance public async getHolding( aDataSource: DataSource, aImpersonationId: string, @@ -655,6 +668,7 @@ export class PortfolioService { activities: [], averagePrice: undefined, dataProviderInfo: undefined, + stakeRewards: undefined, dividendInBaseCurrency: undefined, dividendYieldPercent: undefined, dividendYieldPercentWithCurrencyEffect: undefined, @@ -745,6 +759,16 @@ export class PortfolioService { ) }); + const stakeRewards = getSum( + activities + .filter(({ SymbolProfile, type }) => { + return symbol === SymbolProfile.symbol && type === 'STAKE'; + }) + .map(({ quantity }) => { + return new Big(quantity); + }) + ); + const historicalData = await this.dataProviderService.getHistorical( [{ dataSource, symbol: aSymbol }], 'day', @@ -838,6 +862,7 @@ export class PortfolioService { activities: activitiesOfHolding, averagePrice: averagePrice.toNumber(), dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], + stakeRewards: stakeRewards.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), dividendYieldPercent: dividendYieldPercent.toNumber(), dividendYieldPercentWithCurrencyEffect: @@ -945,6 +970,7 @@ export class PortfolioService { activities: [], averagePrice: 0, dataProviderInfo: undefined, + stakeRewards: 0, dividendInBaseCurrency: 0, dividendYieldPercent: 0, dividendYieldPercentWithCurrencyEffect: 0, @@ -974,6 +1000,7 @@ export class PortfolioService { } } + @LogPerformance public async getHoldings({ dateRange = 'max', filters, @@ -1104,6 +1131,7 @@ export class PortfolioService { dataProviderResponses[symbol]?.marketState ?? 'delayed', name: symbolProfileMap[symbol].name, netPerformance: netPerformance?.toNumber() ?? null, + tags: symbolProfileMap[symbol].tags, netPerformancePercentage: netPerformancePercentage?.toNumber() ?? null, netPerformancePercentageWithCurrencyEffect: @@ -1123,6 +1151,7 @@ export class PortfolioService { }; } + @LogPerformance public async getPerformance({ dateRange = 'max', filters, @@ -1178,17 +1207,11 @@ export class PortfolioService { currency: userCurrency }); - const { errors, hasErrors, historicalData } = - await portfolioCalculator.getSnapshot(); - const { endDate, startDate } = getIntervalFromDateRange(dateRange); - - const { chart } = await portfolioCalculator.getPerformance({ - end: endDate, - start: startDate - }); + const range = { end: endDate, start: startDate }; const { + chart, netPerformance, netPerformanceInPercentage, netPerformanceInPercentageWithCurrencyEffect, @@ -1196,21 +1219,12 @@ export class PortfolioService { netWorth, totalInvestment, valueWithCurrencyEffect - } = chart?.at(-1) ?? { - netPerformance: 0, - netPerformanceInPercentage: 0, - netPerformanceInPercentageWithCurrencyEffect: 0, - netPerformanceWithCurrencyEffect: 0, - netWorth: 0, - totalInvestment: 0, - valueWithCurrencyEffect: 0 - }; + } = await portfolioCalculator.getPerformance(range); return { chart, - errors, - hasErrors, - firstOrderDate: parseDate(historicalData[0]?.date), + hasErrors: false, + firstOrderDate: parseDate(chart[0]?.date), performance: { netPerformance, netPerformanceWithCurrencyEffect, @@ -1224,6 +1238,7 @@ export class PortfolioService { }; } + @LogPerformance public async getReport( impersonationId: string ): Promise { @@ -1382,6 +1397,7 @@ export class PortfolioService { return { rules, statistics: this.getReportStatistics(rules) }; } + @LogPerformance public async updateTags({ dataSource, impersonationId, @@ -1396,7 +1412,6 @@ export class PortfolioService { userId: string; }) { userId = await this.getUserId(impersonationId, userId); - await this.orderService.assignTags({ dataSource, symbol, tags, userId }); } @@ -1589,68 +1604,7 @@ export class PortfolioService { return cashPositions; } - private getDividendsByGroup({ - dividends, - groupBy - }: { - dividends: InvestmentItem[]; - groupBy: GroupBy; - }): InvestmentItem[] { - if (dividends.length === 0) { - return []; - } - - const dividendsByGroup: InvestmentItem[] = []; - let currentDate: Date; - let investmentByGroup = new Big(0); - - for (const [index, dividend] of dividends.entries()) { - if ( - isSameYear(parseDate(dividend.date), currentDate) && - (groupBy === 'year' || - isSameMonth(parseDate(dividend.date), currentDate)) - ) { - // Same group: Add up dividends - - investmentByGroup = investmentByGroup.plus(dividend.investment); - } else { - // New group: Store previous group and reset - - if (currentDate) { - dividendsByGroup.push({ - date: format( - set(currentDate, { - date: 1, - month: groupBy === 'year' ? 0 : currentDate.getMonth() - }), - DATE_FORMAT - ), - investment: investmentByGroup.toNumber() - }); - } - - currentDate = parseDate(dividend.date); - investmentByGroup = new Big(dividend.investment); - } - - if (index === dividends.length - 1) { - // Store current month (latest order) - dividendsByGroup.push({ - date: format( - set(currentDate, { - date: 1, - month: groupBy === 'year' ? 0 : currentDate.getMonth() - }), - DATE_FORMAT - ), - investment: investmentByGroup.toNumber() - }); - } - } - - return dividendsByGroup; - } - + @LogPerformance private getEmergencyFundHoldingsValueInBaseCurrency({ holdings }: { @@ -1676,128 +1630,7 @@ export class PortfolioService { return valueInBaseCurrencyOfEmergencyFundHoldings.toNumber(); } - private getInitialCashPosition({ - balance, - currency - }: { - balance: number; - currency: string; - }): PortfolioPosition { - return { - currency, - allocationInPercentage: 0, - assetClass: AssetClass.LIQUIDITY, - assetSubClass: AssetSubClass.CASH, - countries: [], - dataSource: undefined, - dateOfFirstActivity: undefined, - dividend: 0, - grossPerformance: 0, - grossPerformancePercent: 0, - grossPerformancePercentWithCurrencyEffect: 0, - grossPerformanceWithCurrencyEffect: 0, - holdings: [], - investment: balance, - marketPrice: 0, - name: currency, - netPerformance: 0, - netPerformancePercent: 0, - netPerformancePercentWithCurrencyEffect: 0, - netPerformanceWithCurrencyEffect: 0, - quantity: 0, - sectors: [], - symbol: currency, - tags: [], - transactionCount: 0, - valueInBaseCurrency: balance - }; - } - - private getMarkets({ - assetProfile - }: { - assetProfile: EnhancedSymbolProfile; - }) { - 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 - ) - .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(); - } - } - } - - markets[UNKNOWN_KEY] = new Big(1) - .minus(markets.developedMarkets) - .minus(markets.emergingMarkets) - .minus(markets.otherMarkets) - .toNumber(); - - marketsAdvanced[UNKNOWN_KEY] = new Big(1) - .minus(marketsAdvanced.asiaPacific) - .minus(marketsAdvanced.emergingMarkets) - .minus(marketsAdvanced.europe) - .minus(marketsAdvanced.japan) - .minus(marketsAdvanced.northAmerica) - .minus(marketsAdvanced.otherMarkets) - .toNumber(); - - return { markets, marketsAdvanced }; - } - + @LogPerformance private getReportStatistics( evaluatedRules: PortfolioReportResponse['rules'] ): PortfolioReportResponse['statistics'] { @@ -1816,6 +1649,7 @@ export class PortfolioService { return { rulesActiveCount, rulesFulfilledCount }; } + @LogPerformance private getStreaks({ investments, savingsRate @@ -1838,6 +1672,7 @@ export class PortfolioService { return { currentStreak, longestStreak }; } + @LogPerformance private async getSummary({ balanceInBaseCurrency, emergencyFundHoldingsValueInBaseCurrency, @@ -1863,7 +1698,6 @@ export class PortfolioService { userId, withExcludedAccounts: true }); - const excludedActivities: Activity[] = []; const nonExcludedActivities: Activity[] = []; @@ -1926,7 +1760,9 @@ export class PortfolioService { .plus(emergencyFundHoldingsValueInBaseCurrency) .toNumber(); - const committedFunds = new Big(totalBuy).minus(totalSell); + const committedFunds = new Big(totalBuy) + .minus(totalSell) + .minus(dividendInBaseCurrency); const totalOfExcludedActivities = this.getSumOfActivityType({ userCurrency, @@ -1946,7 +1782,6 @@ export class PortfolioService { currency: userCurrency, withExcludedAccounts: true }); - const excludedBalanceInBaseCurrency = new Big( cashDetailsWithExcludedAccounts.balanceInBaseCurrency ).minus(balanceInBaseCurrency); @@ -1955,12 +1790,9 @@ export class PortfolioService { .plus(totalOfExcludedActivities) .toNumber(); - const netWorth = new Big(balanceInBaseCurrency) - .plus(currentValueInBaseCurrency) - .plus(valuables) - .plus(excludedAccountsAndActivities) - .minus(liabilities) - .toNumber(); + const netWorth = await portfolioCalculator + .getUnfilteredNetWorth(this.getUserCurrency()) + .then((value) => value.toNumber()); const daysInMarket = differenceInDays(new Date(), firstOrderDate); @@ -2023,85 +1855,24 @@ export class PortfolioService { }; } - private getSumOfActivityType({ + @LogPerformance + private async getValueOfAccountsAndPlatforms({ activities, - activityType, - userCurrency + filters = [], + portfolioItemsNow, + userCurrency, + userId, + withExcludedAccounts = false }: { activities: Activity[]; - activityType: ActivityType; + filters?: Filter[]; + portfolioItemsNow: Record; userCurrency: string; + userId: string; + withExcludedAccounts?: boolean; }) { - 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 getTotalEmergencyFund({ - emergencyFundHoldingsValueInBaseCurrency, - userSettings - }: { - emergencyFundHoldingsValueInBaseCurrency: number; - userSettings: UserSettings; - }) { - return new Big( - Math.max( - emergencyFundHoldingsValueInBaseCurrency, - userSettings?.emergencyFund ?? 0 - ) - ); - } - - private getUserCurrency(aUser?: UserWithSettings) { - return ( - aUser?.Settings?.settings.baseCurrency ?? - this.request.user?.Settings?.settings.baseCurrency ?? - DEFAULT_CURRENCY - ); - } - - private async getUserId(aImpersonationId: string, aUserId: string) { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(aImpersonationId); - - return impersonationUserId || aUserId; - } - - private getUserPerformanceCalculationType( - aUser: UserWithSettings - ): PerformanceCalculationType { - return aUser?.Settings?.settings.performanceCalculationType; - } - - private async getValueOfAccountsAndPlatforms({ - activities, - filters = [], - portfolioItemsNow, - userCurrency, - userId, - withExcludedAccounts = false - }: { - activities: Activity[]; - filters?: Filter[]; - portfolioItemsNow: Record; - userCurrency: string; - userId: string; - withExcludedAccounts?: boolean; - }) { - const accounts: PortfolioDetails['accounts'] = {}; - const platforms: PortfolioDetails['platforms'] = {}; + const accounts: PortfolioDetails['accounts'] = {}; + const platforms: PortfolioDetails['platforms'] = {}; let currentAccounts: (Account & { Order?: Order[]; @@ -2216,4 +1987,251 @@ export class PortfolioService { return { accounts, platforms }; } + + @LogPerformance + private getSumOfActivityType({ + activities, + activityType, + userCurrency + }: { + activities: Activity[]; + activityType: ActivityType; + userCurrency: string; + }) { + 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 getInitialCashPosition({ + balance, + currency + }: { + balance: number; + currency: string; + }): PortfolioPosition { + return { + currency, + allocationInPercentage: 0, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + countries: [], + dataSource: undefined, + dateOfFirstActivity: undefined, + dividend: 0, + grossPerformance: 0, + grossPerformancePercent: 0, + grossPerformancePercentWithCurrencyEffect: 0, + grossPerformanceWithCurrencyEffect: 0, + holdings: [], + investment: balance, + marketPrice: 0, + name: currency, + netPerformance: 0, + netPerformancePercent: 0, + netPerformancePercentWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + quantity: 0, + sectors: [], + symbol: currency, + tags: [], + transactionCount: 0, + valueInBaseCurrency: balance + }; + } + + private getDividendsByGroup({ + dividends, + groupBy + }: { + dividends: InvestmentItem[]; + groupBy: GroupBy; + }): InvestmentItem[] { + if (dividends.length === 0) { + return []; + } + + const dividendsByGroup: InvestmentItem[] = []; + let currentDate: Date; + let investmentByGroup = new Big(0); + + for (const [index, dividend] of dividends.entries()) { + if ( + isSameYear(parseDate(dividend.date), currentDate) && + (groupBy === 'year' || + isSameMonth(parseDate(dividend.date), currentDate)) + ) { + // Same group: Add up dividends + + investmentByGroup = investmentByGroup.plus(dividend.investment); + } else { + // New group: Store previous group and reset + + if (currentDate) { + dividendsByGroup.push({ + date: format( + set(currentDate, { + date: 1, + month: groupBy === 'year' ? 0 : currentDate.getMonth() + }), + DATE_FORMAT + ), + investment: investmentByGroup.toNumber() + }); + } + + currentDate = parseDate(dividend.date); + investmentByGroup = new Big(dividend.investment); + } + + if (index === dividends.length - 1) { + // Store current month (latest order) + dividendsByGroup.push({ + date: format( + set(currentDate, { + date: 1, + month: groupBy === 'year' ? 0 : currentDate.getMonth() + }), + DATE_FORMAT + ), + investment: investmentByGroup.toNumber() + }); + } + } + + return dividendsByGroup; + } + + private getMarkets({ + assetProfile + }: { + assetProfile: EnhancedSymbolProfile; + }) { + 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 + ) + .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(); + } + } + } + + markets[UNKNOWN_KEY] = new Big(1) + .minus(markets.developedMarkets) + .minus(markets.emergingMarkets) + .minus(markets.otherMarkets) + .toNumber(); + + marketsAdvanced[UNKNOWN_KEY] = new Big(1) + .minus(marketsAdvanced.asiaPacific) + .minus(marketsAdvanced.emergingMarkets) + .minus(marketsAdvanced.europe) + .minus(marketsAdvanced.japan) + .minus(marketsAdvanced.northAmerica) + .minus(marketsAdvanced.otherMarkets) + .toNumber(); + + return { markets, marketsAdvanced }; + } + + private getTotalEmergencyFund({ + emergencyFundHoldingsValueInBaseCurrency, + userSettings + }: { + emergencyFundHoldingsValueInBaseCurrency: number; + userSettings: UserSettings; + }) { + return new Big( + Math.max( + emergencyFundHoldingsValueInBaseCurrency, + userSettings?.emergencyFund ?? 0 + ) + ); + } + + private getUserCurrency(aUser?: UserWithSettings) { + return ( + aUser?.Settings?.settings.baseCurrency ?? + this.request.user?.Settings?.settings.baseCurrency ?? + DEFAULT_CURRENCY + ); + } + + private async getUserId(aImpersonationId: string, aUserId: string) { + const impersonationUserId = + await this.impersonationService.validateImpersonationId(aImpersonationId); + + return impersonationUserId || aUserId; + } + + private getUserPerformanceCalculationType( + aUser: UserWithSettings + ): PerformanceCalculationType { + return aUser?.Settings?.settings.performanceCalculationType; + } } diff --git a/apps/api/src/app/tag/tag.service.ts b/apps/api/src/app/tag/tag.service.ts new file mode 100644 index 000000000..5247ecfb4 --- /dev/null +++ b/apps/api/src/app/tag/tag.service.ts @@ -0,0 +1,82 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; + +import { Injectable } from '@nestjs/common'; +import { Prisma, Tag } from '@prisma/client'; + +@Injectable() +export class TagService { + public constructor(private readonly prismaService: PrismaService) {} + + public async createTag(data: Prisma.TagCreateInput) { + return this.prismaService.tag.create({ + data + }); + } + + public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise { + return this.prismaService.tag.delete({ where }); + } + + public async getTag( + tagWhereUniqueInput: Prisma.TagWhereUniqueInput + ): Promise { + return this.prismaService.tag.findUnique({ + where: tagWhereUniqueInput + }); + } + + public async getTags({ + cursor, + orderBy, + skip, + take, + where + }: { + cursor?: Prisma.TagWhereUniqueInput; + orderBy?: Prisma.TagOrderByWithRelationInput; + skip?: number; + take?: number; + where?: Prisma.TagWhereInput; + } = {}) { + return this.prismaService.tag.findMany({ + cursor, + orderBy, + skip, + take, + where + }); + } + + public async getTagsWithActivityCount() { + const tagsWithOrderCount = await this.prismaService.tag.findMany({ + include: { + _count: { + select: { orders: true, symbolProfile: true } + } + } + }); + + return tagsWithOrderCount.map(({ _count, id, name, userId }) => { + return { + id, + name, + userId, + activityCount: _count.orders, + holdingCount: _count.symbolProfile + }; + }); + } + + public async updateTag({ + data, + where + }: { + data: Prisma.TagUpdateInput; + where: Prisma.TagWhereUniqueInput; + }): Promise { + return this.prismaService.tag.update({ + data, + where + }); + } +} 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 b34b6fae2..eb9567630 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -6,6 +6,7 @@ import type { HoldingsViewMode, ViewMode } from '@ghostfolio/common/types'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { IsArray, @@ -37,6 +38,9 @@ export class UpdateUserSettingDto { @IsIn([ '1d', + '1w', + '1m', + '3m', '1y', '5y', 'max', @@ -114,4 +118,8 @@ export class UpdateUserSettingDto { @IsOptional() xRayRules?: XRayRulesSettings; + + @IsIn(['TWR', 'ROI', 'ROAI', 'MWR'] as PerformanceCalculationType[]) + @IsOptional() + performanceCalculationType?: PerformanceCalculationType; } diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 868af505b..0f1c2527f 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -183,6 +183,12 @@ export class UserController { } } + for (const key in data) { + if (data[key] !== false && data[key] !== null) { + userSettings[key] = data[key]; + } + } + return this.userService.updateUserSetting({ emitPortfolioChangedEvent, userSettings, diff --git a/apps/api/src/helper/dateQueryHelper.ts b/apps/api/src/helper/dateQueryHelper.ts new file mode 100644 index 000000000..a2b312a12 --- /dev/null +++ b/apps/api/src/helper/dateQueryHelper.ts @@ -0,0 +1,25 @@ +import { resetHours } from '@ghostfolio/common/helper'; + +import { addDays } from 'date-fns'; + +import { DateQuery } from '../app/portfolio/interfaces/date-query.interface'; + +export class DateQueryHelper { + public handleDateQueryIn(dateQuery: DateQuery): { + query: DateQuery; + dates: Date[]; + } { + let dates = []; + let query = dateQuery; + if (dateQuery.in?.length > 0) { + dates = dateQuery.in; + const end = Math.max(...dates.map((d) => d.getTime())); + const start = Math.min(...dates.map((d) => d.getTime())); + query = { + gte: resetHours(new Date(start)), + lt: resetHours(addDays(end, 1)) + }; + } + return { query, dates }; + } +} diff --git a/apps/api/src/helper/portfolio.helper.ts b/apps/api/src/helper/portfolio.helper.ts index 6ebe48d3c..1248b1409 100644 --- a/apps/api/src/helper/portfolio.helper.ts +++ b/apps/api/src/helper/portfolio.helper.ts @@ -5,6 +5,7 @@ export function getFactor(activityType: ActivityType) { switch (activityType) { case 'BUY': + case 'STAKE': factor = 1; break; case 'SELL': diff --git a/apps/api/src/interceptors/performance-logging/performance-logging.interceptor.ts b/apps/api/src/interceptors/performance-logging/performance-logging.interceptor.ts index d863f0ec3..9f746cc92 100644 --- a/apps/api/src/interceptors/performance-logging/performance-logging.interceptor.ts +++ b/apps/api/src/interceptors/performance-logging/performance-logging.interceptor.ts @@ -43,7 +43,7 @@ export function LogPerformance( ) { const originalMethod = descriptor.value; - descriptor.value = async function (...args: any[]) { + descriptor.value = function (...args: any[]) { const startTime = performance.now(); const performanceLoggingService = new PerformanceLoggingService(); 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 8b885c013..652897d9f 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 @@ -14,7 +14,6 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { private static countriesMapping = { 'Russian Federation': 'Russia' }; - private static holdingsWeightTreshold = 0.85; private static sectorsMapping = { 'Consumer Discretionary': 'Consumer Cyclical', 'Consumer Defensive': 'Consumer Staples', @@ -94,10 +93,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { return {}; }); - if ( - holdings?.weight < TrackinsightDataEnhancerService.holdingsWeightTreshold - ) { - // Skip if data is inaccurate + if (holdings?.weight < 1 - Math.min(holdings?.count * 0.000015, 0.95)) { + // Skip if data is inaccurate, dependent on holdings count there might be rounding issues return response; } 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 348935101..b66ff342b 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -1,4 +1,5 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { @@ -397,6 +398,7 @@ export class DataProviderService { return result; } + @LogPerformance public async getQuotes({ items, requestTimeout, @@ -530,6 +532,8 @@ export class DataProviderService { } response[symbol] = dataProviderResponse; + const quotesCacheTTL = + this.getAppropriateCacheTTL(dataProviderResponse); this.redisCacheService.set( this.redisCacheService.getQuoteKey({ @@ -537,7 +541,7 @@ export class DataProviderService { dataSource: DataSource[dataSource] }), JSON.stringify(response[symbol]), - this.configurationService.get('CACHE_QUOTES_TTL') + quotesCacheTTL ); for (const { @@ -620,6 +624,25 @@ export class DataProviderService { return response; } + private getAppropriateCacheTTL(dataProviderResponse: IDataProviderResponse) { + let quotesCacheTTL = this.configurationService.get('CACHE_QUOTES_TTL'); + + if (dataProviderResponse.dataSource === 'MANUAL') { + quotesCacheTTL = 14400; // 4h Cache for Manual Service + } else if (dataProviderResponse.marketState === 'closed') { + const date = new Date(); + const dayOfWeek = date.getDay(); + if (dayOfWeek === 0 || dayOfWeek === 6) { + quotesCacheTTL = 14400; + } else if (date.getHours() > 16) { + quotesCacheTTL = 14400; + } else { + quotesCacheTTL = 900; + } + } + return quotesCacheTTL; + } + public async search({ includeIndices = false, query, diff --git a/apps/api/src/services/data-provider/manual/manual.service.ts b/apps/api/src/services/data-provider/manual/manual.service.ts index 66e625e47..e6763d797 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -13,6 +13,7 @@ import { } from '@ghostfolio/api/services/interfaces/interfaces'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { BatchPrismaClient } from '@ghostfolio/common/chunkhelper'; import { DATE_FORMAT, extractNumberFromString, @@ -147,18 +148,25 @@ export class ManualService implements DataProviderInterface { }) ); - const marketData = await this.prismaService.marketData.findMany({ - distinct: ['symbol'], - orderBy: { - date: 'desc' - }, - take: symbols.length, - where: { - symbol: { - in: symbols - } - } - }); + const batch = new BatchPrismaClient(this.prismaService); + + const marketData = await batch + .over(symbols) + .with((prisma, _symbols) => + prisma.marketData.findMany({ + distinct: ['symbol'], + orderBy: { + date: 'desc' + }, + take: symbols.length, + where: { + symbol: { + in: _symbols + } + } + }) + ) + .then((_result) => _result.flat()); const symbolProfilesWithScraperConfigurationAndInstantMode = symbolProfiles.filter(({ scraperConfiguration }) => { diff --git a/apps/api/src/services/market-data/market-data.service.ts b/apps/api/src/services/market-data/market-data.service.ts index 58b9b09ec..6381039ad 100644 --- a/apps/api/src/services/market-data/market-data.service.ts +++ b/apps/api/src/services/market-data/market-data.service.ts @@ -12,11 +12,14 @@ import { MarketDataState, Prisma } from '@prisma/client'; +import AwaitLock from 'await-lock'; @Injectable() export class MarketDataService { public constructor(private readonly prismaService: PrismaService) {} + lock = new AwaitLock(); + public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) { return this.prismaService.marketData.deleteMany({ where: { @@ -155,7 +158,6 @@ export class MarketDataService { where: Prisma.MarketDataWhereUniqueInput; }): Promise { const { data, where } = params; - return this.prismaService.marketData.upsert({ where, create: { @@ -179,7 +181,7 @@ export class MarketDataService { data: Prisma.MarketDataUpdateInput[]; }): Promise { const upsertPromises = data.map( - ({ dataSource, date, marketPrice, symbol, state }) => { + async ({ dataSource, date, marketPrice, symbol, state }) => { return this.prismaService.marketData.upsert({ create: { dataSource: dataSource as DataSource, @@ -202,7 +204,6 @@ export class MarketDataService { }); } ); - - return this.prismaService.$transaction(upsertPromises); + return await Promise.all(upsertPromises); } } diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts b/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts index 9cf6f63e6..d65d50fb7 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts @@ -1,6 +1,9 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error'; -import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { + IDataGatheringItem, + IDataProviderHistoricalResponse +} from '@ghostfolio/api/services/interfaces/interfaces'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { @@ -8,15 +11,17 @@ import { DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY, DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, - GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME + GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME, + GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME } from '@ghostfolio/common/config'; import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { Process, Processor } from '@nestjs/bull'; import { Injectable, Logger } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { DataSource, Prisma } from '@prisma/client'; import { Job } from 'bull'; +import { isNumber } from 'class-validator'; import { addDays, format, @@ -24,7 +29,9 @@ import { getMonth, getYear, isBefore, - parseISO + parseISO, + eachDayOfInterval, + isEqual } from 'date-fns'; import { DataGatheringService } from './data-gathering.service'; @@ -194,4 +201,166 @@ export class DataGatheringProcessor { throw error; } } + @Process({ + concurrency: parseInt( + process.env.PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA ?? + DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY.toString(), + 10 + ), + name: GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME + }) + public async gatherMissingHistoricalMarketData(job: Job) { + const { dataSource, date, symbol } = job.data; + try { + Logger.log( + `Historical market data gathering for missing values has been started for ${symbol} (${dataSource}) at ${format( + date, + DATE_FORMAT + )}`, + `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` + ); + const entries = await this.marketDataService.marketDataItems({ + where: { + AND: { + symbol: { + equals: symbol + }, + dataSource: { + equals: dataSource + } + } + }, + orderBy: { + date: 'asc' + }, + take: 1 + }); + const firstEntry = entries[0]; + const marketData = await this.marketDataService + .getRange({ + assetProfileIdentifiers: [{ dataSource, symbol }], + dateQuery: { + gte: addDays(firstEntry.date, -10) + } + }) + .then((md) => md.map((m) => m.date)); + + let dates = eachDayOfInterval( + { + start: firstEntry.date, + end: new Date() + }, + { + step: 1 + } + ); + dates = dates.filter((d) => !marketData.some((md) => isEqual(md, d))); + + const historicalData = await this.dataProviderService.getHistoricalRaw({ + assetProfileIdentifiers: [{ dataSource, symbol }], + from: firstEntry.date, + to: new Date() + }); + + const data: Prisma.MarketDataUpdateInput[] = + this.mapToMarketUpsertDataInputs( + dates, + historicalData, + symbol, + dataSource + ); + + await this.marketDataService.updateMany({ data }); + + Logger.log( + `Historical market data gathering for missing values has been completed for ${symbol} (${dataSource}) at ${format( + date, + DATE_FORMAT + )}`, + `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` + ); + } catch (error) { + if (error instanceof AssetProfileDelistedError) { + await this.symbolProfileService.updateSymbolProfile( + { + dataSource, + symbol + }, + { + isActive: false + } + ); + + Logger.log( + `Historical market data gathering has been discarded for ${symbol} (${dataSource})`, + `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` + ); + + return job.discard(); + } + + Logger.error( + error, + `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` + ); + + throw error; + } + } + + private mapToMarketUpsertDataInputs( + missingMarketData: Date[], + historicalData: Record< + string, + Record + >, + symbol: string, + dataSource: DataSource + ): Prisma.MarketDataUpdateInput[] { + return missingMarketData.map((date) => { + if ( + isNumber( + historicalData[symbol]?.[format(date, DATE_FORMAT)]?.marketPrice + ) + ) { + return { + date, + symbol, + dataSource, + marketPrice: + historicalData[symbol]?.[format(date, DATE_FORMAT)]?.marketPrice + }; + } else { + let earlierDate = date; + let index = 0; + while ( + !isNumber( + historicalData[symbol]?.[format(earlierDate, DATE_FORMAT)] + ?.marketPrice + ) + ) { + earlierDate = addDays(earlierDate, -1); + index++; + if (index > 10) { + break; + } + } + if ( + isNumber( + historicalData[symbol]?.[format(earlierDate, DATE_FORMAT)] + ?.marketPrice + ) + ) { + return { + date, + symbol, + dataSource, + marketPrice: + historicalData[symbol]?.[format(earlierDate, DATE_FORMAT)] + ?.marketPrice + }; + } + } + }); + } } diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts index 0f1537d02..8d8c5e06a 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts @@ -13,6 +13,8 @@ import { DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME, GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS, + GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME, + GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS, PROPERTY_BENCHMARKS } from '@ghostfolio/common/config'; import { @@ -111,6 +113,24 @@ export class DataGatheringService { }); } + public async gatherSymbolMissingOnly({ + dataSource, + symbol + }: AssetProfileIdentifier) { + const dataGatheringItems = (await this.getSymbolsMax()).filter( + (dataGatheringItem) => { + return ( + dataGatheringItem.dataSource === dataSource && + dataGatheringItem.symbol === symbol + ); + } + ); + await this.gatherMissingDataSymbols({ + dataGatheringItems, + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + }); + } + public async gatherSymbolForDate({ dataSource, date, @@ -297,6 +317,35 @@ export class DataGatheringService { ); } + public async gatherMissingDataSymbols({ + dataGatheringItems, + priority + }: { + dataGatheringItems: IDataGatheringItem[]; + priority: number; + }) { + await this.addJobsToQueue( + dataGatheringItems.map(({ dataSource, date, symbol }) => { + return { + data: { + dataSource, + date, + symbol + }, + name: GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME, + opts: { + ...GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS, + priority, + jobId: `${getAssetProfileIdentifier({ + dataSource, + symbol + })}-missing-${format(date, DATE_FORMAT)}` + } + }; + }) + ); + } + public async getAllActiveAssetProfileIdentifiers(): Promise< AssetProfileIdentifier[] > { diff --git a/apps/api/src/services/symbol-profile/symbol-profile-overwrite.module.ts b/apps/api/src/services/symbol-profile/symbol-profile-overwrite.module.ts new file mode 100644 index 000000000..9ebd3e2b1 --- /dev/null +++ b/apps/api/src/services/symbol-profile/symbol-profile-overwrite.module.ts @@ -0,0 +1,12 @@ +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; + +import { Module } from '@nestjs/common'; + +import { SymbolProfileOverwriteService } from './symbol-profile-overwrite.service'; + +@Module({ + imports: [PrismaModule], + providers: [SymbolProfileOverwriteService], + exports: [SymbolProfileOverwriteService] +}) +export class SymbolProfileOverwriteModule {} diff --git a/apps/api/src/services/symbol-profile/symbol-profile-overwrite.service.ts b/apps/api/src/services/symbol-profile/symbol-profile-overwrite.service.ts new file mode 100644 index 000000000..bb43a0756 --- /dev/null +++ b/apps/api/src/services/symbol-profile/symbol-profile-overwrite.service.ts @@ -0,0 +1,69 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; + +import { Injectable } from '@nestjs/common'; +import { DataSource, Prisma, SymbolProfileOverrides } from '@prisma/client'; + +@Injectable() +export class SymbolProfileOverwriteService { + public constructor(private readonly prismaService: PrismaService) {} + + public async add( + assetProfileOverwrite: Prisma.SymbolProfileOverridesCreateInput + ): Promise { + return this.prismaService.symbolProfileOverrides.create({ + data: assetProfileOverwrite + }); + } + + public async delete(symbolProfileId: string) { + return this.prismaService.symbolProfileOverrides.delete({ + where: { symbolProfileId: symbolProfileId } + }); + } + + public updateSymbolProfileOverrides({ + assetClass, + assetSubClass, + name, + countries, + sectors, + url, + symbolProfileId + }: Prisma.SymbolProfileOverridesUpdateInput & { symbolProfileId: string }) { + return this.prismaService.symbolProfileOverrides.update({ + data: { + assetClass, + assetSubClass, + name, + countries, + sectors, + url + }, + where: { symbolProfileId: symbolProfileId } + }); + } + + public async GetSymbolProfileId( + Symbol: string, + datasource: DataSource + ): Promise { + const SymbolProfileId = await this.prismaService.symbolProfile + .findFirst({ + where: { + symbol: Symbol, + dataSource: datasource + } + }) + .then((s) => s.id); + + const symbolProfileIdSaved = await this.prismaService.symbolProfileOverrides + .findFirst({ + where: { + symbolProfileId: SymbolProfileId + } + }) + .then((s) => s?.symbolProfileId); + + return symbolProfileIdSaved; + } +} 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 c41a59c78..81e731a19 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -1,3 +1,4 @@ +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { @@ -10,31 +11,19 @@ import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Injectable } from '@nestjs/common'; -import { Prisma, SymbolProfile, SymbolProfileOverrides } from '@prisma/client'; +import { + Prisma, + SymbolProfile, + SymbolProfileOverrides, + Tag +} from '@prisma/client'; import { continents, countries } from 'countries-list'; @Injectable() export class SymbolProfileService { public constructor(private readonly prismaService: PrismaService) {} - public async add( - assetProfile: Prisma.SymbolProfileCreateInput - ): Promise { - return this.prismaService.symbolProfile.create({ data: assetProfile }); - } - - public async delete({ dataSource, symbol }: AssetProfileIdentifier) { - return this.prismaService.symbolProfile.delete({ - where: { dataSource_symbol: { dataSource, symbol } } - }); - } - - public async deleteById(id: string) { - return this.prismaService.symbolProfile.delete({ - where: { id } - }); - } - + @LogPerformance public async getActiveSymbolProfilesByUserSubscription({ withUserSubscription = false }: { @@ -70,6 +59,7 @@ export class SymbolProfileService { }); } + @LogPerformance public async getSymbolProfiles( aAssetProfileIdentifiers: AssetProfileIdentifier[] ): Promise { @@ -86,6 +76,7 @@ export class SymbolProfileService { select: { date: true }, take: 1 }, + tags: true, SymbolProfileOverrides: true }, where: { @@ -102,6 +93,24 @@ export class SymbolProfileService { }); } + public async add( + assetProfile: Prisma.SymbolProfileCreateInput + ): Promise { + return this.prismaService.symbolProfile.create({ data: assetProfile }); + } + + public async delete({ dataSource, symbol }: AssetProfileIdentifier) { + return this.prismaService.symbolProfile.delete({ + where: { dataSource_symbol: { dataSource, symbol } } + }); + } + + public async deleteById(id: string) { + return this.prismaService.symbolProfile.delete({ + where: { id } + }); + } + public async getSymbolProfilesByIds( symbolProfileIds: string[] ): Promise { @@ -111,7 +120,8 @@ export class SymbolProfileService { _count: { select: { activities: true } }, - SymbolProfileOverrides: true + SymbolProfileOverrides: true, + tags: true }, where: { id: { @@ -155,6 +165,7 @@ export class SymbolProfileService { holdings, isActive, name, + tags, scraperConfiguration, sectors, symbolMapping, @@ -172,6 +183,7 @@ export class SymbolProfileService { holdings, isActive, name, + tags, scraperConfiguration, sectors, symbolMapping, @@ -188,6 +200,7 @@ export class SymbolProfileService { activities?: { date: Date; }[]; + tags?: Tag[]; SymbolProfileOverrides: SymbolProfileOverrides; })[] ): EnhancedSymbolProfile[] { @@ -206,7 +219,8 @@ export class SymbolProfileService { sectors: this.getSectors( symbolProfile?.sectors as unknown as Prisma.JsonArray ), - symbolMapping: this.getSymbolMapping(symbolProfile) + symbolMapping: this.getSymbolMapping(symbolProfile), + tags: symbolProfile?.tags }; item.activitiesCount = symbolProfile._count.activities; diff --git a/apps/api/src/services/tag/tag.service.ts b/apps/api/src/services/tag/tag.service.ts index eb2d7bfef..6ee840513 100644 --- a/apps/api/src/services/tag/tag.service.ts +++ b/apps/api/src/services/tag/tag.service.ts @@ -56,7 +56,8 @@ export class TagService { where: { userId } - } + }, + symbolProfile: {} } } }, @@ -75,11 +76,11 @@ export class TagService { } }); - return tags.map(({ _count, id, name, userId }) => ({ + return tags.map(({ id, name, userId }) => ({ id, name, userId, - isUsed: _count.activities > 0 + isUsed: true })); } diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts index dcd9ff72c..b56aff09b 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts @@ -261,6 +261,17 @@ export class AdminMarketDataComponent }); } + public onGatherMissing() { + this.adminService + .gatherMissingOnly() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + setTimeout(() => { + window.location.reload(); + }, 300); + }); + } + public onGatherProfileData() { this.adminService .gatherProfileData() 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 bfa6d550e..76e647126 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 @@ -231,6 +231,9 @@ >Gather All Historical Market Data + diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index d9b344699..a653e85ee 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts @@ -15,6 +15,7 @@ import { } from '@ghostfolio/common/interfaces'; import { translate } from '@ghostfolio/ui/i18n'; +import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, @@ -34,6 +35,7 @@ import { ValidationErrors, Validators } from '@angular/forms'; +import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { MatCheckboxChange } from '@angular/material/checkbox'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -41,7 +43,8 @@ import { AssetClass, AssetSubClass, MarketData, - SymbolProfile + SymbolProfile, + Tag } from '@prisma/client'; import { format } from 'date-fns'; import { StatusCodes } from 'http-status-codes'; @@ -60,6 +63,8 @@ import { AssetProfileDialogParams } from './interfaces/interfaces'; standalone: false }) export class AssetProfileDialog implements OnDestroy, OnInit { + @ViewChild('tagInput') tagInput: ElementRef; + public separatorKeysCodes: number[] = [ENTER, COMMA]; private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format( new Date(), DATE_FORMAT @@ -91,6 +96,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit { }), isActive: [true], name: ['', Validators.required], + tags: new FormControl(undefined), + tagsDisconnected: new FormControl(undefined), scraperConfiguration: this.formBuilder.group({ defaultMarketPrice: null, headers: JSON.stringify({}), @@ -149,6 +156,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit { [name: string]: { name: string; value: number }; }; + public HoldingTags: { id: string; name: string; userId: string }[]; + public user: User; private unsubscribeSubject = new Subject(); @@ -188,6 +197,18 @@ export class AssetProfileDialog implements OnDestroy, OnInit { } public initialize() { + this.dataService + .fetchTags() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((tags) => { + this.HoldingTags = tags.map(({ id, name, userId }) => { + return { id, name, userId }; + }); + this.dataService.updateInfo(); + + this.changeDetectorRef.markForCheck(); + }); + this.historicalDataItems = undefined; this.userService.stateChanged @@ -247,6 +268,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit { assetClass: this.assetProfile.assetClass ?? null, assetSubClass: this.assetProfile.assetSubClass ?? null, comment: this.assetProfile?.comment ?? '', + tags: this.assetProfile?.tags ?? [], + tagsDisconnected: [], countries: JSON.stringify( this.assetProfile?.countries?.map(({ code, weight }) => { return { code, weight }; @@ -272,7 +295,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit { }, sectors: JSON.stringify(this.assetProfile?.sectors ?? []), symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {}), - url: this.assetProfile?.url ?? '' + url: this.assetProfile?.url }); this.assetProfileForm.markAsPristine(); @@ -316,6 +339,16 @@ export class AssetProfileDialog implements OnDestroy, OnInit { .subscribe(); } + public onGatherSymbolMissingOnly({ + dataSource, + symbol + }: AssetProfileIdentifier) { + this.adminService + .gatherSymbolMissingOnly({ dataSource, symbol }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(); + } + public onMarketDataChanged(withRefresh: boolean = false) { if (withRefresh) { this.initialize(); @@ -397,10 +430,12 @@ export class AssetProfileDialog implements OnDestroy, OnInit { assetClass: this.assetProfileForm.get('assetClass').value, assetSubClass: this.assetProfileForm.get('assetSubClass').value, comment: this.assetProfileForm.get('comment').value || null, + tags: this.assetProfileForm.get('tags').value, + tagsDisconnected: this.assetProfileForm.get('tagsDisconnected').value, currency: this.assetProfileForm.get('currency').value, isActive: this.assetProfileForm.get('isActive').value, name: this.assetProfileForm.get('name').value, - url: this.assetProfileForm.get('url').value || null + url: this.assetProfileForm.get('url').value }; try { @@ -569,6 +604,30 @@ export class AssetProfileDialog implements OnDestroy, OnInit { }); } + public onRemoveTag(aTag: Tag) { + this.assetProfileForm.controls['tags'].setValue( + this.assetProfileForm.controls['tags'].value.filter(({ id }) => { + return id !== aTag.id; + }) + ); + this.assetProfileForm.controls['tagsDisconnected'].setValue([ + ...(this.assetProfileForm.controls['tagsDisconnected'].value ?? []), + aTag + ]); + this.assetProfileForm.markAsDirty(); + } + + public onAddTag(event: MatAutocompleteSelectedEvent) { + this.assetProfileForm.controls['tags'].setValue([ + ...(this.assetProfileForm.controls['tags'].value ?? []), + this.HoldingTags.find(({ id }) => { + return id === event.option.value; + }) + ]); + this.tagInput.nativeElement.value = ''; + this.assetProfileForm.markAsDirty(); + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index 4c7d39fc0..499ec746b 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html @@ -34,6 +34,19 @@ [disabled]=" assetProfileForm.dirty || !assetProfileForm.controls.isActive.value " + (click)=" + onGatherSymbolMissingOnly({ + dataSource: data.dataSource, + symbol: data.symbol + }) + " + > + Gather Missing Historical Data + +