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 c4c8a0d35..000000000 --- a/.env.dev +++ /dev/null @@ -1,25 +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 - -NX_NATIVE_COMMAND_RUNNER=false diff --git a/.env.example b/.env.example index 766894992..8df547e37 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -COMPOSE_PROJECT_NAME=ghostfolio +COMPOSE_PROJECT_NAME=ghostfolio-development # CACHE REDIS_HOST=localhost @@ -10,7 +10,6 @@ POSTGRES_DB=ghostfolio-db POSTGRES_USER=user POSTGRES_PASSWORD= -# VARIOUS ACCESS_TOKEN_SALT= DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer JWT_SECRET_KEY= diff --git a/.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 47943977f..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: 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/CHANGELOG.md b/CHANGELOG.md index 8f3628ca0..33f1064ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 + +### Changed + - Extended the assistant by a holding selector - Separated the _FIRE_ / _X-ray_ page - Improved the language localization for Italian (`it`) @@ -1452,9 +1539,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 e6c38f273..0e5c0d275 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,6 +61,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 df369859b..7b9b09c0c 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'; @@ -148,6 +149,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 e0444d112..d8c05b0cc 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -105,6 +105,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.getAllAssetProfileIdentifiers(); + + 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) @@ -158,7 +175,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; } @@ -335,7 +367,12 @@ export class AdminController { return this.adminService.patchAssetProfileData({ ...assetProfileData, dataSource, - symbol + symbol, + tags: { + connect: assetProfileData.tags?.map(({ id }) => { + return { id }; + }) + } }); } diff --git a/apps/api/src/app/admin/admin.module.ts b/apps/api/src/app/admin/admin.module.ts index 81c58ff03..55acd194c 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'; QueueModule, SubscriptionModule, 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 49964c77d..112d774e7 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -36,11 +36,11 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { AssetClass, AssetSubClass, - DataSource, Prisma, PrismaClient, Property, - SymbolProfile + SymbolProfile, + DataSource } from '@prisma/client'; import { differenceInDays } from 'date-fns'; import { groupBy } from 'lodash'; @@ -259,7 +259,8 @@ export class AdminService { }, scraperConfiguration: true, sectors: true, - symbol: true + symbol: true, + tags: true } }), this.prismaService.symbolProfile.count({ where }) @@ -313,7 +314,8 @@ export class AdminService { name, Order, sectors, - symbol + symbol, + tags }) => { const countriesCount = countries ? Object.keys(countries).length @@ -348,7 +350,9 @@ export class AdminService { sectorsCount, activitiesCount: _count.Order, date: Order?.[0]?.date, - isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription + isUsedByUsersWithSubscription: + await isUsedByUsersWithSubscription, + tags }; } ) @@ -442,6 +446,7 @@ export class AdminService { dataSource, holdings, name, + tags, scraperConfiguration, sectors, symbol, @@ -466,6 +471,7 @@ export class AdminService { sectors, symbol, symbolMapping, + tags, ...(dataSource === 'MANUAL' ? { assetClass, assetSubClass, name, url } : { @@ -631,7 +637,8 @@ export class AdminService { date: dateOfFirstActivity, id: undefined, name: symbol, - sectorsCount: 0 + sectorsCount: 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 8c9ae220b..f24b4501a 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,6 @@ import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; -import { AssetClass, AssetSubClass, Prisma } from '@prisma/client'; +import { AssetClass, AssetSubClass, Prisma, Tag } from '@prisma/client'; import { IsArray, IsEnum, @@ -35,6 +35,10 @@ export class UpdateAssetProfileDto { @IsOptional() name?: string; + @IsArray() + @IsOptional() + tags?: 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 30415970d..5e676c0a6 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -618,18 +618,17 @@ 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}")` ); } - - if (assetProfile.currency !== currency) { - throw new Error( - `activities.${index}.currency ("${currency}") does not match with currency of ${assetProfile.symbol} ("${assetProfile.currency}")` - ); - } } assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] = diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 5613adc5c..18f996a52 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -27,7 +27,8 @@ import { Order, Prisma, Tag, - Type as ActivityType + Type as ActivityType, + SymbolProfile } from '@prisma/client'; import { Big } from 'big.js'; import { endOfToday, isAfter } from 'date-fns'; @@ -50,34 +51,40 @@ export class OrderService { public async assignTags({ dataSource, symbol, - tags, - userId + tags }: { tags: Tag[]; userId: string } & AssetProfileIdentifier) { - const orders = await this.prismaService.order.findMany({ - where: { - userId, - SymbolProfile: { + const symbolProfile: SymbolProfile = + await this.symbolProfileService.getSymbolProfiles([ + { dataSource, symbol } - } - }); - - return Promise.all( - orders.map(({ id }) => - this.prismaService.order.update({ - data: { - tags: { - // The set operation replaces all existing connections with the provided ones - set: tags.map(({ id }) => { - return { id }; - }) + ])[0]; + return await this.symbolProfileService.updateSymbolProfile({ + assetClass: symbolProfile.assetClass, + assetSubClass: symbolProfile.assetSubClass, + countries: symbolProfile.countries, + currency: symbolProfile.currency, + dataSource, + holdings: symbolProfile.holdings, + name: symbolProfile.name, + sectors: symbolProfile.sectors, + symbol, + tags: { + connectOrCreate: tags.map(({ id, name }) => { + return { + create: { + id, + name + }, + where: { + id } - }, - where: { id } + }; }) - ) - ); + }, + url: symbolProfile.url + }); } public async createOrder( @@ -297,6 +304,7 @@ export class OrderService { }); } + @LogPerformance public async getOrders({ endDate, filters, @@ -451,13 +459,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) { @@ -489,7 +518,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/constantPortfolioReturn/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts new file mode 100644 index 000000000..988e01425 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts @@ -0,0 +1,595 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; +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 { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; +import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces'; + +import { Inject, Logger } from '@nestjs/common'; +import { Big } from 'big.js'; +import { + addDays, + eachDayOfInterval, + endOfDay, + format, + isAfter, + isBefore, + subDays +} from 'date-fns'; + +import { CurrentRateService } from '../../current-rate.service'; +import { DateQuery } from '../../interfaces/date-query.interface'; +import { PortfolioOrder } from '../../interfaces/portfolio-order.interface'; +import { TWRPortfolioCalculator } from '../twr/portfolio-calculator'; + +export class CPRPortfolioCalculator extends TWRPortfolioCalculator { + private holdings: { [date: string]: { [symbol: string]: Big } } = {}; + private holdingCurrencies: { [symbol: string]: string } = {}; + + constructor( + { + accountBalanceItems, + activities, + configurationService, + currency, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService, + userId, + filters + }: { + accountBalanceItems: HistoricalDataItem[]; + activities: Activity[]; + configurationService: ConfigurationService; + currency: string; + currentRateService: CurrentRateService; + exchangeRateDataService: ExchangeRateDataService; + portfolioSnapshotService: PortfolioSnapshotService; + redisCacheService: RedisCacheService; + filters: Filter[]; + userId: string; + }, + @Inject() + private orderService: OrderService + ) { + super({ + accountBalanceItems, + activities, + configurationService, + currency, + filters, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService, + userId + }); + } + + @LogPerformance + public async getPerformanceWithTimeWeightedReturn({ + start, + end + }: { + start: Date; + end: Date; + }): Promise<{ chart: HistoricalDataItem[] }> { + const item = await super.getPerformance({ + end, + start + }); + + const itemResult = item.chart; + const dates = itemResult.map((item) => parseDate(item.date)); + const timeWeighted = await this.getTimeWeightedChartData({ + dates + }); + + item.chart = itemResult.map((itemInt) => { + const timeWeightedItem = timeWeighted.find( + (timeWeightedItem) => timeWeightedItem.date === itemInt.date + ); + if (timeWeightedItem) { + itemInt.timeWeightedPerformance = + timeWeightedItem.netPerformanceInPercentage; + itemInt.timeWeightedPerformanceWithCurrencyEffect = + timeWeightedItem.netPerformanceInPercentageWithCurrencyEffect; + } + + return itemInt; + }); + return item; + } + + @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; + }, + {} + ); + + const totalInvestment = await 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) + ); + return totalInvestment; + } + + @LogPerformance + protected async getTimeWeightedChartData({ + dates + }: { + dates?: Date[]; + }): Promise { + dates = dates.sort((a, b) => a.getTime() - b.getTime()); + const start = dates[0]; + const end = dates[dates.length - 1]; + const marketMapTask = this.computeMarketMap({ + gte: start, + lt: addDays(end, 1) + }); + const timelineHoldings = await this.getHoldings( + this.activities, + start, + end + ); + + const data: HistoricalDataItem[] = []; + const startString = format(start, DATE_FORMAT); + + data.push({ + date: startString, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + this.marketMap = await marketMapTask; + + let totalInvestment = Object.keys(timelineHoldings[startString]).reduce( + (sum, holding) => { + return sum.plus( + timelineHoldings[startString][holding].mul( + this.marketMap[startString][holding] ?? new Big(0) + ) + ); + }, + new Big(0) + ); + + let previousNetPerformanceInPercentage = new Big(0); + let previousNetPerformanceInPercentageWithCurrencyEffect = new Big(0); + + for (let i = 1; i < dates.length; i++) { + const date = format(dates[i], DATE_FORMAT); + const previousDate = format(dates[i - 1], DATE_FORMAT); + const holdings = timelineHoldings[previousDate]; + let newTotalInvestment = new Big(0); + let netPerformanceInPercentage = new Big(0); + let netPerformanceInPercentageWithCurrencyEffect = new Big(0); + + for (const holding of Object.keys(holdings)) { + ({ + netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, + newTotalInvestment + } = await this.handleSingleHolding( + previousDate, + holding, + date, + totalInvestment, + timelineHoldings, + netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, + newTotalInvestment + )); + } + totalInvestment = newTotalInvestment; + + previousNetPerformanceInPercentage = previousNetPerformanceInPercentage + .plus(1) + .mul(netPerformanceInPercentage.plus(1)) + .minus(1); + previousNetPerformanceInPercentageWithCurrencyEffect = + previousNetPerformanceInPercentageWithCurrencyEffect + .plus(1) + .mul(netPerformanceInPercentageWithCurrencyEffect.plus(1)) + .minus(1); + + data.push({ + date, + netPerformanceInPercentage: previousNetPerformanceInPercentage + .mul(100) + .toNumber(), + netPerformanceInPercentageWithCurrencyEffect: + previousNetPerformanceInPercentageWithCurrencyEffect + .mul(100) + .toNumber() + }); + } + + return data; + } + + @LogPerformance + protected async handleSingleHolding( + previousDate: string, + holding: string, + date: string, + totalInvestment: Big, + timelineHoldings: { [date: string]: { [symbol: string]: Big } }, + netPerformanceInPercentage: Big, + netPerformanceInPercentageWithCurrencyEffect: Big, + newTotalInvestment: Big + ) { + const previousPrice = + Object.keys(this.marketMap).indexOf(previousDate) > 0 + ? this.marketMap[previousDate][holding] + : undefined; + const priceDictionary = this.marketMap[date]; + let currentPrice = + priceDictionary !== undefined ? priceDictionary[holding] : previousPrice; + currentPrice ??= previousPrice; + const previousHolding = timelineHoldings[previousDate][holding]; + + const priceInBaseCurrency = currentPrice + ? new Big( + await this.exchangeRateDataService.toCurrencyAtDate( + currentPrice?.toNumber() ?? 0, + this.getCurrency(holding), + this.currency, + parseDate(date) + ) + ) + : new Big(0); + + if (previousHolding.eq(0)) { + return { + netPerformanceInPercentage: netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect: + netPerformanceInPercentageWithCurrencyEffect, + newTotalInvestment: newTotalInvestment.plus( + timelineHoldings[date][holding].mul(priceInBaseCurrency) + ) + }; + } + if (previousPrice === undefined || currentPrice === undefined) { + Logger.warn( + `Missing historical market data for ${holding} (${previousPrice === undefined ? previousDate : date}})`, + 'PortfolioCalculator' + ); + return { + netPerformanceInPercentage: netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect: + netPerformanceInPercentageWithCurrencyEffect, + newTotalInvestment: newTotalInvestment.plus( + timelineHoldings[date][holding].mul(priceInBaseCurrency) + ) + }; + } + const previousPriceInBaseCurrency = previousPrice + ? new Big( + await this.exchangeRateDataService.toCurrencyAtDate( + previousPrice?.toNumber() ?? 0, + this.getCurrency(holding), + this.currency, + parseDate(previousDate) + ) + ) + : new Big(0); + const portfolioWeight = totalInvestment.toNumber() + ? previousHolding.mul(previousPriceInBaseCurrency).div(totalInvestment) + : new Big(0); + + netPerformanceInPercentage = netPerformanceInPercentage.plus( + currentPrice.div(previousPrice).minus(1).mul(portfolioWeight) + ); + + netPerformanceInPercentageWithCurrencyEffect = + netPerformanceInPercentageWithCurrencyEffect.plus( + priceInBaseCurrency + .div(previousPriceInBaseCurrency) + .minus(1) + .mul(portfolioWeight) + ); + + newTotalInvestment = newTotalInvestment.plus( + timelineHoldings[date][holding].mul(priceInBaseCurrency) + ); + return { + netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, + newTotalInvestment + }; + } + + @LogPerformance + protected getCurrency(symbol: string) { + return this.getCurrencyFromActivities(this.activities, symbol); + } + + @LogPerformance + protected getCurrencyFromActivities( + activities: PortfolioOrder[], + symbol: string + ) { + if (!this.holdingCurrencies[symbol]) { + this.holdingCurrencies[symbol] = activities.find( + (a) => a.SymbolProfile.symbol === symbol + ).SymbolProfile.currency; + } + + return this.holdingCurrencies[symbol]; + } + + @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); + } + + 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; + } + + @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 + ); + } + + @LogPerformance + protected async computeMarketMap(dateQuery: DateQuery): Promise<{ + [date: string]: { [symbol: string]: Big }; + }> { + const dataGatheringItems: IDataGatheringItem[] = + this.mapToDataGatheringItems(this.activities); + const { values: marketSymbols } = await this.currentRateService.getValues({ + dataGatheringItems, + dateQuery + }); + + const marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + } = {}; + + for (const marketSymbol of marketSymbols) { + const date = format(marketSymbol.date, DATE_FORMAT); + + if (!marketSymbolMap[date]) { + marketSymbolMap[date] = {}; + } + + if (marketSymbol.marketPrice) { + marketSymbolMap[date][marketSymbol.symbol] = new Big( + marketSymbol.marketPrice + ); + } + } + + return marketSymbolMap; + } + + @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); + }); + } +} 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 18738373e..2f028d84d 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'; @@ -8,13 +9,15 @@ import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; +import { OrderService } from '../../order/order.service'; +import { CPRPortfolioCalculator } from './constantPortfolioReturn/portfolio-calculator'; import { MWRPortfolioCalculator } from './mwr/portfolio-calculator'; import { PortfolioCalculator } from './portfolio-calculator'; -import { TWRPortfolioCalculator } from './twr/portfolio-calculator'; export enum PerformanceCalculationType { MWR = 'MWR', // Money-Weighted Rate of Return - TWR = 'TWR' // Time-Weighted Rate of Return + TWR = 'TWR', // Time-Weighted Rate of Return + CPR = 'CPR' // Constant Portfolio Rate of Return } @Injectable() @@ -24,9 +27,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, @@ -57,18 +62,37 @@ export class PortfolioCalculatorFactory { redisCacheService: this.redisCacheService }); case PerformanceCalculationType.TWR: - return new TWRPortfolioCalculator({ - accountBalanceItems, - activities, - currency, - currentRateService: this.currentRateService, - filters, - userId, - configurationService: this.configurationService, - exchangeRateDataService: this.exchangeRateDataService, - portfolioSnapshotService: this.portfolioSnapshotService, - redisCacheService: this.redisCacheService - }); + return new CPRPortfolioCalculator( + { + accountBalanceItems, + activities, + currency, + currentRateService: this.currentRateService, + userId, + configurationService: this.configurationService, + exchangeRateDataService: this.exchangeRateDataService, + portfolioSnapshotService: this.portfolioSnapshotService, + redisCacheService: this.redisCacheService, + filters + }, + this.orderService + ); + case PerformanceCalculationType.CPR: + return new CPRPortfolioCalculator( + { + accountBalanceItems, + activities, + currency, + currentRateService: this.currentRateService, + userId, + configurationService: this.configurationService, + exchangeRateDataService: this.exchangeRateDataService, + portfolioSnapshotService: this.portfolioSnapshotService, + redisCacheService: this.redisCacheService, + filters + }, + this.orderService + ); default: throw new Error('Invalid calculation type'); } diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index dec0e6387..30f6ec264 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -57,12 +57,12 @@ export abstract class PortfolioCalculator { protected accountBalanceItems: HistoricalDataItem[]; protected activities: PortfolioOrder[]; - private configurationService: ConfigurationService; - private currency: string; - private currentRateService: CurrentRateService; + protected configurationService: ConfigurationService; + protected currency: string; + protected currentRateService: CurrentRateService; private dataProviderInfos: DataProviderInfo[]; private endDate: Date; - private exchangeRateDataService: ExchangeRateDataService; + protected exchangeRateDataService: ExchangeRateDataService; private filters: Filter[]; private portfolioSnapshotService: PortfolioSnapshotService; private redisCacheService: RedisCacheService; @@ -70,7 +70,8 @@ export abstract class PortfolioCalculator { private snapshotPromise: Promise; private startDate: Date; private transactionPoints: TransactionPoint[]; - private userId: string; + protected userId: string; + protected marketMap: { [date: string]: { [symbol: string]: Big } } = {}; public constructor({ accountBalanceItems, @@ -620,10 +621,12 @@ export abstract class PortfolioCalculator { }; } + @LogPerformance public getDataProviderInfos() { return this.dataProviderInfos; } + @LogPerformance public async getDividendInBaseCurrency() { await this.snapshotPromise; @@ -634,18 +637,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 []; @@ -663,6 +669,7 @@ export abstract class PortfolioCalculator { }); } + @LogPerformance public getInvestmentsByGroup({ data, groupBy @@ -686,12 +693,14 @@ export abstract class PortfolioCalculator { })); } + @LogPerformance public async getLiabilitiesInBaseCurrency() { await this.snapshotPromise; return this.snapshot.totalLiabilitiesWithCurrencyEffect; } + @LogPerformance public async getPerformance({ end, start }) { await this.snapshotPromise; @@ -760,6 +769,7 @@ export abstract class PortfolioCalculator { return { chart }; } + @LogPerformance public async getSnapshot() { await this.snapshotPromise; @@ -815,6 +825,7 @@ export abstract class PortfolioCalculator { return this.transactionPoints; } + @LogPerformance public async getValuablesInBaseCurrency() { await this.snapshotPromise; @@ -890,7 +901,7 @@ export abstract class PortfolioCalculator { } @LogPerformance - private computeTransactionPoints() { + protected computeTransactionPoints() { this.transactionPoints = []; const symbols: { [symbol: string]: TransactionPointSymbol } = {}; @@ -1029,7 +1040,7 @@ export abstract class PortfolioCalculator { } @LogPerformance - private async initialize() { + protected async initialize() { const startTimeTotal = performance.now(); let cachedPortfolioSnapshot: PortfolioSnapshot; diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts index 37499f0e3..3ffe0bfbd 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts @@ -80,7 +80,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts index 23c594e5b..86110b11f 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -80,7 +80,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts index 90f6a59d1..b83479ef8 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts @@ -80,7 +80,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index e232b42c4..d321f104e 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -93,7 +93,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts index fe379a92a..a0784542f 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts @@ -80,7 +80,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts index 60fe6dc6b..501b97bf7 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts @@ -93,7 +93,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts index 228568374..3be884154 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts @@ -80,7 +80,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts index 5fa90e94c..845b798cd 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts @@ -79,7 +79,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts index ba1cbeb3c..3cb84ca14 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -93,7 +93,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); @@ -155,25 +156,25 @@ describe('PortfolioCalculator', () => { dividendInBaseCurrency: new Big('0.62'), fee: new Big('19'), firstBuyDate: '2021-09-16', - grossPerformance: new Big('33.25'), - grossPerformancePercentage: new Big('0.11136043941322258691'), + grossPerformance: new Big('33.87'), + grossPerformancePercentage: new Big('0.11343693482483756447'), grossPerformancePercentageWithCurrencyEffect: new Big( - '0.11136043941322258691' + '0.11343693482483756447' ), - grossPerformanceWithCurrencyEffect: new Big('33.25'), + 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.25'), - netPerformancePercentage: new Big('0.04772590260566682296'), + netPerformance: new Big('14.87'), + netPerformancePercentage: new Big('0.04980239801728180052'), netPerformancePercentageWithCurrencyEffectMap: { - max: new Big('0.04772590260566682296') + max: new Big('0.04980239801728180052') }, netPerformanceWithCurrencyEffectMap: { '1d': new Big('-5.39'), - '5y': new Big('14.25'), - max: new Big('14.25'), + '5y': new Big('14.87'), + max: new Big('14.87'), wtd: new Big('-5.39') }, quantity: new Big('1'), diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts index 84898490f..bcd33a2ad 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts @@ -74,7 +74,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts index f9f99ee45..888d1e968 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -94,7 +94,8 @@ describe('PortfolioCalculator', () => { currentRateService, exchangeRateDataService, portfolioSnapshotService, - redisCacheService + redisCacheService, + null ); }); diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts index 66cdb9e8e..575d01dc6 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -1,253 +1,254 @@ -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 { - PerformanceCalculationType, - PortfolioCalculatorFactory -} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; -import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; -import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { 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 { Big } from 'big.js'; -import { last } from 'lodash'; -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), - SymbolProfile: { - ...symbolProfileDummyData, - currency: activity.currency, - dataSource: activity.dataSource, - name: 'Novartis AG', - symbol: activity.symbol - } - })); - - const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ - activities, - calculationType: PerformanceCalculationType.TWR, - 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 - }); - - expect(portfolioSnapshot.historicalData[1]).toEqual({ - date: '2022-03-07', - investmentValueWithCurrencyEffect: 151.6, - netPerformance: 0, - netPerformanceInPercentage: 0, - netPerformanceInPercentageWithCurrencyEffect: 0, - netPerformanceWithCurrencyEffect: 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, - 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(last(portfolioSnapshot.historicalData)).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 { + PerformanceCalculationType, + PortfolioCalculatorFactory +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { 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 { Big } from 'big.js'; +import { last } from 'lodash'; +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), + SymbolProfile: { + ...symbolProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: 'Novartis AG', + symbol: activity.symbol + } + })); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.TWR, + 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 + }); + + expect(portfolioSnapshot.historicalData[1]).toEqual({ + date: '2022-03-07', + investmentValueWithCurrencyEffect: 151.6, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 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, + 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(last(portfolioSnapshot.historicalData)).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/twr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts index 6c0d230b0..3474a4e7e 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts @@ -137,6 +137,8 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0); let grossPerformanceFromSells = new Big(0); let grossPerformanceFromSellsWithCurrencyEffect = new Big(0); + let grossPerformanceFromDividends = new Big(0); + let grossPerformanceFromDividendsWithCurrencyEffect = new Big(0); let initialValue: Big; let initialValueWithCurrencyEffect: Big; let investmentAtStartDate: Big; @@ -198,6 +200,8 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { investmentValuesWithCurrencyEffect: {}, netPerformance: new Big(0), netPerformancePercentage: new Big(0), + netPerformanceValuesPercentage: {}, + unitPrices: {}, netPerformancePercentageWithCurrencyEffectMap: {}, netPerformanceValues: {}, netPerformanceValuesWithCurrencyEffect: {}, @@ -266,7 +270,9 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { totalLiabilities: new Big(0), totalLiabilitiesInBaseCurrency: new Big(0), totalValuables: new Big(0), - totalValuablesInBaseCurrency: new Big(0) + totalValuablesInBaseCurrency: new Big(0), + netPerformanceValuesPercentage: {}, + unitPrices: {} }; } @@ -499,6 +505,13 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { .mul(order.quantity) .mul(getFactor(order.type)); } + } else if (order.type === 'STAKE') { + transactionInvestment = new Big(0); + + transactionInvestmentWithCurrencyEffect = new Big(0); + + totalQuantityFromBuyTransactions = + totalQuantityFromBuyTransactions.plus(order.quantity); } if (PortfolioCalculator.ENABLE_LOGGING) { @@ -554,28 +567,27 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { order.unitPriceInBaseCurrencyWithCurrencyEffect ); - const grossPerformanceFromSell = - order.type === 'SELL' - ? order.unitPriceInBaseCurrency - .minus(lastAveragePrice) - .mul(order.quantity) - : new Big(0); - - const grossPerformanceFromSellWithCurrencyEffect = - order.type === 'SELL' - ? order.unitPriceInBaseCurrencyWithCurrencyEffect - .minus(lastAveragePriceWithCurrencyEffect) - .mul(order.quantity) - : new Big(0); - - grossPerformanceFromSells = grossPerformanceFromSells.plus( - grossPerformanceFromSell - ); - - grossPerformanceFromSellsWithCurrencyEffect = - grossPerformanceFromSellsWithCurrencyEffect.plus( - grossPerformanceFromSellWithCurrencyEffect - ); + ({ + grossPerformanceFromSells, + grossPerformanceFromSellsWithCurrencyEffect + } = this.handleSellOrder( + order, + lastAveragePrice, + lastAveragePriceWithCurrencyEffect, + grossPerformanceFromSells, + grossPerformanceFromSellsWithCurrencyEffect + )); + + ({ + grossPerformanceFromDividends, + grossPerformanceFromDividendsWithCurrencyEffect + } = this.handleDividend( + order, + grossPerformanceFromDividends, + grossPerformanceFromDividendsWithCurrencyEffect, + currentExchangeRate, + exchangeRateAtOrderDate + )); lastAveragePrice = totalQuantityFromBuyTransactions.eq(0) ? new Big(0) @@ -597,19 +609,21 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { grossPerformanceFromSells.toNumber() ); console.log( - 'grossPerformanceFromSellWithCurrencyEffect', - grossPerformanceFromSellWithCurrencyEffect.toNumber() + 'grossPerformanceFromSellsWithCurrencyEffect', + grossPerformanceFromSellsWithCurrencyEffect.toNumber() ); } const newGrossPerformance = valueOfInvestment .minus(totalInvestment) - .plus(grossPerformanceFromSells); + .plus(grossPerformanceFromSells) + .plus(grossPerformanceFromDividends); const newGrossPerformanceWithCurrencyEffect = valueOfInvestmentWithCurrencyEffect .minus(totalInvestmentWithCurrencyEffect) - .plus(grossPerformanceFromSellsWithCurrencyEffect); + .plus(grossPerformanceFromSellsWithCurrencyEffect) + .plus(grossPerformanceFromDividendsWithCurrencyEffect); grossPerformance = newGrossPerformance; @@ -958,7 +972,72 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { timeWeightedInvestment: timeWeightedAverageInvestmentBetweenStartAndEndDate, timeWeightedInvestmentWithCurrencyEffect: - timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect, + netPerformanceValuesPercentage: {}, + unitPrices: {} + }; + } + + private handleSellOrder( + order: PortfolioOrderItem, + lastAveragePrice, + lastAveragePriceWithCurrencyEffect, + grossPerformanceFromSells, + grossPerformanceFromSellsWithCurrencyEffect + ) { + if (order.type === 'SELL') { + const grossPerformanceFromSell = order.unitPriceInBaseCurrency + .minus(lastAveragePrice) + .mul(order.quantity); + + const grossPerformanceFromSellWithCurrencyEffect = + order.unitPriceInBaseCurrencyWithCurrencyEffect + .minus(lastAveragePriceWithCurrencyEffect) + .mul(order.quantity); + + grossPerformanceFromSells = grossPerformanceFromSells.plus( + grossPerformanceFromSell + ); + + grossPerformanceFromSellsWithCurrencyEffect = + grossPerformanceFromSellsWithCurrencyEffect.plus( + grossPerformanceFromSellWithCurrencyEffect + ); + } + return { + grossPerformanceFromSells, + grossPerformanceFromSellsWithCurrencyEffect + }; + } + + private handleDividend( + order: PortfolioOrderItem, + grossPerformanceFromDividends, + grossPerformanceFromDividendsWithCurrencyEffect, + currentExchangeRate: number, + exchangeRateAtDateOfOrder: number + ) { + if (order.type === 'DIVIDEND') { + const grossPerformanceFromDividend = order.unitPrice + .mul(currentExchangeRate) + .mul(order.quantity); + + const grossPerformanceFromDividendWithCurrencyEffect = order.unitPrice + .mul(exchangeRateAtDateOfOrder) + .mul(order.quantity); + + grossPerformanceFromDividends = grossPerformanceFromDividends.plus( + grossPerformanceFromDividend + ); + + grossPerformanceFromDividendsWithCurrencyEffect = + grossPerformanceFromDividendsWithCurrencyEffect.plus( + grossPerformanceFromDividendWithCurrencyEffect + ); + } + return { + grossPerformanceFromDividends, + grossPerformanceFromDividendsWithCurrencyEffect }; } } diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts index 79e4d40dc..bf75dfd1a 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts @@ -11,6 +11,7 @@ export interface PortfolioHoldingDetail { averagePrice: number; dataProviderInfo: DataProviderInfo; dividendInBaseCurrency: number; + stakeRewards: number; dividendYieldPercent: number; dividendYieldPercentWithCurrencyEffect: number; feeInBaseCurrency: number; 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 f2415dff3..e33027fb6 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'; @@ -158,6 +161,24 @@ 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 + : []); } for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) { @@ -474,6 +495,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, @@ -482,10 +504,9 @@ export class PortfolioController { @Query('range') dateRange: DateRange = 'max', @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string, - @Query('withExcludedAccounts') withExcludedAccountsParam = 'false' + @Query('withExcludedAccounts') withExcludedAccounts = false, + @Query('timeWeightedPerformance') calculateTimeWeightedPerformance = false ): Promise { - const withExcludedAccounts = withExcludedAccountsParam === 'true'; - const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, @@ -499,7 +520,8 @@ export class PortfolioController { filters, impersonationId, withExcludedAccounts, - userId: this.request.user.id + userId: this.request.user.id, + calculateTimeWeightedPerformance }); if ( diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 15ca227e9..bae845fab 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 { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; @@ -77,6 +78,7 @@ import { } from 'date-fns'; import { isEmpty, last, uniq } from 'lodash'; +import { CPRPortfolioCalculator } from './calculator/constantPortfolioReturn/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PerformanceCalculationType, @@ -106,6 +108,7 @@ export class PortfolioService { private readonly userService: UserService ) {} + @LogPerformance public async getAccounts({ filters, userId, @@ -196,6 +199,7 @@ export class PortfolioService { }); } + @LogPerformance public async getAccountsWithAggregations({ filters, userId, @@ -232,6 +236,7 @@ export class PortfolioService { }; } + @LogPerformance public async getDividends({ activities, groupBy @@ -253,6 +258,7 @@ export class PortfolioService { return dividends; } + @LogPerformance public async getInvestments({ dateRange, filters, @@ -329,6 +335,7 @@ export class PortfolioService { }; } + @LogPerformance public async getDetails({ dateRange = 'max', filters, @@ -619,6 +626,7 @@ export class PortfolioService { }; } + @LogPerformance public async getPosition( aDataSource: DataSource, aImpersonationId: string, @@ -638,6 +646,7 @@ export class PortfolioService { return { averagePrice: undefined, dataProviderInfo: undefined, + stakeRewards: undefined, dividendInBaseCurrency: undefined, dividendYieldPercent: undefined, dividendYieldPercentWithCurrencyEffect: undefined, @@ -728,6 +737,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', @@ -798,6 +817,7 @@ export class PortfolioService { transactionCount, averagePrice: averagePrice.toNumber(), dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], + stakeRewards: stakeRewards.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), dividendYieldPercent: dividendYieldPercent.toNumber(), dividendYieldPercentWithCurrencyEffect: @@ -815,7 +835,7 @@ export class PortfolioService { grossPerformanceWithCurrencyEffect: position.grossPerformanceWithCurrencyEffect?.toNumber(), historicalData: historicalDataArray, - investment: position.investment?.toNumber(), + investment: position.investmentWithCurrencyEffect?.toNumber(), netPerformance: position.netPerformance?.toNumber(), netPerformancePercent: position.netPerformancePercentage?.toNumber(), netPerformancePercentWithCurrencyEffect: @@ -885,6 +905,7 @@ export class PortfolioService { SymbolProfile, averagePrice: 0, dataProviderInfo: undefined, + stakeRewards: 0, dividendInBaseCurrency: 0, dividendYieldPercent: 0, dividendYieldPercentWithCurrencyEffect: 0, @@ -909,6 +930,7 @@ export class PortfolioService { } } + @LogPerformance public async getPositions({ dateRange = 'max', filters, @@ -1038,6 +1060,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: @@ -1057,17 +1080,20 @@ export class PortfolioService { }; } + @LogPerformance public async getPerformance({ dateRange = 'max', filters, impersonationId, - userId + userId, + calculateTimeWeightedPerformance = false }: { dateRange?: DateRange; filters?: Filter[]; impersonationId: string; userId: string; withExcludedAccounts?: boolean; + calculateTimeWeightedPerformance?: boolean; }): Promise { userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); @@ -1113,15 +1139,14 @@ export class PortfolioService { currency: userCurrency }); - const { errors, hasErrors, historicalData } = - await portfolioCalculator.getSnapshot(); - const { endDate, startDate } = getIntervalFromDateRange(dateRange); + const range = { end: endDate, start: startDate }; - const { chart } = await portfolioCalculator.getPerformance({ - end: endDate, - start: startDate - }); + const { chart } = await (calculateTimeWeightedPerformance + ? ( + portfolioCalculator as CPRPortfolioCalculator + ).getPerformanceWithTimeWeightedReturn(range) + : portfolioCalculator.getPerformance(range)); const { netPerformance, @@ -1146,9 +1171,8 @@ export class PortfolioService { return { chart, - errors, - hasErrors, - firstOrderDate: parseDate(historicalData[0]?.date), + hasErrors: false, + firstOrderDate: parseDate(chart[0]?.date), performance: { netPerformance, netPerformanceWithCurrencyEffect, @@ -1162,6 +1186,7 @@ export class PortfolioService { }; } + @LogPerformance public async getReport(impersonationId: string): Promise { const userId = await this.getUserId(impersonationId, this.request.user.id); const userSettings = this.request.user.Settings.settings as UserSettings; @@ -1254,6 +1279,7 @@ export class PortfolioService { }; } + @LogPerformance public async updateTags({ dataSource, impersonationId, @@ -1268,7 +1294,6 @@ export class PortfolioService { userId: string; }) { userId = await this.getUserId(impersonationId, userId); - await this.orderService.assignTags({ dataSource, symbol, tags, userId }); } @@ -1411,6 +1436,7 @@ export class PortfolioService { return { markets, marketsAdvanced }; } + @LogPerformance private async getCashPositions({ cashDetails, userCurrency, @@ -1523,6 +1549,7 @@ export class PortfolioService { return dividendsByGroup; } + @LogPerformance private getEmergencyFundPositionsValueInBaseCurrency({ holdings }: { @@ -1670,6 +1697,7 @@ export class PortfolioService { return { markets, marketsAdvanced }; } + @LogPerformance private getStreaks({ investments, savingsRate @@ -1692,6 +1720,7 @@ export class PortfolioService { return { currentStreak, longestStreak }; } + @LogPerformance private async getSummary({ balanceInBaseCurrency, emergencyFundPositionsValueInBaseCurrency, @@ -1717,7 +1746,6 @@ export class PortfolioService { userId, withExcludedAccounts: true }); - const excludedActivities: Activity[] = []; const nonExcludedActivities: Activity[] = []; @@ -1782,7 +1810,9 @@ export class PortfolioService { .plus(emergencyFundPositionsValueInBaseCurrency) .toNumber(); - const committedFunds = new Big(totalBuy).minus(totalSell); + const committedFunds = new Big(totalBuy) + .minus(totalSell) + .minus(dividendInBaseCurrency); const totalOfExcludedActivities = this.getSumOfActivityType({ userCurrency, @@ -1802,7 +1832,6 @@ export class PortfolioService { currency: userCurrency, withExcludedAccounts: true }); - const excludedBalanceInBaseCurrency = new Big( cashDetailsWithExcludedAccounts.balanceInBaseCurrency ).minus(balanceInBaseCurrency); @@ -1811,12 +1840,17 @@ export class PortfolioService { .plus(totalOfExcludedActivities) .toNumber(); - const netWorth = new Big(balanceInBaseCurrency) - .plus(currentValueInBaseCurrency) - .plus(valuables) - .plus(excludedAccountsAndActivities) - .minus(liabilities) - .toNumber(); + const netWorth = + portfolioCalculator instanceof CPRPortfolioCalculator + ? await (portfolioCalculator as CPRPortfolioCalculator) + .getUnfilteredNetWorth(this.getUserCurrency()) + .then((value) => value.toNumber()) + : new Big(balanceInBaseCurrency) + .plus(currentValueInBaseCurrency) + .plus(valuables) + .plus(excludedAccountsAndActivities) + .minus(liabilities) + .toNumber(); const daysInMarket = differenceInDays(new Date(), firstOrderDate); @@ -1880,6 +1914,7 @@ export class PortfolioService { }; } + @LogPerformance private getSumOfActivityType({ activities, activityType, @@ -1921,6 +1956,7 @@ export class PortfolioService { return impersonationUserId || aUserId; } + @LogPerformance private async getValueOfAccountsAndPlatforms({ activities, filters = [], diff --git a/apps/api/src/app/tag/tag.service.ts b/apps/api/src/app/tag/tag.service.ts index c4a5447ac..5247ecfb4 100644 --- a/apps/api/src/app/tag/tag.service.ts +++ b/apps/api/src/app/tag/tag.service.ts @@ -51,7 +51,7 @@ export class TagService { const tagsWithOrderCount = await this.prismaService.tag.findMany({ include: { _count: { - select: { orders: true } + select: { orders: true, symbolProfile: true } } } }); @@ -61,7 +61,8 @@ export class TagService { id, name, userId, - activityCount: _count.orders + activityCount: _count.orders, + holdingCount: _count.symbolProfile }; }); } 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..d8e06790b 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -37,6 +37,9 @@ export class UpdateUserSettingDto { @IsIn([ '1d', + '1w', + '1m', + '3m', '1y', '5y', 'max', diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 149e06285..5c6688bd7 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -158,6 +158,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 437ef4eba..3ae10c536 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 @@ -15,7 +15,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', @@ -123,10 +122,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { }); }); - 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 c8a7422d0..2c7b2e08e 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 { @@ -354,6 +355,7 @@ export class DataProviderService { return result; } + @LogPerformance public async getQuotes({ items, requestTimeout, @@ -483,6 +485,8 @@ export class DataProviderService { } response[symbol] = dataProviderResponse; + const quotesCacheTTL = + this.getAppropriateCacheTTL(dataProviderResponse); this.redisCacheService.set( this.redisCacheService.getQuoteKey({ @@ -490,7 +494,7 @@ export class DataProviderService { dataSource: DataSource[dataSource] }), JSON.stringify(response[symbol]), - this.configurationService.get('CACHE_QUOTES_TTL') + quotesCacheTTL ); for (const { @@ -573,6 +577,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 30c7efa6a..8d6e6012c 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -12,6 +12,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, @@ -149,18 +150,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 c0abdf04e..9c919d84a 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: { @@ -117,7 +120,6 @@ export class MarketDataService { where: Prisma.MarketDataWhereUniqueInput; }): Promise { const { data, where } = params; - return this.prismaService.marketData.upsert({ where, create: { @@ -141,7 +143,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, @@ -164,7 +166,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 eedad7475..4e073cd9d 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,20 +1,25 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; -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 { DATA_GATHERING_QUEUE, DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY, DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY, GATHER_ASSET_PROFILE_PROCESS, - 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, @@ -22,7 +27,9 @@ import { getMonth, getYear, isBefore, - parseISO + parseISO, + eachDayOfInterval, + isEqual } from 'date-fns'; import { DataGatheringService } from './data-gathering.service'; @@ -150,4 +157,148 @@ export class DataGatheringProcessor { throw new Error(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) { + try { + const { dataSource, date, symbol } = job.data; + + 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({ + dataGatheringItems: [{ 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) { + Logger.error( + error, + `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` + ); + + throw new Error(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 b79b2a098..45b429d48 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, @@ -293,6 +313,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 getAllAssetProfileIdentifiers(): 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 eb8778c34..0ea5dcff5 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,7 +11,12 @@ 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() @@ -35,6 +41,7 @@ export class SymbolProfileService { }); } + @LogPerformance public async getSymbolProfiles( aAssetProfileIdentifiers: AssetProfileIdentifier[] ): Promise { @@ -51,6 +58,7 @@ export class SymbolProfileService { select: { date: true }, take: 1 }, + tags: true, SymbolProfileOverrides: true }, where: { @@ -76,7 +84,8 @@ export class SymbolProfileService { _count: { select: { Order: true } }, - SymbolProfileOverrides: true + SymbolProfileOverrides: true, + tags: true }, where: { id: { @@ -134,6 +143,7 @@ export class SymbolProfileService { dataSource, holdings, name, + tags, scraperConfiguration, sectors, symbol, @@ -150,6 +160,7 @@ export class SymbolProfileService { currency, holdings, name, + tags, scraperConfiguration, sectors, symbolMapping, @@ -166,6 +177,7 @@ export class SymbolProfileService { Order?: { date: Date; }[]; + tags?: Tag[]; SymbolProfileOverrides: SymbolProfileOverrides; })[] ): EnhancedSymbolProfile[] { @@ -180,7 +192,8 @@ export class SymbolProfileService { holdings: this.getHoldings(symbolProfile), scraperConfiguration: this.getScraperConfiguration(symbolProfile), sectors: this.getSectors(symbolProfile), - symbolMapping: this.getSymbolMapping(symbolProfile) + symbolMapping: this.getSymbolMapping(symbolProfile), + tags: symbolProfile?.tags }; item.activitiesCount = symbolProfile._count.Order; diff --git a/apps/client-e2e/project.json b/apps/client-e2e/project.json index 16d13e012..dd08a156e 100644 --- a/apps/client-e2e/project.json +++ b/apps/client-e2e/project.json @@ -3,6 +3,8 @@ "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/client-e2e/src", "projectType": "application", + "tags": [], + "implicitDependencies": ["client"], "targets": { "e2e": { "executor": "@nx/cypress:cypress", @@ -17,7 +19,5 @@ } } } - }, - "tags": [], - "implicitDependencies": ["client"] + } } diff --git a/apps/client/project.json b/apps/client/project.json index dd644f8c1..8831ce346 100644 --- a/apps/client/project.json +++ b/apps/client/project.json @@ -2,13 +2,59 @@ "name": "client", "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "application", + "sourceRoot": "apps/client/src", + "prefix": "gf", + "i18n": { + "locales": { + "ca": { + "baseHref": "/ca/", + "translation": "apps/client/src/locales/messages.ca.xlf" + }, + "de": { + "baseHref": "/de/", + "translation": "apps/client/src/locales/messages.de.xlf" + }, + "es": { + "baseHref": "/es/", + "translation": "apps/client/src/locales/messages.es.xlf" + }, + "fr": { + "baseHref": "/fr/", + "translation": "apps/client/src/locales/messages.fr.xlf" + }, + "it": { + "baseHref": "/it/", + "translation": "apps/client/src/locales/messages.it.xlf" + }, + "nl": { + "baseHref": "/nl/", + "translation": "apps/client/src/locales/messages.nl.xlf" + }, + "pl": { + "baseHref": "/pl/", + "translation": "apps/client/src/locales/messages.pl.xlf" + }, + "pt": { + "baseHref": "/pt/", + "translation": "apps/client/src/locales/messages.pt.xlf" + }, + "tr": { + "baseHref": "/tr/", + "translation": "apps/client/src/locales/messages.tr.xlf" + }, + "zh": { + "baseHref": "/zh/", + "translation": "apps/client/src/locales/messages.zh.xlf" + } + }, + "sourceLocale": "en" + }, + "tags": [], "generators": { "@schematics/angular:component": { "style": "scss" } }, - "sourceRoot": "apps/client/src", - "prefix": "gf", "targets": { "build": { "executor": "@nx/angular:webpack-browser", @@ -242,51 +288,5 @@ }, "outputs": ["{workspaceRoot}/coverage/apps/client"] } - }, - "i18n": { - "locales": { - "ca": { - "baseHref": "/ca/", - "translation": "apps/client/src/locales/messages.ca.xlf" - }, - "de": { - "baseHref": "/de/", - "translation": "apps/client/src/locales/messages.de.xlf" - }, - "es": { - "baseHref": "/es/", - "translation": "apps/client/src/locales/messages.es.xlf" - }, - "fr": { - "baseHref": "/fr/", - "translation": "apps/client/src/locales/messages.fr.xlf" - }, - "it": { - "baseHref": "/it/", - "translation": "apps/client/src/locales/messages.it.xlf" - }, - "nl": { - "baseHref": "/nl/", - "translation": "apps/client/src/locales/messages.nl.xlf" - }, - "pl": { - "baseHref": "/pl/", - "translation": "apps/client/src/locales/messages.pl.xlf" - }, - "pt": { - "baseHref": "/pt/", - "translation": "apps/client/src/locales/messages.pt.xlf" - }, - "tr": { - "baseHref": "/tr/", - "translation": "apps/client/src/locales/messages.tr.xlf" - }, - "zh": { - "baseHref": "/zh/", - "translation": "apps/client/src/locales/messages.zh.xlf" - } - }, - "sourceLocale": "en" - }, - "tags": [] + } } 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 5eb869694..506960ad3 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 @@ -259,6 +259,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 de5707d02..163f89522 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 @@ -194,6 +194,9 @@ + 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 aacf387e7..d3025ba81 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 @@ -13,22 +13,27 @@ import { } from '@ghostfolio/common/interfaces'; import { translate } from '@ghostfolio/ui/i18n'; +import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + ElementRef, Inject, OnDestroy, - OnInit + OnInit, + ViewChild } from '@angular/core'; import { FormBuilder, FormControl, Validators } from '@angular/forms'; +import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { AssetClass, AssetSubClass, MarketData, - SymbolProfile + SymbolProfile, + Tag } from '@prisma/client'; import { format } from 'date-fns'; import { parse as csvToJson } from 'papaparse'; @@ -45,6 +50,8 @@ import { AssetProfileDialogParams } from './interfaces/interfaces'; styleUrls: ['./asset-profile-dialog.component.scss'] }) export class AssetProfileDialog implements OnDestroy, OnInit { + @ViewChild('tagInput') tagInput: ElementRef; + public separatorKeysCodes: number[] = [ENTER, COMMA]; public assetProfileClass: string; public assetClasses = Object.keys(AssetClass).map((assetClass) => { return { id: assetClass, label: translate(assetClass) }; @@ -63,6 +70,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit { csvString: '' }), name: ['', Validators.required], + tags: new FormControl(undefined), scraperConfiguration: '', sectors: '', symbolMapping: '', @@ -81,6 +89,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit { [name: string]: { name: string; value: number }; }; + public HoldingTags: { id: string; name: string; userId: string }[]; + private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format( new Date(), DATE_FORMAT @@ -109,6 +119,18 @@ export class AssetProfileDialog implements OnDestroy, OnInit { } public initialize() { + this.adminService + .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.adminService .fetchAdminMarketDataBySymbol({ dataSource: this.data.dataSource, @@ -149,6 +171,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit { assetClass: this.assetProfile.assetClass ?? null, assetSubClass: this.assetProfile.assetSubClass ?? null, comment: this.assetProfile?.comment ?? '', + tags: this.assetProfile?.tags ?? [], countries: JSON.stringify( this.assetProfile?.countries?.map(({ code, weight }) => { return { code, weight }; @@ -164,7 +187,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(); @@ -200,6 +223,16 @@ export class AssetProfileDialog implements OnDestroy, OnInit { .subscribe(); } + public onGatherSymbolMissingOnly({ + dataSource, + symbol + }: AssetProfileIdentifier) { + this.adminService + .gatherSymbolMissingOnly({ dataSource, symbol }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(); + } + public onImportHistoricalData() { try { const marketData = csvToJson( @@ -294,9 +327,10 @@ 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, currency: this.assetProfileForm.get('currency').value, name: this.assetProfileForm.get('name').value, - url: this.assetProfileForm.get('url').value || null + url: this.assetProfileForm.get('url').value }; try { @@ -364,6 +398,26 @@ 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.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 a5d2205d2..14b2f423c 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 @@ -31,6 +31,19 @@ > Gather Historical Data +