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 e4a935626..76b0825a0 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -COMPOSE_PROJECT_NAME=ghostfolio +COMPOSE_PROJECT_NAME=ghostfolio-development # CACHE REDIS_HOST=redis @@ -10,7 +10,6 @@ POSTGRES_DB=ghostfolio-db POSTGRES_USER=user POSTGRES_PASSWORD= -# VARIOUS ACCESS_TOKEN_SALT= DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer JWT_SECRET_KEY= diff --git a/.github/workflows/docker-image-branch.yml b/.github/workflows/docker-image-branch.yml new file mode 100644 index 000000000..988332c3f --- /dev/null +++ b/.github/workflows/docker-image-branch.yml @@ -0,0 +1,47 @@ +name: Docker image CD - Branch + +on: + push: + branches: + - '*' + +jobs: + build_and_push: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v4 + with: + images: dandevaud/ghostfolio + tags: | + type=semver,pattern={{major}} + type=semver,pattern={{version}} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to DockerHub + if: github.event_name != 'pull_request' + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64,linux/arm/v7,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: dandevaud/ghostfolio:${{ github.ref_name }} + labels: ${{ steps.meta.output.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/docker-image-dev.yml b/.github/workflows/docker-image-dev.yml new file mode 100644 index 000000000..01e4d9231 --- /dev/null +++ b/.github/workflows/docker-image-dev.yml @@ -0,0 +1,47 @@ +name: Docker image CD - DEV + +on: + push: + branches: + - 'dockerpush' + +jobs: + build_and_push: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v4 + with: + images: dandevaud/ghostfolio + tags: | + type=semver,pattern={{major}} + type=semver,pattern={{version}} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to DockerHub + if: github.event_name != 'pull_request' + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64,linux/arm/v7,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: dandevaud/ghostfolio:beta + labels: ${{ steps.meta.output.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 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/.husky/pre-commit b/.husky/pre-push similarity index 100% rename from .husky/pre-commit rename to .husky/pre-push diff --git a/CHANGELOG.md b/CHANGELOG.md index c73e55719..2e5c1116f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 2.149.0 - 2025-03-30 +### Changed + +- Restructured the resources page +- Renamed the static portfolio analysis rule from _Allocation Cluster Risk_ to _Economic Market Cluster Risk_ (Developed Markets and Emerging Markets) +- Improved the language localization for German (`de`) +- Switched the `consistent-generic-constructors` rule from `warn` to `error` in the `eslint` configuration +- Switched the `consistent-indexed-object-style` rule from `warn` to `off` in the `eslint` configuration +- Switched the `consistent-type-assertions` rule from `warn` to `error` in the `eslint` configuration +- Switched the `prefer-optional-chain` rule from `warn` to `error` in the `eslint` configuration +- Upgraded `Nx` from version `20.0.3` to `20.0.6` + +## 2.119.0 - 2024-10-26 + +### Changed + +- Switched the `consistent-type-definitions` rule from `warn` to `error` in the `eslint` configuration +- Switched the `no-empty-function` rule from `warn` to `error` in the `eslint` configuration +- Switched the `prefer-function-type` rule from `warn` to `error` in the `eslint` configuration +- Upgraded `prisma` from version `5.20.0` to `5.21.1` + +### Fixed + +- Fixed an issue with the X-axis scale of the dividend timeline on the analysis page +- Fixed an issue with the X-axis scale of the investment timeline on the analysis page +- Fixed an issue with the X-axis scale of the portfolio evolution chart on the analysis page +- Fixed an issue in the calculation of the static portfolio analysis rule: _Allocation Cluster Risk_ (Developed Markets) +- Fixed an issue in the calculation of the static portfolio analysis rule: _Allocation Cluster Risk_ (Emerging Markets) + +## 2.118.0 - 2024-10-23 + +### Added + +- Added a new static portfolio analysis rule: _Allocation Cluster Risk_ (Developed Markets) +- Added a new static portfolio analysis rule: _Allocation Cluster Risk_ (Emerging Markets) +- Added support for mutual funds in the _EOD Historical Data_ service + +### Changed + +- Improved the font colors of the chart of the holdings tab on the home page (experimental) +- Optimized the dialog sizes for mobile (full screen) +- Optimized the git-hook via `husky` to lint only affected projects before a commit +- Upgraded `angular` from version `18.1.1` to `18.2.8` +- Upgraded `Nx` from version `19.5.6` to `20.0.3` + +### Fixed + +- Fixed the warning `export was not found` in connection with `GetValuesParams` +- Quoted the password for the _Redis_ service `healthcheck` in the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`) + +## 2.117.0 - 2024-10-19 + +### Added + +- Added the logotype to the footer +- Added the data providers management to the admin control panel + +### Changed + +- Improved the backgrounds of the chart of the holdings tab on the home page (experimental) +- Improved the language localization for German (`de`) + +### Fixed + +- Fixed an issue in the carousel component for the testimonial section on the landing page + +## 2.116.0 - 2024-10-17 + +### Added + +- Extended the content of the _Self-Hosting_ section by the benchmarks concept for _Compare with..._ on the Frequently Asked Questions (FAQ) page +- Extended the content of the _Self-Hosting_ section by the benchmarks concept for _Markets_ on the Frequently Asked Questions (FAQ) page +- Set the permissions (`chmod 0700`) on `entrypoint.sh` in the `Dockerfile` + +### Changed + +- Improved the empty state in the benchmarks of the markets overview +- Disabled the text hover effect in the chart of the holdings tab on the home page (experimental) +- Improved the usability to customize the rule thresholds in the _X-ray_ section by introducing units (experimental) +- Switched to adjusted market prices (splits and dividends) in the get historical functionality of the _EOD Historical Data_ service +- Improved the language localization for German (`de`) + +### Fixed + +- Fixed the usage of the environment variable `PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY` + +## 2.115.0 - 2024-10-14 + ### Added - Added support for changing the asset profile identifier (`dataSource` and `symbol`) in the asset profile details dialog of the admin control panel (experimental) @@ -1919,9 +2006,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 103dc3b9e..e2c1e47f4 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 aab4c0766..74b612b7e 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -1,5 +1,6 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; @@ -162,6 +163,7 @@ export class AccountService { }); } + @LogPerformance public async getCashDetails({ currency, filters = [], diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 2df1d98ae..fc6292246 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.getAllActiveAssetProfileIdentifiers(); + + const promises = assetProfileIdentifiers.map(({ dataSource, symbol }) => { + return this.dataGatheringService.gatherSymbolMissingOnly({ + dataSource, + symbol + }); + }); + + await Promise.all(promises); + } + @HasPermission(permissions.accessAdminControl) @Post('gather/profile-data') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -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; } @@ -340,7 +372,17 @@ export class AdminController { ): Promise { return this.adminService.patchAssetProfileData( { dataSource, symbol }, - assetProfile + { + ...assetProfile, + tags: { + connect: assetProfile.tags?.map(({ id }) => { + return { id }; + }), + disconnect: assetProfile.tagsDisconnected?.map(({ id }) => ({ + id + })) + } + } ); } diff --git a/apps/api/src/app/admin/admin.module.ts b/apps/api/src/app/admin/admin.module.ts index d94cdd963..b02c0203b 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 6a8906c17..701d95d8f 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -41,11 +41,11 @@ import { import { AssetClass, AssetSubClass, - DataSource, Prisma, PrismaClient, Property, - SymbolProfile + SymbolProfile, + DataSource } from '@prisma/client'; import { differenceInDays } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @@ -259,7 +259,8 @@ export class AdminService { scraperConfiguration: true, sectors: true, symbol: true, - SymbolProfileOverrides: true + SymbolProfileOverrides: true, + tags: true } }), this.prismaService.symbolProfile.count({ where }) @@ -314,7 +315,8 @@ export class AdminService { Order, sectors, symbol, - SymbolProfileOverrides + SymbolProfileOverrides, + tags }) => { let countriesCount = countries ? Object.keys(countries).length : 0; @@ -374,7 +376,9 @@ export class AdminService { sectorsCount, activitiesCount: _count.Order, date: Order?.[0]?.date, - isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription + isUsedByUsersWithSubscription: + await isUsedByUsersWithSubscription, + tags }; } ) @@ -482,6 +486,7 @@ export class AdminService { holdings, isActive, name, + tags, scraperConfiguration, sectors, symbol: newSymbol, @@ -563,6 +568,7 @@ export class AdminService { sectors, symbol, symbolMapping, + tags, ...(dataSource === 'MANUAL' ? { assetClass, assetSubClass, name, url } : { @@ -749,7 +755,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 45923410a..a03b3b074 100644 --- a/apps/api/src/app/admin/update-asset-profile.dto.ts +++ b/apps/api/src/app/admin/update-asset-profile.dto.ts @@ -1,6 +1,12 @@ import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; -import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; +import { + AssetClass, + AssetSubClass, + DataSource, + Prisma, + Tag +} from '@prisma/client'; import { IsArray, IsBoolean, @@ -44,6 +50,14 @@ export class UpdateAssetProfileDto { @IsString() name?: string; + @IsArray() + @IsOptional() + tags?: Tag[]; + + @IsArray() + @IsOptional() + tagsDisconnected?: Tag[]; + @IsObject() @IsOptional() scraperConfiguration?: Prisma.InputJsonObject; diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index c72420417..f25433916 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -627,18 +627,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 a26099e9d..572904f3d 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -50,33 +50,55 @@ export class OrderService { public async assignTags({ dataSource, symbol, - tags, - userId + userId, + tags }: { tags: Tag[]; userId: string } & AssetProfileIdentifier) { - const orders = await this.prismaService.order.findMany({ - where: { - userId, - SymbolProfile: { - dataSource, - symbol - } + const promis = await this.symbolProfileService.getSymbolProfiles([ + { + dataSource, + symbol + } + ]); + const symbolProfile: EnhancedSymbolProfile = promis[0]; + const result = await this.symbolProfileService.updateSymbolProfile( + { dataSource, symbol }, + { + assetClass: symbolProfile.assetClass, + assetSubClass: symbolProfile.assetSubClass, + countries: symbolProfile.countries.reduce( + (all, v) => [...all, { code: v.code, weight: v.weight }], + [] + ), + currency: symbolProfile.currency, + dataSource, + holdings: symbolProfile.holdings.reduce( + (all, v) => [ + ...all, + { name: v.name, weight: v.allocationInPercentage } + ], + [] + ), + name: symbolProfile.name, + sectors: symbolProfile.sectors.reduce( + (all, v) => [...all, { name: v.name, weight: v.weight }], + [] + ), + symbol, + tags: { + connectOrCreate: tags.map(({ id, name }) => { + return { + create: { + id, + name + }, + where: { + id + } + }; + }) + }, + url: symbolProfile.url } - }); - - await Promise.all( - orders.map(({ id }) => - this.prismaService.order.update({ - data: { - tags: { - // The set operation replaces all existing connections with the provided ones - set: tags.map((tag) => { - return { id: tag.id }; - }) - } - }, - where: { id } - }) - ) ); this.eventEmitter.emit( @@ -85,6 +107,8 @@ export class OrderService { userId }) ); + + return result; } public async createOrder( @@ -302,6 +326,7 @@ export class OrderService { }); } + @LogPerformance public async getOrders({ endDate, filters, @@ -456,13 +481,34 @@ export class OrderService { } if (filtersByTag?.length > 0) { - where.tags = { - some: { - OR: filtersByTag.map(({ id }) => { - return { id }; - }) + where.AND = [ + { + OR: [ + { + tags: { + some: { + OR: filtersByTag.map(({ id }) => { + return { + id: id + }; + }) + } + } + }, + { + SymbolProfile: { + tags: { + some: { + OR: filtersByTag.map(({ id }) => { + return { id }; + }) + } + } + } + } + ] } - }; + ]; } if (sortColumn) { @@ -494,7 +540,11 @@ export class OrderService { } }, // eslint-disable-next-line @typescript-eslint/naming-convention - SymbolProfile: true, + SymbolProfile: { + include: { + tags: true + } + }, tags: true } }), diff --git a/apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts new file mode 100644 index 000000000..c5d73e6d6 --- /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 { RoaiPortfolioCalculator } from '../roai/portfolio-calculator'; + +export class CPRPortfolioCalculator extends RoaiPortfolioCalculator { + 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 6cc5edeaf..6dd3210e2 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'; @@ -16,7 +17,8 @@ import { TwrPortfolioCalculator } from './twr/portfolio-calculator'; export enum PerformanceCalculationType { MWR = 'MWR', // Money-Weighted Rate of Return ROAI = 'ROAI', // Return on Average Investment - TWR = 'TWR' // Time-Weighted Rate of Return + TWR = 'TWR', // Time-Weighted Rate of Return + CPR = 'CPR' // Constant Portfolio Rate of Return } @Injectable() @@ -29,6 +31,7 @@ export class PortfolioCalculatorFactory { private readonly redisCacheService: RedisCacheService ) {} + @LogPerformance public createCalculator({ accountBalanceItems = [], activities, @@ -76,13 +79,26 @@ export class PortfolioCalculatorFactory { accountBalanceItems, activities, currency, - filters, + currentRateService: this.currentRateService, userId, configurationService: this.configurationService, + exchangeRateDataService: this.exchangeRateDataService, + portfolioSnapshotService: this.portfolioSnapshotService, + redisCacheService: this.redisCacheService, + filters + }); + case PerformanceCalculationType.CPR: + return new RoaiPortfolioCalculator({ + accountBalanceItems, + activities, + currency, currentRateService: this.currentRateService, + userId, + configurationService: this.configurationService, exchangeRateDataService: this.exchangeRateDataService, portfolioSnapshotService: this.portfolioSnapshotService, - redisCacheService: this.redisCacheService + redisCacheService: this.redisCacheService, + filters }); 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 52d57230b..dd83e798e 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, @@ -161,10 +162,6 @@ export abstract class PortfolioCalculator { this.snapshotPromise = this.initialize(); } - protected abstract calculateOverallPerformance( - positions: TimelinePosition[] - ): PortfolioSnapshot; - @LogPerformance public async computeSnapshot(): Promise { const lastTransactionPoint = this.transactionPoints.at(-1); @@ -202,10 +199,7 @@ export abstract class PortfolioCalculator { for (const { currency, dataSource, symbol } of transactionPoints[ firstIndex - 1 ].items) { - dataGatheringItems.push({ - dataSource, - symbol - }); + dataGatheringItems.push({ dataSource, symbol }); currencies[symbol] = currency; } @@ -234,17 +228,12 @@ export abstract class PortfolioCalculator { values: marketSymbols } = await this.currentRateService.getValues({ dataGatheringItems, - dateQuery: { - gte: this.startDate, - lt: this.endDate - } + dateQuery: { gte: this.startDate, lt: this.endDate } }); this.dataProviderInfos = dataProviderInfos; - const marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; - } = {}; + const marketSymbolMap: { [date: string]: { [symbol: string]: Big } } = {}; for (const marketSymbol of marketSymbols) { const date = format(marketSymbol.date, DATE_FORMAT); @@ -623,10 +612,12 @@ export abstract class PortfolioCalculator { }; } + @LogPerformance public getDataProviderInfos() { return this.dataProviderInfos; } + @LogPerformance public async getDividendInBaseCurrency() { await this.snapshotPromise; @@ -637,18 +628,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 []; @@ -666,6 +660,7 @@ export abstract class PortfolioCalculator { }); } + @LogPerformance public getInvestmentsByGroup({ data, groupBy @@ -689,12 +684,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; @@ -763,12 +760,6 @@ export abstract class PortfolioCalculator { return { chart }; } - public async getSnapshot() { - await this.snapshotPromise; - - return this.snapshot; - } - public getStartDate() { let firstAccountBalanceDate: Date; let firstActivityDate: Date; @@ -794,24 +785,6 @@ export abstract class PortfolioCalculator { return min([firstAccountBalanceDate, firstActivityDate]); } - protected abstract getSymbolMetrics({ - chartDateMap, - dataSource, - end, - exchangeRates, - marketSymbolMap, - start, - symbol - }: { - chartDateMap: { [date: string]: boolean }; - end: Date; - exchangeRates: { [dateString: string]: number }; - marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; - }; - start: Date; - } & AssetProfileIdentifier): SymbolMetrics; - public getTransactionPoints() { return this.transactionPoints; } @@ -822,76 +795,15 @@ export abstract class PortfolioCalculator { return this.snapshot.totalValuablesWithCurrencyEffect; } - private getChartDateMap({ - endDate, - startDate, - step - }: { - endDate: Date; - startDate: Date; - step: number; - }): { [date: string]: true } { - // Create a map of all relevant chart dates: - // 1. Add transaction point dates - const chartDateMap = this.transactionPoints.reduce((result, { date }) => { - result[date] = true; - return result; - }, {}); - - // 2. Add dates between transactions respecting the specified step size - for (const date of eachDayOfInterval( - { end: endDate, start: startDate }, - { step } - )) { - chartDateMap[format(date, DATE_FORMAT)] = true; - } - - if (step > 1) { - // Reduce the step size of last 90 days - for (const date of eachDayOfInterval( - { end: endDate, start: subDays(endDate, 90) }, - { step: 3 } - )) { - chartDateMap[format(date, DATE_FORMAT)] = true; - } - - // Reduce the step size of last 30 days - for (const date of eachDayOfInterval( - { end: endDate, start: subDays(endDate, 30) }, - { step: 1 } - )) { - chartDateMap[format(date, DATE_FORMAT)] = true; - } - } - - // Make sure the end date is present - chartDateMap[format(endDate, DATE_FORMAT)] = true; - - // Make sure some key dates are present - for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) { - const { endDate: dateRangeEnd, startDate: dateRangeStart } = - getIntervalFromDateRange(dateRange); - - if ( - !isBefore(dateRangeStart, startDate) && - !isAfter(dateRangeStart, endDate) - ) { - chartDateMap[format(dateRangeStart, DATE_FORMAT)] = true; - } - - if ( - !isBefore(dateRangeEnd, startDate) && - !isAfter(dateRangeEnd, endDate) - ) { - chartDateMap[format(dateRangeEnd, DATE_FORMAT)] = true; - } - } + @LogPerformance + public async getSnapshot() { + await this.snapshotPromise; - return chartDateMap; + return this.snapshot; } @LogPerformance - private computeTransactionPoints() { + protected computeTransactionPoints() { this.transactionPoints = []; const symbols: { [symbol: string]: TransactionPointSymbol } = {}; @@ -1030,7 +942,7 @@ export abstract class PortfolioCalculator { } @LogPerformance - private async initialize() { + protected async initialize() { const startTimeTotal = performance.now(); let cachedPortfolioSnapshot: PortfolioSnapshot; @@ -1110,4 +1022,92 @@ export abstract class PortfolioCalculator { await this.initialize(); } } + + private getChartDateMap({ + endDate, + startDate, + step + }: { + endDate: Date; + startDate: Date; + step: number; + }): { [date: string]: true } { + // Create a map of all relevant chart dates: + // 1. Add transaction point dates + const chartDateMap = this.transactionPoints.reduce((result, { date }) => { + result[date] = true; + return result; + }, {}); + + // 2. Add dates between transactions respecting the specified step size + for (const date of eachDayOfInterval( + { end: endDate, start: startDate }, + { step } + )) { + chartDateMap[format(date, DATE_FORMAT)] = true; + } + + if (step > 1) { + // Reduce the step size of last 90 days + for (const date of eachDayOfInterval( + { end: endDate, start: subDays(endDate, 90) }, + { step: 3 } + )) { + chartDateMap[format(date, DATE_FORMAT)] = true; + } + + // Reduce the step size of last 30 days + for (const date of eachDayOfInterval( + { end: endDate, start: subDays(endDate, 30) }, + { step: 1 } + )) { + chartDateMap[format(date, DATE_FORMAT)] = true; + } + } + + // Make sure the end date is present + chartDateMap[format(endDate, DATE_FORMAT)] = true; + + // Make sure some key dates are present + for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) { + const { endDate: dateRangeEnd, startDate: dateRangeStart } = + getIntervalFromDateRange(dateRange); + + if ( + !isBefore(dateRangeStart, startDate) && + !isAfter(dateRangeStart, endDate) + ) { + chartDateMap[format(dateRangeStart, DATE_FORMAT)] = true; + } + + if ( + !isBefore(dateRangeEnd, startDate) && + !isAfter(dateRangeEnd, endDate) + ) { + chartDateMap[format(dateRangeEnd, DATE_FORMAT)] = true; + } + } + + return chartDateMap; + } + + protected abstract getSymbolMetrics({ + chartDateMap, + dataSource, + end, + exchangeRates, + marketSymbolMap, + start, + symbol + }: { + chartDateMap: { [date: string]: boolean }; + end: Date; + exchangeRates: { [dateString: string]: number }; + marketSymbolMap: { [date: string]: { [symbol: string]: Big } }; + start: Date; + } & AssetProfileIdentifier): SymbolMetrics; + + protected abstract calculateOverallPerformance( + positions: TimelinePosition[] + ): PortfolioSnapshot; } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts index e157e2d26..c5325ee7f 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts @@ -176,9 +176,7 @@ describe('PortfolioCalculator', () => { netPerformancePercentageWithCurrencyEffectMap: { max: new Big('-0.0552834149755073478') }, - netPerformanceWithCurrencyEffectMap: { - max: new Big('-15.8') - }, + netPerformanceWithCurrencyEffectMap: { max: new Big('-15.8') }, marketPrice: 148.9, marketPriceInBaseCurrency: 148.9, quantity: new Big('0'), diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts index a1650ea82..8ca8d8c2f 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -163,9 +163,7 @@ describe('PortfolioCalculator', () => { netPerformancePercentageWithCurrencyEffectMap: { max: new Big('-0.0552834149755073478') }, - netPerformanceWithCurrencyEffectMap: { - max: new Big('-15.8') - }, + netPerformanceWithCurrencyEffectMap: { max: new Big('-15.8') }, marketPrice: 148.9, marketPriceInBaseCurrency: 148.9, quantity: new Big('0'), diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts index b3793a5b4..a80dd192e 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts @@ -161,9 +161,7 @@ describe('PortfolioCalculator', () => { netPerformancePercentageWithCurrencyEffectMap: { max: new Big('0.24112962014285697628') }, - netPerformanceWithCurrencyEffectMap: { - max: new Big('19.851974') - }, + netPerformanceWithCurrencyEffectMap: { max: new Big('19.851974') }, marketPrice: 116.45, marketPriceInBaseCurrency: 103.10483, quantity: new Big('1'), @@ -199,30 +197,12 @@ describe('PortfolioCalculator', () => { expect(investmentsByMonth).toEqual([ { date: '2023-01-01', investment: 82.329056 }, - { - date: '2023-02-01', - investment: 0 - }, - { - date: '2023-03-01', - investment: 0 - }, - { - date: '2023-04-01', - investment: 0 - }, - { - date: '2023-05-01', - investment: 0 - }, - { - date: '2023-06-01', - investment: 0 - }, - { - date: '2023-07-01', - investment: 0 - } + { date: '2023-02-01', investment: 0 }, + { date: '2023-03-01', investment: 0 }, + { date: '2023-04-01', investment: 0 }, + { date: '2023-05-01', investment: 0 }, + { date: '2023-06-01', investment: 0 }, + { date: '2023-07-01', investment: 0 } ]); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts index 84bcc5bc1..895118ac4 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -157,9 +157,7 @@ describe('PortfolioCalculator', () => { netPerformancePercentageWithCurrencyEffectMap: { max: new Big('0.12348284960422163588') }, - netPerformanceWithCurrencyEffectMap: { - max: new Big('17.68') - }, + netPerformanceWithCurrencyEffectMap: { max: new Big('17.68') }, marketPrice: 87.8, marketPriceInBaseCurrency: 87.8, quantity: new Big('1'), diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts index 937fd8b48..589f4c0ac 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -1,252 +1,252 @@ -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 { 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.ROAI, - currency: 'CHF', - userId: userDummyData.id - }); - - const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); - - const investments = portfolioCalculator.getInvestments(); - - const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ - data: portfolioSnapshot.historicalData, - groupBy: 'month' - }); - - expect(portfolioSnapshot.historicalData[0]).toEqual({ - date: '2022-03-06', - investmentValueWithCurrencyEffect: 0, - netPerformance: 0, - netPerformanceInPercentage: 0, - netPerformanceInPercentageWithCurrencyEffect: 0, - netPerformanceWithCurrencyEffect: 0, - netWorth: 0, - totalAccountBalance: 0, - totalInvestment: 0, - totalInvestmentValueWithCurrencyEffect: 0, - value: 0, - valueWithCurrencyEffect: 0 - }); - - 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(portfolioSnapshot.historicalData.at(-1)).toMatchObject( - expect.objectContaining({ - netPerformance: 19.86, - netPerformanceInPercentage: 0.13100263852242744063, - netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, - netPerformanceWithCurrencyEffect: 19.86, - totalInvestmentValueWithCurrencyEffect: 0 - }) - ); - - expect(investments).toEqual([ - { date: '2022-03-07', investment: new Big('151.6') }, - { date: '2022-04-08', investment: new Big('0') } - ]); - - expect(investmentsByMonth).toEqual([ - { date: '2022-03-01', investment: 151.6 }, - { date: '2022-04-01', investment: -151.6 } - ]); - }); - }); -}); +import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + loadActivityExportFile, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + 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 { 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.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + expect(portfolioSnapshot.historicalData[0]).toEqual({ + date: '2022-03-06', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + 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(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: 19.86, + netPerformanceInPercentage: 0.13100263852242744063, + netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, + netPerformanceWithCurrencyEffect: 19.86, + totalInvestmentValueWithCurrencyEffect: 0 + }) + ); + + expect(investments).toEqual([ + { date: '2022-03-07', investment: new Big('151.6') }, + { date: '2022-04-08', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2022-03-01', investment: 151.6 }, + { date: '2022-04-01', investment: -151.6 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index 5b918fa03..8ff3f1de0 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -221,7 +221,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { totalLiabilities: new Big(0), totalLiabilitiesInBaseCurrency: new Big(0), totalValuables: new Big(0), - totalValuablesInBaseCurrency: new Big(0) + totalValuablesInBaseCurrency: new Big(0), + unitPrices: {} }; } @@ -271,7 +272,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { totalLiabilities: new Big(0), totalLiabilitiesInBaseCurrency: new Big(0), totalValuables: new Big(0), - totalValuablesInBaseCurrency: new Big(0) + totalValuablesInBaseCurrency: new Big(0), + unitPrices: {} }; } @@ -963,7 +965,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { timeWeightedInvestment: timeWeightedAverageInvestmentBetweenStartAndEndDate, timeWeightedInvestmentWithCurrencyEffect: - timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect, + unitPrices: marketSymbolMap[endDateString] }; } } 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 c3e46d50d..c00db4b40 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -5,7 +5,10 @@ import { hasNotDefinedValuesInObject, nullifyValuesInObject } from '@ghostfolio/api/helper/object.helper'; -import { PerformanceLoggingInterceptor } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; +import { + LogPerformance, + PerformanceLoggingInterceptor +} from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; @@ -159,6 +162,23 @@ export class PortfolioController { portfolioPosition.investment / totalInvestment; portfolioPosition.valueInPercentage = portfolioPosition.valueInBaseCurrency / totalValue; + portfolioPosition.assetClass = hasDetails + ? portfolioPosition.assetClass + : undefined; + portfolioPosition.assetSubClass = hasDetails + ? portfolioPosition.assetSubClass + : undefined; + portfolioPosition.countries = hasDetails + ? portfolioPosition.countries + : []; + portfolioPosition.currency = hasDetails + ? portfolioPosition.currency + : undefined; + portfolioPosition.markets = hasDetails + ? portfolioPosition.markets + : undefined; + portfolioPosition.sectors = hasDetails ? portfolioPosition.sectors : []; + portfolioPosition.tags = hasDetails ? portfolioPosition.tags : []; } for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) { @@ -225,6 +245,7 @@ export class PortfolioController { currency: hasDetails ? portfolioPosition.currency : undefined, holdings: hasDetails ? portfolioPosition.holdings : [], markets: hasDetails ? portfolioPosition.markets : undefined, + tags: hasDetails ? portfolioPosition.tags : [], marketsAdvanced: hasDetails ? portfolioPosition.marketsAdvanced : undefined, @@ -476,6 +497,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, @@ -484,10 +506,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, @@ -501,7 +522,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 7287c103b..11d7125bf 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -5,6 +5,7 @@ import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interf import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { AssetClassClusterRiskEquity } from '@ghostfolio/api/models/rules/asset-class-cluster-risk/equity'; @@ -82,8 +83,9 @@ import { parseISO, set } from 'date-fns'; -import { isEmpty } from 'lodash'; +import { isEmpty, uniqBy } from 'lodash'; +import { CPRPortfolioCalculator } from './calculator/constantPortfolioReturn/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PerformanceCalculationType, @@ -113,6 +115,7 @@ export class PortfolioService { private readonly userService: UserService ) {} + @LogPerformance public async getAccounts({ filters, userId, @@ -203,6 +206,7 @@ export class PortfolioService { }); } + @LogPerformance public async getAccountsWithAggregations({ filters, userId, @@ -239,6 +243,7 @@ export class PortfolioService { }; } + @LogPerformance public async getDividends({ activities, groupBy @@ -260,6 +265,7 @@ export class PortfolioService { return dividends; } + @LogPerformance public async getInvestments({ dateRange, filters, @@ -336,6 +342,7 @@ export class PortfolioService { }; } + @LogPerformance public async getDetails({ dateRange = 'max', filters, @@ -484,13 +491,17 @@ export class PortfolioService { })); } + const tagsInternal = tags.concat( + symbolProfiles.find((sp) => sp.symbol === symbol)?.tags ?? [] + ); + holdings[symbol] = { currency, markets, marketsAdvanced, marketPrice, symbol, - tags, + tags: uniqBy(tagsInternal, 'id'), transactionCount, allocationInPercentage: filteredValueInBaseCurrency.eq(0) ? 0 @@ -627,6 +638,7 @@ export class PortfolioService { }; } + @LogPerformance public async getPosition( aDataSource: DataSource, aImpersonationId: string, @@ -646,6 +658,7 @@ export class PortfolioService { return { averagePrice: undefined, dataProviderInfo: undefined, + stakeRewards: undefined, dividendInBaseCurrency: undefined, dividendYieldPercent: undefined, dividendYieldPercentWithCurrencyEffect: undefined, @@ -736,6 +749,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', @@ -806,6 +829,7 @@ export class PortfolioService { transactionCount, averagePrice: averagePrice.toNumber(), dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], + stakeRewards: stakeRewards.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), dividendYieldPercent: dividendYieldPercent.toNumber(), dividendYieldPercentWithCurrencyEffect: @@ -823,7 +847,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: @@ -893,6 +917,7 @@ export class PortfolioService { SymbolProfile, averagePrice: 0, dataProviderInfo: undefined, + stakeRewards: 0, dividendInBaseCurrency: 0, dividendYieldPercent: 0, dividendYieldPercentWithCurrencyEffect: 0, @@ -917,6 +942,7 @@ export class PortfolioService { } } + @LogPerformance public async getPositions({ dateRange = 'max', filters, @@ -1046,6 +1072,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: @@ -1065,17 +1092,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 }); @@ -1120,15 +1150,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, @@ -1150,9 +1179,8 @@ export class PortfolioService { return { chart, - errors, - hasErrors, - firstOrderDate: parseDate(historicalData[0]?.date), + hasErrors: false, + firstOrderDate: parseDate(chart[0]?.date), performance: { netPerformance, netPerformanceWithCurrencyEffect, @@ -1166,6 +1194,7 @@ export class PortfolioService { }; } + @LogPerformance public async getReport( impersonationId: string ): Promise { @@ -1320,6 +1349,7 @@ export class PortfolioService { return { rules, statistics: this.getReportStatistics(rules) }; } + @LogPerformance public async updateTags({ dataSource, impersonationId, @@ -1334,7 +1364,6 @@ export class PortfolioService { userId: string; }) { userId = await this.getUserId(impersonationId, userId); - await this.orderService.assignTags({ dataSource, symbol, tags, userId }); } @@ -1477,6 +1506,7 @@ export class PortfolioService { return { markets, marketsAdvanced }; } + @LogPerformance private async getCashPositions({ cashDetails, userCurrency, @@ -1589,6 +1619,7 @@ export class PortfolioService { return dividendsByGroup; } + @LogPerformance private getEmergencyFundHoldingsValueInBaseCurrency({ holdings }: { @@ -1736,6 +1767,7 @@ export class PortfolioService { return { markets, marketsAdvanced }; } + @LogPerformance private getReportStatistics( evaluatedRules: PortfolioReportResponse['rules'] ): PortfolioReportResponse['statistics'] { @@ -1754,6 +1786,7 @@ export class PortfolioService { return { rulesActiveCount, rulesFulfilledCount }; } + @LogPerformance private getStreaks({ investments, savingsRate @@ -1776,6 +1809,7 @@ export class PortfolioService { return { currentStreak, longestStreak }; } + @LogPerformance private async getSummary({ balanceInBaseCurrency, emergencyFundHoldingsValueInBaseCurrency, @@ -1801,7 +1835,6 @@ export class PortfolioService { userId, withExcludedAccounts: true }); - const excludedActivities: Activity[] = []; const nonExcludedActivities: Activity[] = []; @@ -1864,7 +1897,9 @@ export class PortfolioService { .plus(emergencyFundHoldingsValueInBaseCurrency) .toNumber(); - const committedFunds = new Big(totalBuy).minus(totalSell); + const committedFunds = new Big(totalBuy) + .minus(totalSell) + .minus(dividendInBaseCurrency); const totalOfExcludedActivities = this.getSumOfActivityType({ userCurrency, @@ -1884,7 +1919,6 @@ export class PortfolioService { currency: userCurrency, withExcludedAccounts: true }); - const excludedBalanceInBaseCurrency = new Big( cashDetailsWithExcludedAccounts.balanceInBaseCurrency ).minus(balanceInBaseCurrency); @@ -1893,12 +1927,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); @@ -1961,6 +2000,7 @@ export class PortfolioService { }; } + @LogPerformance private getSumOfActivityType({ activities, activityType, @@ -2017,6 +2057,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 new file mode 100644 index 000000000..5247ecfb4 --- /dev/null +++ b/apps/api/src/app/tag/tag.service.ts @@ -0,0 +1,82 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; + +import { Injectable } from '@nestjs/common'; +import { Prisma, Tag } from '@prisma/client'; + +@Injectable() +export class TagService { + public constructor(private readonly prismaService: PrismaService) {} + + public async createTag(data: Prisma.TagCreateInput) { + return this.prismaService.tag.create({ + data + }); + } + + public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise { + return this.prismaService.tag.delete({ where }); + } + + public async getTag( + tagWhereUniqueInput: Prisma.TagWhereUniqueInput + ): Promise { + return this.prismaService.tag.findUnique({ + where: tagWhereUniqueInput + }); + } + + public async getTags({ + cursor, + orderBy, + skip, + take, + where + }: { + cursor?: Prisma.TagWhereUniqueInput; + orderBy?: Prisma.TagOrderByWithRelationInput; + skip?: number; + take?: number; + where?: Prisma.TagWhereInput; + } = {}) { + return this.prismaService.tag.findMany({ + cursor, + orderBy, + skip, + take, + where + }); + } + + public async getTagsWithActivityCount() { + const tagsWithOrderCount = await this.prismaService.tag.findMany({ + include: { + _count: { + select: { orders: true, symbolProfile: true } + } + } + }); + + return tagsWithOrderCount.map(({ _count, id, name, userId }) => { + return { + id, + name, + userId, + activityCount: _count.orders, + holdingCount: _count.symbolProfile + }; + }); + } + + public async updateTag({ + data, + where + }: { + data: Prisma.TagUpdateInput; + where: Prisma.TagWhereUniqueInput; + }): Promise { + return this.prismaService.tag.update({ + data, + where + }); + } +} diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index b34b6fae2..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 868af505b..0f1c2527f 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -183,6 +183,12 @@ export class UserController { } } + for (const key in data) { + if (data[key] !== false && data[key] !== null) { + userSettings[key] = data[key]; + } + } + return this.userService.updateUserSetting({ emitPortfolioChangedEvent, userSettings, diff --git a/apps/api/src/helper/dateQueryHelper.ts b/apps/api/src/helper/dateQueryHelper.ts new file mode 100644 index 000000000..a2b312a12 --- /dev/null +++ b/apps/api/src/helper/dateQueryHelper.ts @@ -0,0 +1,25 @@ +import { resetHours } from '@ghostfolio/common/helper'; + +import { addDays } from 'date-fns'; + +import { DateQuery } from '../app/portfolio/interfaces/date-query.interface'; + +export class DateQueryHelper { + public handleDateQueryIn(dateQuery: DateQuery): { + query: DateQuery; + dates: Date[]; + } { + let dates = []; + let query = dateQuery; + if (dateQuery.in?.length > 0) { + dates = dateQuery.in; + const end = Math.max(...dates.map((d) => d.getTime())); + const start = Math.min(...dates.map((d) => d.getTime())); + query = { + gte: resetHours(new Date(start)), + lt: resetHours(addDays(end, 1)) + }; + } + return { query, dates }; + } +} diff --git a/apps/api/src/helper/portfolio.helper.ts b/apps/api/src/helper/portfolio.helper.ts index 6ebe48d3c..1248b1409 100644 --- a/apps/api/src/helper/portfolio.helper.ts +++ b/apps/api/src/helper/portfolio.helper.ts @@ -5,6 +5,7 @@ export function getFactor(activityType: ActivityType) { switch (activityType) { case 'BUY': + case 'STAKE': factor = 1; break; case 'SELL': diff --git a/apps/api/src/interceptors/performance-logging/performance-logging.interceptor.ts b/apps/api/src/interceptors/performance-logging/performance-logging.interceptor.ts index d863f0ec3..9f746cc92 100644 --- a/apps/api/src/interceptors/performance-logging/performance-logging.interceptor.ts +++ b/apps/api/src/interceptors/performance-logging/performance-logging.interceptor.ts @@ -43,7 +43,7 @@ export function LogPerformance( ) { const originalMethod = descriptor.value; - descriptor.value = async function (...args: any[]) { + descriptor.value = function (...args: any[]) { const startTime = performance.now(); const performanceLoggingService = new PerformanceLoggingService(); diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts index 8b885c013..652897d9f 100644 --- a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts @@ -14,7 +14,6 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { private static countriesMapping = { 'Russian Federation': 'Russia' }; - private static holdingsWeightTreshold = 0.85; private static sectorsMapping = { 'Consumer Discretionary': 'Consumer Cyclical', 'Consumer Defensive': 'Consumer Staples', @@ -94,10 +93,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { return {}; }); - if ( - holdings?.weight < TrackinsightDataEnhancerService.holdingsWeightTreshold - ) { - // Skip if data is inaccurate + if (holdings?.weight < 1 - Math.min(holdings?.count * 0.000015, 0.95)) { + // Skip if data is inaccurate, dependent on holdings count there might be rounding issues return response; } diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index 88ae136ae..8bac6a95c 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 { @@ -386,6 +387,7 @@ export class DataProviderService { return result; } + @LogPerformance public async getQuotes({ items, requestTimeout, @@ -515,6 +517,8 @@ export class DataProviderService { } response[symbol] = dataProviderResponse; + const quotesCacheTTL = + this.getAppropriateCacheTTL(dataProviderResponse); this.redisCacheService.set( this.redisCacheService.getQuoteKey({ @@ -522,7 +526,7 @@ export class DataProviderService { dataSource: DataSource[dataSource] }), JSON.stringify(response[symbol]), - this.configurationService.get('CACHE_QUOTES_TTL') + quotesCacheTTL ); for (const { @@ -605,6 +609,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 331806098..f12966991 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -13,6 +13,7 @@ import { } from '@ghostfolio/api/services/interfaces/interfaces'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { BatchPrismaClient } from '@ghostfolio/common/chunkhelper'; import { DATE_FORMAT, extractNumberFromString, @@ -146,18 +147,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 390586b37..d8722e3d9 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: { @@ -133,7 +136,6 @@ export class MarketDataService { where: Prisma.MarketDataWhereUniqueInput; }): Promise { const { data, where } = params; - return this.prismaService.marketData.upsert({ where, create: { @@ -157,7 +159,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, @@ -180,7 +182,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..1f88375d5 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({ + assetProfileIdentifiers: [{ dataSource, symbol }], + from: firstEntry.date, + to: new Date() + }); + + const data: Prisma.MarketDataUpdateInput[] = + this.mapToMarketUpsertDataInputs( + dates, + historicalData, + symbol, + dataSource + ); + + await this.marketDataService.updateMany({ data }); + + Logger.log( + `Historical market data gathering for missing values has been completed for ${symbol} (${dataSource}) at ${format( + date, + DATE_FORMAT + )}`, + `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` + ); + } catch (error) { + 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 90a269315..9fbb06e56 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts @@ -13,6 +13,8 @@ import { DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME, GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS, + GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME, + GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS, PROPERTY_BENCHMARKS } from '@ghostfolio/common/config'; import { @@ -111,6 +113,24 @@ export class DataGatheringService { }); } + public async gatherSymbolMissingOnly({ + dataSource, + symbol + }: AssetProfileIdentifier) { + const dataGatheringItems = (await this.getSymbolsMax()).filter( + (dataGatheringItem) => { + return ( + dataGatheringItem.dataSource === dataSource && + dataGatheringItem.symbol === symbol + ); + } + ); + await this.gatherMissingDataSymbols({ + dataGatheringItems, + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + }); + } + public async gatherSymbolForDate({ dataSource, date, @@ -297,6 +317,35 @@ export class DataGatheringService { ); } + public async gatherMissingDataSymbols({ + dataGatheringItems, + priority + }: { + dataGatheringItems: IDataGatheringItem[]; + priority: number; + }) { + await this.addJobsToQueue( + dataGatheringItems.map(({ dataSource, date, symbol }) => { + return { + data: { + dataSource, + date, + symbol + }, + name: GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME, + opts: { + ...GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS, + priority, + jobId: `${getAssetProfileIdentifier({ + dataSource, + symbol + })}-missing-${format(date, DATE_FORMAT)}` + } + }; + }) + ); + } + public async getAllActiveAssetProfileIdentifiers(): Promise< AssetProfileIdentifier[] > { diff --git a/apps/api/src/services/symbol-profile/symbol-profile-overwrite.module.ts b/apps/api/src/services/symbol-profile/symbol-profile-overwrite.module.ts new file mode 100644 index 000000000..9ebd3e2b1 --- /dev/null +++ b/apps/api/src/services/symbol-profile/symbol-profile-overwrite.module.ts @@ -0,0 +1,12 @@ +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; + +import { Module } from '@nestjs/common'; + +import { SymbolProfileOverwriteService } from './symbol-profile-overwrite.service'; + +@Module({ + imports: [PrismaModule], + providers: [SymbolProfileOverwriteService], + exports: [SymbolProfileOverwriteService] +}) +export class SymbolProfileOverwriteModule {} diff --git a/apps/api/src/services/symbol-profile/symbol-profile-overwrite.service.ts b/apps/api/src/services/symbol-profile/symbol-profile-overwrite.service.ts new file mode 100644 index 000000000..bb43a0756 --- /dev/null +++ b/apps/api/src/services/symbol-profile/symbol-profile-overwrite.service.ts @@ -0,0 +1,69 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; + +import { Injectable } from '@nestjs/common'; +import { DataSource, Prisma, SymbolProfileOverrides } from '@prisma/client'; + +@Injectable() +export class SymbolProfileOverwriteService { + public constructor(private readonly prismaService: PrismaService) {} + + public async add( + assetProfileOverwrite: Prisma.SymbolProfileOverridesCreateInput + ): Promise { + return this.prismaService.symbolProfileOverrides.create({ + data: assetProfileOverwrite + }); + } + + public async delete(symbolProfileId: string) { + return this.prismaService.symbolProfileOverrides.delete({ + where: { symbolProfileId: symbolProfileId } + }); + } + + public updateSymbolProfileOverrides({ + assetClass, + assetSubClass, + name, + countries, + sectors, + url, + symbolProfileId + }: Prisma.SymbolProfileOverridesUpdateInput & { symbolProfileId: string }) { + return this.prismaService.symbolProfileOverrides.update({ + data: { + assetClass, + assetSubClass, + name, + countries, + sectors, + url + }, + where: { symbolProfileId: symbolProfileId } + }); + } + + public async GetSymbolProfileId( + Symbol: string, + datasource: DataSource + ): Promise { + const SymbolProfileId = await this.prismaService.symbolProfile + .findFirst({ + where: { + symbol: Symbol, + dataSource: datasource + } + }) + .then((s) => s.id); + + const symbolProfileIdSaved = await this.prismaService.symbolProfileOverrides + .findFirst({ + where: { + symbolProfileId: SymbolProfileId + } + }) + .then((s) => s?.symbolProfileId); + + return symbolProfileIdSaved; + } +} diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts index e934a6324..afb2283d5 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -1,3 +1,4 @@ +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { @@ -10,31 +11,19 @@ import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Injectable } from '@nestjs/common'; -import { Prisma, SymbolProfile, SymbolProfileOverrides } from '@prisma/client'; +import { + Prisma, + SymbolProfile, + SymbolProfileOverrides, + Tag +} from '@prisma/client'; import { continents, countries } from 'countries-list'; @Injectable() export class SymbolProfileService { public constructor(private readonly prismaService: PrismaService) {} - public async add( - assetProfile: Prisma.SymbolProfileCreateInput - ): Promise { - return this.prismaService.symbolProfile.create({ data: assetProfile }); - } - - public async delete({ dataSource, symbol }: AssetProfileIdentifier) { - return this.prismaService.symbolProfile.delete({ - where: { dataSource_symbol: { dataSource, symbol } } - }); - } - - public async deleteById(id: string) { - return this.prismaService.symbolProfile.delete({ - where: { id } - }); - } - + @LogPerformance public async getActiveSymbolProfilesByUserSubscription({ withUserSubscription = false }: { @@ -70,6 +59,7 @@ export class SymbolProfileService { }); } + @LogPerformance public async getSymbolProfiles( aAssetProfileIdentifiers: AssetProfileIdentifier[] ): Promise { @@ -86,6 +76,7 @@ export class SymbolProfileService { select: { date: true }, take: 1 }, + tags: true, SymbolProfileOverrides: true }, where: { @@ -102,6 +93,24 @@ export class SymbolProfileService { }); } + public async add( + assetProfile: Prisma.SymbolProfileCreateInput + ): Promise { + return this.prismaService.symbolProfile.create({ data: assetProfile }); + } + + public async delete({ dataSource, symbol }: AssetProfileIdentifier) { + return this.prismaService.symbolProfile.delete({ + where: { dataSource_symbol: { dataSource, symbol } } + }); + } + + public async deleteById(id: string) { + return this.prismaService.symbolProfile.delete({ + where: { id } + }); + } + public async getSymbolProfilesByIds( symbolProfileIds: string[] ): Promise { @@ -111,7 +120,8 @@ export class SymbolProfileService { _count: { select: { Order: true } }, - SymbolProfileOverrides: true + SymbolProfileOverrides: true, + tags: true }, where: { id: { @@ -155,6 +165,7 @@ export class SymbolProfileService { holdings, isActive, name, + tags, scraperConfiguration, sectors, symbolMapping, @@ -172,6 +183,7 @@ export class SymbolProfileService { holdings, isActive, name, + tags, scraperConfiguration, sectors, symbolMapping, @@ -188,6 +200,7 @@ export class SymbolProfileService { Order?: { date: Date; }[]; + tags?: Tag[]; SymbolProfileOverrides: SymbolProfileOverrides; })[] ): EnhancedSymbolProfile[] { @@ -206,7 +219,8 @@ export class SymbolProfileService { sectors: this.getSectors( symbolProfile?.sectors as unknown as Prisma.JsonArray ), - symbolMapping: this.getSymbolMapping(symbolProfile) + symbolMapping: this.getSymbolMapping(symbolProfile), + tags: symbolProfile?.tags }; item.activitiesCount = symbolProfile._count.Order; diff --git a/apps/api/src/services/tag/tag.service.ts b/apps/api/src/services/tag/tag.service.ts index 3d6bd3907..e410f9260 100644 --- a/apps/api/src/services/tag/tag.service.ts +++ b/apps/api/src/services/tag/tag.service.ts @@ -56,7 +56,8 @@ export class TagService { where: { userId } - } + }, + symbolProfile: {} } } }, @@ -75,11 +76,11 @@ export class TagService { } }); - return tags.map(({ _count, id, name, userId }) => ({ + return tags.map(({ id, name, userId }) => ({ id, name, userId, - isUsed: _count.orders > 0 + isUsed: true })); } 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 b2144d7b3..f4c8fcf14 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", 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 5fe268142..c5e30644c 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 @@ -260,6 +260,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 50ccf0591..aa7533156 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 @@ -198,6 +198,9 @@ >Gather All Historical Market Data + diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index d9b344699..29175c6c3 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts @@ -15,6 +15,7 @@ import { } from '@ghostfolio/common/interfaces'; import { translate } from '@ghostfolio/ui/i18n'; +import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, @@ -35,13 +36,15 @@ import { Validators } from '@angular/forms'; import { MatCheckboxChange } from '@angular/material/checkbox'; +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 { StatusCodes } from 'http-status-codes'; @@ -60,6 +63,8 @@ import { AssetProfileDialogParams } from './interfaces/interfaces'; standalone: false }) export class AssetProfileDialog implements OnDestroy, OnInit { + @ViewChild('tagInput') tagInput: ElementRef; + public separatorKeysCodes: number[] = [ENTER, COMMA]; private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format( new Date(), DATE_FORMAT @@ -91,6 +96,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit { }), isActive: [true], name: ['', Validators.required], + tags: new FormControl(undefined), + tagsDisconnected: new FormControl(undefined), scraperConfiguration: this.formBuilder.group({ defaultMarketPrice: null, headers: JSON.stringify({}), @@ -149,6 +156,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit { [name: string]: { name: string; value: number }; }; + public HoldingTags: { id: string; name: string; userId: string }[]; + public user: User; private unsubscribeSubject = new Subject(); @@ -188,6 +197,18 @@ export class AssetProfileDialog implements OnDestroy, OnInit { } public initialize() { + this.dataService + .fetchTags() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((tags) => { + this.HoldingTags = tags.map(({ id, name, userId }) => { + return { id, name, userId }; + }); + this.dataService.updateInfo(); + + this.changeDetectorRef.markForCheck(); + }); + this.historicalDataItems = undefined; this.userService.stateChanged @@ -247,6 +268,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit { assetClass: this.assetProfile.assetClass ?? null, assetSubClass: this.assetProfile.assetSubClass ?? null, comment: this.assetProfile?.comment ?? '', + tags: this.assetProfile?.tags ?? [], + tagsDisconnected: [], countries: JSON.stringify( this.assetProfile?.countries?.map(({ code, weight }) => { return { code, weight }; @@ -272,7 +295,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit { }, sectors: JSON.stringify(this.assetProfile?.sectors ?? []), symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {}), - url: this.assetProfile?.url ?? '' + url: this.assetProfile?.url }); this.assetProfileForm.markAsPristine(); @@ -316,6 +339,16 @@ export class AssetProfileDialog implements OnDestroy, OnInit { .subscribe(); } + public onGatherSymbolMissingOnly({ + dataSource, + symbol + }: AssetProfileIdentifier) { + this.adminService + .gatherSymbolMissingOnly({ dataSource, symbol }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(); + } + public onMarketDataChanged(withRefresh: boolean = false) { if (withRefresh) { this.initialize(); @@ -397,10 +430,12 @@ export class AssetProfileDialog implements OnDestroy, OnInit { assetClass: this.assetProfileForm.get('assetClass').value, assetSubClass: this.assetProfileForm.get('assetSubClass').value, comment: this.assetProfileForm.get('comment').value || null, + tags: this.assetProfileForm.get('tags').value, + tagsDisconnected: this.assetProfileForm.get('tagsDisconnected').value, currency: this.assetProfileForm.get('currency').value, isActive: this.assetProfileForm.get('isActive').value, name: this.assetProfileForm.get('name').value, - url: this.assetProfileForm.get('url').value || null + url: this.assetProfileForm.get('url').value }; try { @@ -569,6 +604,30 @@ export class AssetProfileDialog implements OnDestroy, OnInit { }); } + public onRemoveTag(aTag: Tag) { + this.assetProfileForm.controls['tags'].setValue( + this.assetProfileForm.controls['tags'].value.filter(({ id }) => { + return id !== aTag.id; + }) + ); + this.assetProfileForm.controls['tagsDisconnected'].setValue([ + ...(this.assetProfileForm.controls['tagsDisconnected'].value ?? []), + aTag + ]); + this.assetProfileForm.markAsDirty(); + } + + public onAddTag(event: MatAutocompleteSelectedEvent) { + this.assetProfileForm.controls['tags'].setValue([ + ...(this.assetProfileForm.controls['tags'].value ?? []), + this.HoldingTags.find(({ id }) => { + return id === event.option.value; + }) + ]); + this.tagInput.nativeElement.value = ''; + this.assetProfileForm.markAsDirty(); + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index 9c13a503c..1fe3a4e82 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 @@ -26,6 +26,19 @@ > Gather Historical Market Dataa +