diff --git a/.agents/skills/karpathy-guidelines/SKILL.md b/.agents/skills/karpathy-guidelines/SKILL.md new file mode 100644 index 000000000..029e4d55d --- /dev/null +++ b/.agents/skills/karpathy-guidelines/SKILL.md @@ -0,0 +1,72 @@ +--- +name: karpathy-guidelines +description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria. +license: MIT +--- + +# Karpathy Guidelines + +Behavioral guidelines to reduce common LLM coding mistakes, derived from [Andrej Karpathy's observations](https://x.com/karpathy/status/2015883857489522876) on LLM coding pitfalls. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: + +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: + +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: + +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: + +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: + +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. diff --git a/.claude/skills/karpathy-guidelines b/.claude/skills/karpathy-guidelines new file mode 120000 index 000000000..743bef527 --- /dev/null +++ b/.claude/skills/karpathy-guidelines @@ -0,0 +1 @@ +../../.agents/skills/karpathy-guidelines \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f70d713f..046d1b527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,95 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Included cash in the performance calculation of the portfolio on the home page +## 3.26.0 - 2026-07-14 + +### Added + +- Added the markets endpoint for the _Fear & Greed Index_ (market mood) to the `GHOSTFOLIO` data provider + +### Changed + +- Hardened the validation of the countries in the asset profile endpoints +- Hardened the validation of the holdings in the asset profile endpoints +- Hardened the validation of the scraper configuration in the asset profile endpoint +- Hardened the validation of the sectors in the asset profile endpoints +- Rounded the value of the _Fear & Greed Index_ (market mood) in the twitter bot service +- Set the change detection strategy to `OnPush` in the _X-ray_ page +- Deprecated `SymbolProfile` in favor of `assetProfile` in the activity interface +- Upgraded `countries-list` from version `3.3.0` to `3.4.0` +- Upgraded `Nx` from version `23.0.1` to `23.0.2` + +## 3.25.0 - 2026-07-12 + +### Changed + +- Changed the default value of the `DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS` environment variable from `RAPID_API` to `MANUAL` +- Improved the language localization for Dutch (`nl`) +- Upgraded `helmet` from version `7.0.0` to `8.2.0` + +### Fixed + +- Fixed the layout of the page tabs component by truncating long labels +- Fixed the display of assets without a currency in the search results of the assistant +- Fixed the display of assets without a currency in the symbol autocomplete component + +### Todo + +- **Breaking Change**: Set the environment variable `DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS=RAPID_API` to keep using _Rapid API_ as the data source of the _Fear & Greed Index_ (market mood) + +## 3.24.0 - 2026-07-11 + +### Added + +- Exposed the `DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS` environment variable to set the data source of the _Fear & Greed Index_ (market mood) +- Exposed the `ENABLE_FEATURE_RATE_LIMITING` environment variable to control rate limiting for authentication and sign-up endpoints +- Exposed the `TRUST_PROXY` environment variable to determine the client IP address when running behind a reverse proxy + +### Changed + +- Rounded the value of the _Fear & Greed Index_ (market mood) +- Improved the language localization for Korean (`ko`) + +## 3.23.0 - 2026-07-10 + +### Changed + +- Migrated the deprecated `@nx/webpack:webpack` executor to `@nx/webpack/plugin` +- Set the change detection strategy to `OnPush` in the about page +- Set the change detection strategy to `OnPush` in the admin control panel +- Set the change detection strategy to `OnPush` in the blog page components +- Set the change detection strategy to `OnPush` in the Frequently Asked Questions (FAQ) page +- Set the change detection strategy to `OnPush` in the home page +- Set the change detection strategy to `OnPush` in the markets overview +- Set the change detection strategy to `OnPush` in the resources page +- Set the change detection strategy to `OnPush` in the user account page +- Set the change detection strategy to `OnPush` in the _Zen Mode_ +- Improved the language localization for Chinese (`zh`) +- Improved the language localization for German (`de`) + +## 3.22.0 - 2026-07-08 + +### Added + +- Added support for a copy-to-clipboard action in the alert dialog component + +### Changed + +- Improved the user account deletion flow in the user settings of the user account page +- Improved the date formatting of the first activity in the historical market data table of the admin control panel +- Set the change detection strategy to `OnPush` in the activities page +- Set the change detection strategy to `OnPush` in the allocations page +- Set the change detection strategy to `OnPush` in the analysis page +- Set the change detection strategy to `OnPush` in the portfolio holdings page +- Set the change detection strategy to `OnPush` in the activities page +- Set the change detection strategy to `OnPush` in the _FIRE_ page +- Set the change detection strategy to `OnPush` in the users section of the admin control panel +- Hardened the endpoint to update a property of the admin control panel by validating the `key` path parameter +- Renamed the `SymbolProfileOverrides` _Prisma_ data model to `AssetProfileOverrides` while keeping the database table name +- Improved the language localization for Dutch (`nl`) +- Improved the language localization for French (`fr`) +- Improved the language localization for German (`de`) + ## 3.21.0 - 2026-07-05 ### Added diff --git a/README.md b/README.md index a9b3a3055..ce89412a3 100644 --- a/README.md +++ b/README.md @@ -85,27 +85,28 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c ### Supported Environment Variables -| Name | Type | Default Value | Description | -| --------------------------- | --------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | -| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | -| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | -| `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` | -| `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) | -| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | -| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | -| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | -| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | -| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | -| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | -| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | -| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | -| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | -| `REDIS_HOST` | `string` | | The host where _Redis_ is running | -| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | -| `REDIS_PORT` | `number` | | The port where _Redis_ is running | -| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | -| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | +| Name | Type | Default Value | Description | +| --------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | +| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | +| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | +| `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` | +| `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) | +| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | +| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | +| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | +| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | +| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | +| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | +| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | +| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | +| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | +| `REDIS_HOST` | `string` | | The host where _Redis_ is running | +| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | +| `REDIS_PORT` | `number` | | The port where _Redis_ is running | +| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | +| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | +| `TRUST_PROXY` | `string` (optional) | | The [trust proxy](https://expressjs.com/en/guide/behind-proxies.html) setting of _Express.js_ to determine the client IP address for rate limiting, e.g. `1` if the Ghostfolio application runs behind a single reverse proxy | #### OpenID Connect OIDC (experimental) diff --git a/apps/api/project.json b/apps/api/project.json index 4e1affb13..c4c1be7ac 100644 --- a/apps/api/project.json +++ b/apps/api/project.json @@ -7,32 +7,10 @@ "generators": {}, "targets": { "build": { - "executor": "@nx/webpack:webpack", - "options": { - "compiler": "tsc", - "deleteOutputPath": false, - "main": "apps/api/src/main.ts", - "outputPath": "dist/apps/api", - "sourceMap": true, - "target": "node", - "tsConfig": "apps/api/tsconfig.app.json", - "webpackConfig": "apps/api/webpack.config.js" - }, "configurations": { - "production": { - "generatePackageJson": true, - "optimization": true, - "extractLicenses": true, - "inspect": false, - "fileReplacements": [ - { - "replace": "apps/api/src/environments/environment.ts", - "with": "apps/api/src/environments/environment.prod.ts" - } - ] - } + "production": {} }, - "outputs": ["{options.outputPath}"] + "outputs": ["{workspaceRoot}/dist/apps/api"] }, "copy-assets": { "executor": "nx:run-commands", diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index b404b6a37..427cddf4c 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -455,6 +455,23 @@ export class ActivitiesService { userId, accountId: account.id, accountUserId: account.userId, + assetProfile: { + activitiesCount: 0, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + countries: [], + createdAt: new Date(balanceItem.date), + currency: account.currency, + dataSource: + this.dataProviderService.getDataSourceForExchangeRates(), + holdings: [], + id: account.currency, + isActive: true, + name: account.currency, + sectors: [], + symbol: account.currency, + updatedAt: new Date(balanceItem.date) + }, comment: account.name, createdAt: new Date(balanceItem.date), currency: account.currency, @@ -615,14 +632,14 @@ export class ActivitiesService { }, { OR: [ - { SymbolProfileOverrides: { is: null } }, - { SymbolProfileOverrides: { assetClass: null } } + { assetProfileOverrides: { is: null } }, + { assetProfileOverrides: { assetClass: null } } ] } ] }, { - SymbolProfileOverrides: { + assetProfileOverrides: { OR: filtersByAssetClass.map(({ id }) => { return { assetClass: AssetClass[id] }; }) @@ -823,6 +840,7 @@ export class ActivitiesService { return { ...order, + assetProfile, feeInAssetProfileCurrency, feeInBaseCurrency, unitPriceInAssetProfileCurrency, diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 019468dfd..26e0de0ac 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -29,7 +29,11 @@ import { ScraperConfiguration } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; -import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; +import type { + DateRange, + PropertyKey, + RequestWithUser +} from '@ghostfolio/common/types'; import { Body, @@ -55,6 +59,7 @@ import { isDate, parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AdminService } from './admin.service'; +import { PropertyKeyPipe } from './pipes/property-key.pipe'; @Controller('admin') export class AdminController { @@ -310,7 +315,7 @@ export class AdminController { @Put('settings/:key') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async updateProperty( - @Param('key') key: string, + @Param('key', PropertyKeyPipe) key: PropertyKey, @Body() data: UpdatePropertyDto ) { return this.adminService.putSetting(key, data.value); diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index e6f5fd700..8325fa90e 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -299,7 +299,7 @@ export class AdminService { ); } } else { - const symbolProfileOverrides = { + const assetProfileOverrides = { assetClass: assetClass as AssetClass, assetSubClass: assetSubClass as AssetSubClass, countries: countries as Prisma.JsonArray, @@ -320,7 +320,7 @@ export class AdminService { symbolMapping, ...this.symbolProfileService.getAssetProfileUpdateInput( { dataSource, symbol }, - symbolProfileOverrides + assetProfileOverrides ) }; @@ -344,17 +344,17 @@ export class AdminService { } } - public async putSetting(key: string, value: string) { + public async putSetting(key: PropertyKey, value: string) { let response: Property; if (value) { response = await this.propertyService.put({ - value, - key: key as PropertyKey + key, + value }); } else { response = await this.propertyService.delete({ - key: key as PropertyKey + key }); } diff --git a/apps/api/src/app/admin/pipes/property-key.pipe.ts b/apps/api/src/app/admin/pipes/property-key.pipe.ts new file mode 100644 index 000000000..a980b552b --- /dev/null +++ b/apps/api/src/app/admin/pipes/property-key.pipe.ts @@ -0,0 +1,29 @@ +import * as config from '@ghostfolio/common/config'; +import type { PropertyKey } from '@ghostfolio/common/types'; + +import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; + +@Injectable() +export class PropertyKeyPipe implements PipeTransform { + private readonly allowedKeys: Set; + + public constructor() { + this.allowedKeys = new Set( + Object.entries(config) + .filter(([key]) => { + return key.startsWith('PROPERTY_'); + }) + .map(([, value]) => { + return value as string; + }) + ); + } + + public transform(value: string): PropertyKey { + if (!this.allowedKeys.has(value)) { + throw new BadRequestException(`Invalid property key: ${value}`); + } + + return value as PropertyKey; + } +} diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 04b1f3bf2..4bdd50c9e 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,7 +1,9 @@ import { EventsModule } from '@ghostfolio/api/events/events.module'; +import { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper'; import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware'; import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { CronModule } from '@ghostfolio/api/services/cron/cron.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; @@ -13,18 +15,22 @@ import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/po import { BULL_BOARD_ROUTE, DEFAULT_LANGUAGE_CODE, - SUPPORTED_LANGUAGE_CODES + SUPPORTED_LANGUAGE_CODES, + THROTTLE_DEFAULT_LIMIT, + THROTTLE_DEFAULT_TTL } from '@ghostfolio/common/config'; import { ExpressAdapter } from '@bull-board/express'; import { BullBoardModule } from '@bull-board/nestjs'; +import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis'; import { BullModule } from '@nestjs/bull'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; -import { StatusCodes } from 'http-status-codes'; +import { ThrottlerModule } from '@nestjs/throttler'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { join } from 'node:path'; import { AccessModule } from './access/access.module'; @@ -95,12 +101,13 @@ import { UserModule } from './user/user.module'; middleware: BullBoardAuthMiddleware, route: BULL_BOARD_ROUTE }), - BullModule.forRoot({ - redis: { - db: parseInt(process.env.REDIS_DB ?? '0', 10), - host: process.env.REDIS_HOST, - password: process.env.REDIS_PASSWORD, - port: parseInt(process.env.REDIS_PORT ?? '6379', 10) + BullModule.forRootAsync({ + imports: [ConfigurationModule], + inject: [ConfigurationService], + useFactory: (configurationService: ConfigurationService) => { + return { + redis: getRedisConnectionOptions(configurationService) + }; } }), CacheModule, @@ -168,6 +175,36 @@ import { UserModule } from './user/user.module'; SubscriptionModule, SymbolModule, TagsModule, + ThrottlerModule.forRootAsync({ + imports: [ConfigurationModule], + inject: [ConfigurationService], + useFactory: (configurationService: ConfigurationService) => { + const isRateLimitingEnabled = configurationService.get( + 'ENABLE_FEATURE_RATE_LIMITING' + ); + + return { + errorMessage: getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + skipIf: () => { + return !isRateLimitingEnabled; + }, + storage: isRateLimitingEnabled + ? new ThrottlerStorageRedisService({ + ...getRedisConnectionOptions(configurationService), + // Reject commands immediately while Redis is unavailable + enableOfflineQueue: false, + maxRetriesPerRequest: 1 + }) + : undefined, + throttlers: [ + { + limit: THROTTLE_DEFAULT_LIMIT, + ttl: THROTTLE_DEFAULT_TTL + } + ] + }; + } + }), UserModule, WatchlistModule ], diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index 388f1dbd3..ac50f4b8a 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -1,4 +1,5 @@ import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; +import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; @@ -39,6 +40,7 @@ export class AuthController { * @deprecated */ @Get('anonymous/:accessToken') + @UseGuards(CustomThrottlerGuard) public async accessTokenLoginGet( @Param('accessToken') accessToken: string ): Promise { @@ -55,6 +57,7 @@ export class AuthController { } @Post('anonymous') + @UseGuards(CustomThrottlerGuard) public async accessTokenLogin( @Body() body: { accessToken: string } ): Promise { @@ -135,6 +138,7 @@ export class AuthController { } @Post('webauthn/generate-authentication-options') + @UseGuards(CustomThrottlerGuard) public async generateAuthenticationOptions( @Body() body: { deviceId: string } ) { @@ -156,6 +160,7 @@ export class AuthController { } @Post('webauthn/verify-authentication') + @UseGuards(CustomThrottlerGuard) public async verifyAuthentication( @Body() body: { deviceId: string; credential: AssertionCredentialJSON } ) { diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts index 68b8d5627..b1a36df26 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts @@ -198,6 +198,7 @@ export class AssetProfilesService { take: 1 }, assetClass: true, + assetProfileOverrides: true, assetSubClass: true, comment: true, countries: true, @@ -210,8 +211,7 @@ export class AssetProfilesService { name: true, scraperConfiguration: true, sectors: true, - symbol: true, - SymbolProfileOverrides: true + symbol: true } }), this.prismaService.symbolProfile.count({ where }) @@ -268,7 +268,7 @@ export class AssetProfilesService { const { assetClass, assetSubClass, countries, name, sectors } = applyAssetProfileOverrides( assetProfile, - assetProfile.SymbolProfileOverrides + assetProfile.assetProfileOverrides ); const countriesCount = countries ? Object.keys(countries).length : 0; diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts index 04165e9a1..5d6979acc 100644 --- a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts @@ -8,6 +8,7 @@ import { DividendsResponse, HistoricalResponse, LookupResponse, + MarketDataOfMarketsResponse, QuotesResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; @@ -19,6 +20,7 @@ import { HttpException, Inject, Param, + ParseIntPipe, Query, UseGuards, Version @@ -203,6 +205,43 @@ export class GhostfolioController { } } + @Get('markets') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('api-key'), HasPermissionGuard) + public async getMarketDataOfMarkets( + @Query('includeHistoricalData', new ParseIntPipe({ optional: true })) + includeHistoricalData = 0 + ): Promise { + const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); + + if ( + this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + + try { + const marketDataOfMarkets = + await this.ghostfolioService.getMarketDataOfMarkets({ + includeHistoricalData + }); + + await this.ghostfolioService.incrementDailyRequests({ + userId: this.request.user.id + }); + + return marketDataOfMarkets; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } + @Get('quotes') @HasPermission(permissions.enableDataProviderGhostfolio) @UseGuards(AuthGuard('api-key'), HasPermissionGuard) diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts index 484f30ee3..1b8788ecb 100644 --- a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts @@ -1,4 +1,5 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; @@ -33,6 +34,7 @@ import { GhostfolioService } from './ghostfolio.service'; PrismaModule, PropertyModule, RedisCacheModule, + SymbolModule, SymbolProfileModule ], providers: [ diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts index caff86ae0..abbb5cbf6 100644 --- a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts @@ -1,3 +1,4 @@ +import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service'; @@ -25,6 +26,7 @@ import { HistoricalResponse, LookupItem, LookupResponse, + MarketDataOfMarketsResponse, QuotesResponse } from '@ghostfolio/common/interfaces'; import { UserWithSettings } from '@ghostfolio/common/types'; @@ -32,6 +34,7 @@ import { UserWithSettings } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; import { Big } from 'big.js'; +import { isEmpty } from 'lodash'; @Injectable() export class GhostfolioService { @@ -42,7 +45,8 @@ export class GhostfolioService { private readonly dataProviderService: DataProviderService, private readonly fetchService: FetchService, private readonly prismaService: PrismaService, - private readonly propertyService: PropertyService + private readonly propertyService: PropertyService, + private readonly symbolService: SymbolService ) {} public async getAssetProfile({ symbol }: GetAssetProfileParams) { @@ -198,6 +202,33 @@ export class GhostfolioService { } } + public async getMarketDataOfMarkets({ + includeHistoricalData + }: { + includeHistoricalData: number; + }): Promise { + try { + const marketDataOfMarkets = + await this.symbolService.getMarketDataOfMarkets({ + includeHistoricalData + }); + + for (const symbolItem of Object.values( + marketDataOfMarkets.fearAndGreedIndex + )) { + if (!isEmpty(symbolItem)) { + symbolItem.dataSource = DataSource.GHOSTFOLIO; + } + } + + return marketDataOfMarkets; + } catch (error) { + this.logger.error(error); + + throw error; + } + } + public async getMaxDailyRequests() { return parseInt( (await this.propertyService.getByKey( diff --git a/apps/api/src/app/endpoints/market-data/market-data.controller.ts b/apps/api/src/app/endpoints/market-data/market-data.controller.ts index 5dad0511f..03d50c284 100644 --- a/apps/api/src/app/endpoints/market-data/market-data.controller.ts +++ b/apps/api/src/app/endpoints/market-data/market-data.controller.ts @@ -3,12 +3,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; -import { - ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, - ghostfolioFearAndGreedIndexDataSourceStocks, - ghostfolioFearAndGreedIndexSymbolCryptocurrencies, - ghostfolioFearAndGreedIndexSymbolStocks -} from '@ghostfolio/common/config'; import { UpdateBulkMarketDataDto } from '@ghostfolio/common/dtos'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces'; @@ -49,38 +43,9 @@ export class MarketDataController { @Query('includeHistoricalData', new ParseIntPipe({ optional: true })) includeHistoricalData = 0 ): Promise { - const [ - marketDataFearAndGreedIndexCryptocurrencies, - marketDataFearAndGreedIndexStocks - ] = await Promise.all([ - this.symbolService.get({ - includeHistoricalData, - dataGatheringItem: { - dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, - symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies - }, - useIntradayData: true - }), - this.symbolService.get({ - includeHistoricalData, - dataGatheringItem: { - dataSource: ghostfolioFearAndGreedIndexDataSourceStocks, - symbol: ghostfolioFearAndGreedIndexSymbolStocks - }, - useIntradayData: true - }) - ]); - - return { - fearAndGreedIndex: { - CRYPTOCURRENCIES: { - ...marketDataFearAndGreedIndexCryptocurrencies - }, - STOCKS: { - ...marketDataFearAndGreedIndexStocks - } - } - }; + return this.symbolService.getMarketDataOfMarkets({ + includeHistoricalData + }); } @Post(':dataSource/:symbol') diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts index f43e51f7b..a87d36218 100644 --- a/apps/api/src/app/endpoints/public/public.controller.ts +++ b/apps/api/src/app/endpoints/public/public.controller.ts @@ -110,26 +110,27 @@ export class PublicController { ? [] : activities.map( ({ + assetProfile, currency, date, fee, quantity, - SymbolProfile, type, unitPrice, value, valueInBaseCurrency }) => { return { + assetProfile, currency, date, fee, quantity, - SymbolProfile, type, unitPrice, value, - valueInBaseCurrency + valueInBaseCurrency, + SymbolProfile: assetProfile }; } ); diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index 8ebfde13d..a94479562 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -124,8 +124,8 @@ export class ExportService { const customAssetProfiles = uniqBy( activities - .map(({ SymbolProfile }) => { - return SymbolProfile; + .map(({ assetProfile }) => { + return assetProfile; }) .filter(({ userId: assetProfileUserId }) => { return assetProfileUserId === userId; @@ -224,13 +224,13 @@ export class ExportService { activities: activities.map( ({ accountId, + assetProfile, comment, currency, date, fee, id, quantity, - SymbolProfile, tags: currentTags, type, unitPrice @@ -243,10 +243,10 @@ export class ExportService { quantity, type, unitPrice, - currency: currency ?? SymbolProfile.currency, - dataSource: SymbolProfile.dataSource, + currency: currency ?? assetProfile.currency, + dataSource: assetProfile.dataSource, date: date.toISOString(), - symbol: SymbolProfile.symbol, + symbol: assetProfile.symbol, tags: currentTags.map(({ id: tagId }) => { return tagId; }) diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 9b1aa417c..07019e694 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -122,11 +122,11 @@ export class ImportService { const isDuplicate = activities.some((activity) => { return ( activity.accountId === account?.id && - activity.SymbolProfile.currency === assetProfile.currency && - activity.SymbolProfile.dataSource === assetProfile.dataSource && + activity.assetProfile.currency === assetProfile.currency && + activity.assetProfile.dataSource === assetProfile.dataSource && isSameSecond(activity.date, date) && activity.quantity === quantity && - activity.SymbolProfile.symbol === assetProfile.symbol && + activity.assetProfile.symbol === assetProfile.symbol && activity.type === 'DIVIDEND' && activity.unitPrice === marketPrice ); @@ -138,6 +138,7 @@ export class ImportService { return { account, + assetProfile, date, error, quantity, @@ -463,19 +464,18 @@ export class ImportService { const error = activity.error; const fee = activity.fee; const quantity = activity.quantity; - const SymbolProfile = activity.SymbolProfile; const tagIds = activity.tagIds ?? []; const type = activity.type; const unitPrice = activity.unitPrice; const assetProfile = assetProfiles[ getAssetProfileIdentifier({ - dataSource: SymbolProfile.dataSource, - symbol: SymbolProfile.symbol + dataSource: activity.assetProfile.dataSource, + symbol: activity.assetProfile.symbol }) ] ?? { - dataSource: SymbolProfile.dataSource, - symbol: SymbolProfile.symbol + dataSource: activity.assetProfile.dataSource, + symbol: activity.assetProfile.symbol }; const { assetClass, @@ -619,6 +619,8 @@ export class ImportService { activities.push({ ...order, + // @ts-ignore + assetProfile, error, value, valueInBaseCurrency, @@ -633,19 +635,19 @@ export class ImportService { if (!isDryRun) { // Gather symbol data in the background, if not dry run - const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => { + const uniqueActivities = uniqBy(activities, ({ assetProfile }) => { return getAssetProfileIdentifier({ - dataSource: SymbolProfile.dataSource, - symbol: SymbolProfile.symbol + dataSource: assetProfile.dataSource, + symbol: assetProfile.symbol }); }); this.dataGatheringService.gatherSymbols({ - dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => { + dataGatheringItems: uniqueActivities.map(({ assetProfile, date }) => { return { date, - dataSource: SymbolProfile.dataSource, - symbol: SymbolProfile.symbol + dataSource: assetProfile.dataSource, + symbol: assetProfile.symbol }; }), priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH @@ -692,12 +694,12 @@ export class ImportService { activity.accountId === accountId && activity.comment === comment && (activity.currency === currency || - activity.SymbolProfile.currency === currency) && - activity.SymbolProfile.dataSource === dataSource && + activity.assetProfile.currency === currency) && + activity.assetProfile.dataSource === dataSource && isSameSecond(activity.date, date) && activity.fee === fee && activity.quantity === quantity && - activity.SymbolProfile.symbol === symbol && + activity.assetProfile.symbol === symbol && activity.type === type && activity.unitPrice === unitPrice ); @@ -717,7 +719,7 @@ export class ImportService { quantity, type, unitPrice, - SymbolProfile: { + assetProfile: { dataSource, symbol, activitiesCount: undefined, diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index 4441036a2..10836f7b8 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -4,6 +4,7 @@ import { UserService } from '@ghostfolio/api/app/user/user.service'; import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { @@ -15,8 +16,7 @@ import { PROPERTY_GITHUB_STARGAZERS, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_SLACK_COMMUNITY_USERS, - PROPERTY_UPTIME, - ghostfolioFearAndGreedIndexDataSourceStocks + PROPERTY_UPTIME } from '@ghostfolio/common/config'; import { InfoItem, Statistics } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; @@ -33,6 +33,7 @@ export class InfoService { public constructor( private readonly benchmarkService: BenchmarkService, private readonly configurationService: ConfigurationService, + private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly jwtService: JwtService, private readonly propertyService: PropertyService, @@ -60,13 +61,15 @@ export class InfoService { } if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { + const fearAndGreedIndexDataSource = + this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(); + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { info.fearAndGreedDataSource = encodeDataSource( - ghostfolioFearAndGreedIndexDataSourceStocks + fearAndGreedIndexDataSource ); } else { - info.fearAndGreedDataSource = - ghostfolioFearAndGreedIndexDataSourceStocks; + info.fearAndGreedDataSource = fearAndGreedIndexDataSource; } globalPermissions.push(permissions.enableFearAndGreedIndex); diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts index f4c99916f..5e6bfba99 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts @@ -22,7 +22,7 @@ export const activityDummyData = { valueInBaseCurrency: undefined }; -export const symbolProfileDummyData = { +export const assetProfileDummyData = { activitiesCount: undefined, assetClass: undefined, assetSubClass: undefined, diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index be450bf4b..ccf4005ff 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -121,11 +121,11 @@ export abstract class PortfolioCalculator { this.activities = activities .map( ({ + assetProfile, date, feeInAssetProfileCurrency, feeInBaseCurrency, quantity, - SymbolProfile, tags = [], type, unitPriceInAssetProfileCurrency @@ -141,7 +141,7 @@ export abstract class PortfolioCalculator { } return { - SymbolProfile, + assetProfile, tags, type, date: format(date, DATE_FORMAT), @@ -930,23 +930,23 @@ export abstract class PortfolioCalculator { let lastTransactionPoint: TransactionPoint = null; for (const { + assetProfile, date, fee, feeInBaseCurrency, quantity, - SymbolProfile, tags, type, unitPrice } of this.activities) { let currentTransactionPointItem: TransactionPointSymbol; - const assetSubClass = SymbolProfile.assetSubClass; - const currency = SymbolProfile.currency; - const dataSource = SymbolProfile.dataSource; + const assetSubClass = assetProfile.assetSubClass; + const currency = assetProfile.currency; + const dataSource = assetProfile.dataSource; const factor = getFactor(type); - const skipErrors = !!SymbolProfile.userId; // Skip errors for custom asset profiles - const symbol = SymbolProfile.symbol; + const skipErrors = !!assetProfile.userId; // Skip errors for custom asset profiles + const symbol = assetProfile.symbol; const oldAccumulatedSymbol = symbols[symbol]; @@ -1030,12 +1030,12 @@ export abstract class PortfolioCalculator { 'id' ); - symbols[SymbolProfile.symbol] = currentTransactionPointItem; + symbols[symbol] = currentTransactionPointItem; const items = lastTransactionPoint?.items ?? []; const newItems = items.filter(({ symbol }) => { - return symbol !== SymbolProfile.symbol; + return symbol !== assetProfile.symbol; }); newItems.push(currentTransactionPointItem); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts index f21418cb4..fe23af04b 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -85,33 +85,33 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2021-11-22'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'CHF', dataSource: 'YAHOO', name: 'Bâloise Holding AG', symbol: 'BALN.SW' }, + date: new Date('2021-11-22'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, type: 'BUY', unitPriceInAssetProfileCurrency: 142.9 }, { ...activityDummyData, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.65, - feeInBaseCurrency: 1.65, - quantity: 2, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'CHF', dataSource: 'YAHOO', name: 'Bâloise Holding AG', symbol: 'BALN.SW' }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.65, + feeInBaseCurrency: 1.65, + quantity: 2, type: 'BUY', unitPriceInAssetProfileCurrency: 136.6 } 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 e2cba2e61..061aaf817 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 @@ -1,231 +1,231 @@ -import { - activityDummyData, - symbolProfileDummyData, - userDummyData -} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; -import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; -import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; -import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { 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 { Activity } from '@ghostfolio/common/interfaces'; -import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; - -import { Big } from 'big.js'; - -jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { - return { - CurrentRateService: jest.fn().mockImplementation(() => { - return CurrentRateServiceMock; - }) - }; -}); - -jest.mock( - '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', - () => { - return { - PortfolioSnapshotService: jest.fn().mockImplementation(() => { - return PortfolioSnapshotServiceMock; - }) - }; - } -); - -jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { - return { - RedisCacheService: jest.fn().mockImplementation(() => { - return RedisCacheServiceMock; - }) - }; -}); - -describe('PortfolioCalculator', () => { - let configurationService: ConfigurationService; - let currentRateService: CurrentRateService; - let exchangeRateDataService: ExchangeRateDataService; - let portfolioCalculatorFactory: PortfolioCalculatorFactory; - let portfolioSnapshotService: PortfolioSnapshotService; - let redisCacheService: RedisCacheService; - - beforeEach(() => { - configurationService = new ConfigurationService(); - - currentRateService = new CurrentRateService(null, null, null, null); - - exchangeRateDataService = new ExchangeRateDataService( - null, - null, - null, - null - ); - - portfolioSnapshotService = new PortfolioSnapshotService(null, null); - - redisCacheService = new RedisCacheService(null, null); - - portfolioCalculatorFactory = new PortfolioCalculatorFactory( - configurationService, - currentRateService, - exchangeRateDataService, - portfolioSnapshotService, - redisCacheService - ); - }); - - describe('get current positions', () => { - it.only('with BALN.SW buy and sell in two activities', async () => { - jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); - - const activities: Activity[] = [ - { - ...activityDummyData, - date: new Date('2021-11-22'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - SymbolProfile: { - ...symbolProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - type: 'BUY', - unitPriceInAssetProfileCurrency: 142.9 - }, - { - ...activityDummyData, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.65, - feeInBaseCurrency: 1.65, - quantity: 1, - SymbolProfile: { - ...symbolProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - type: 'SELL', - unitPriceInAssetProfileCurrency: 136.6 - }, - { - ...activityDummyData, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 1, - SymbolProfile: { - ...symbolProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - type: 'SELL', - unitPriceInAssetProfileCurrency: 136.6 - } - ]; - - 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' - }); - - const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ - data: portfolioSnapshot.historicalData, - groupBy: 'year' - }); - - expect(portfolioSnapshot).toMatchObject({ - currentValueInBaseCurrency: new Big('0'), - errors: [], - hasErrors: false, - positions: [ - { - activitiesCount: 3, - averagePrice: new Big('0'), - currency: 'CHF', - dataSource: 'YAHOO', - dateOfFirstActivity: '2021-11-22', - dividend: new Big('0'), - dividendInBaseCurrency: new Big('0'), - fee: new Big('3.2'), - feeInBaseCurrency: new Big('3.2'), - grossPerformance: new Big('-12.6'), - grossPerformancePercentage: new Big('-0.04408677396780965649'), - grossPerformancePercentageWithCurrencyEffect: new Big( - '-0.04408677396780965649' - ), - grossPerformanceWithCurrencyEffect: new Big('-12.6'), - investment: new Big('0'), - investmentWithCurrencyEffect: new Big('0'), - netPerformancePercentageWithCurrencyEffectMap: { - max: new Big('-0.0552834149755073478') - }, - netPerformanceWithCurrencyEffectMap: { - max: new Big('-15.8') - }, - marketPrice: 148.9, - marketPriceInBaseCurrency: 148.9, - quantity: new Big('0'), - symbol: 'BALN.SW', - tags: [], - timeWeightedInvestment: new Big('285.80000000000000396627'), - timeWeightedInvestmentWithCurrencyEffect: new Big( - '285.80000000000000396627' - ), - valueInBaseCurrency: new Big('0') - } - ], - totalFeesWithCurrencyEffect: new Big('3.2'), - totalInterestWithCurrencyEffect: new Big('0'), - totalInvestment: new Big('0'), - totalInvestmentWithCurrencyEffect: new Big('0'), - totalLiabilitiesWithCurrencyEffect: new Big('0') - }); - - expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( - expect.objectContaining({ - netPerformance: -15.8, - netPerformanceInPercentage: -0.05528341497550734703, - netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, - netPerformanceWithCurrencyEffect: -15.8, - totalInvestment: 0, - totalInvestmentValueWithCurrencyEffect: 0 - }) - ); - - expect(investments).toEqual([ - { date: '2021-11-22', investment: new Big('285.8') }, - { date: '2021-11-30', investment: new Big('0') } - ]); - - expect(investmentsByMonth).toEqual([ - { date: '2021-11-01', investment: 0 }, - { date: '2021-12-01', investment: 0 } - ]); - - expect(investmentsByYear).toEqual([ - { date: '2021-01-01', investment: 0 } - ]); - }); - }); -}); +import { + activityDummyData, + assetProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BALN.SW buy and sell in two activities', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + date: new Date('2021-11-22'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, + type: 'BUY', + unitPriceInAssetProfileCurrency: 142.9 + }, + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.65, + feeInBaseCurrency: 1.65, + quantity: 1, + type: 'SELL', + unitPriceInAssetProfileCurrency: 136.6 + }, + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + type: 'SELL', + unitPriceInAssetProfileCurrency: 136.6 + } + ]; + + 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' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 3, + averagePrice: new Big('0'), + currency: 'CHF', + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-11-22', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('3.2'), + feeInBaseCurrency: new Big('3.2'), + grossPerformance: new Big('-12.6'), + grossPerformancePercentage: new Big('-0.04408677396780965649'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '-0.04408677396780965649' + ), + grossPerformanceWithCurrencyEffect: new Big('-12.6'), + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('-0.0552834149755073478') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('-15.8') + }, + marketPrice: 148.9, + marketPriceInBaseCurrency: 148.9, + quantity: new Big('0'), + symbol: 'BALN.SW', + tags: [], + timeWeightedInvestment: new Big('285.80000000000000396627'), + timeWeightedInvestmentWithCurrencyEffect: new Big( + '285.80000000000000396627' + ), + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('3.2'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: -15.8, + netPerformanceInPercentage: -0.05528341497550734703, + netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, + netPerformanceWithCurrencyEffect: -15.8, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0 + }) + ); + + expect(investments).toEqual([ + { date: '2021-11-22', investment: new Big('285.8') }, + { date: '2021-11-30', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2021-11-01', investment: 0 }, + { date: '2021-12-01', investment: 0 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2021-01-01', investment: 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 fa397de46..c8e0af46d 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 @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -85,33 +85,33 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2021-11-22'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'CHF', dataSource: 'YAHOO', name: 'Bâloise Holding AG', symbol: 'BALN.SW' }, + date: new Date('2021-11-22'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, type: 'BUY', unitPriceInAssetProfileCurrency: 142.9 }, { ...activityDummyData, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.65, - feeInBaseCurrency: 1.65, - quantity: 2, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'CHF', dataSource: 'YAHOO', name: 'Bâloise Holding AG', symbol: 'BALN.SW' }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.65, + feeInBaseCurrency: 1.65, + quantity: 2, type: 'SELL', unitPriceInAssetProfileCurrency: 136.6 } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts index db44fe0ae..4922382a9 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -85,17 +85,17 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'CHF', dataSource: 'YAHOO', name: 'Bâloise Holding AG', symbol: 'BALN.SW' }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, type: 'BUY', unitPriceInAssetProfileCurrency: 136.6 } @@ -217,17 +217,17 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'CHF', dataSource: 'YAHOO', name: 'Bâloise Holding AG', symbol: 'BALN.SW' }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, type: 'BUY', unitPriceInAssetProfileCurrency: 135.0 } @@ -257,17 +257,17 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'CHF', dataSource: 'YAHOO', name: 'Bâloise Holding AG', symbol: 'BALN.SW' }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, type: 'BUY', unitPriceInAssetProfileCurrency: 135.0 } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts index 1afd9225d..21a8d2056 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts @@ -1,7 +1,7 @@ import { activityDummyData, + assetProfileDummyData, loadExportFile, - symbolProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -107,17 +107,17 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = exportResponse.activities.map( (activity) => ({ ...activityDummyData, - ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: 4.46, - feeInBaseCurrency: 3.94, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: activity.dataSource, name: 'Bitcoin', symbol: activity.symbol }, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: 4.46, + feeInBaseCurrency: 3.94, unitPriceInAssetProfileCurrency: 44558.42 }) ); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts index 0e5750166..bc80e8996 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts @@ -1,7 +1,7 @@ import { activityDummyData, + assetProfileDummyData, loadExportFile, - symbolProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -95,17 +95,17 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = exportResponse.activities.map( (activity) => ({ ...activityDummyData, - ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: 4.46, - feeInBaseCurrency: 4.46, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: activity.dataSource, name: 'Bitcoin', symbol: activity.symbol }, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: 4.46, + feeInBaseCurrency: 4.46, unitPriceInAssetProfileCurrency: 44558.42 }) ); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index 1e556735d..f20506b06 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -98,33 +98,33 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2015-01-01'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 2, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'YAHOO', name: 'Bitcoin USD', symbol: 'BTCUSD' }, + date: new Date('2015-01-01'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 2, type: 'BUY', unitPriceInAssetProfileCurrency: 320.43 }, { ...activityDummyData, - date: new Date('2017-12-31'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 1, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'YAHOO', name: 'Bitcoin USD', symbol: 'BTCUSD' }, + date: new Date('2017-12-31'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, type: 'SELL', unitPriceInAssetProfileCurrency: 14156.4 } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts index 9d84540e3..79c6979ef 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts @@ -1,7 +1,7 @@ import { activityDummyData, + assetProfileDummyData, loadExportFile, - symbolProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -96,16 +96,16 @@ describe('PortfolioCalculator', () => { (activity) => ({ ...activityDummyData, ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - feeInBaseCurrency: activity.fee, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: activity.dataSource, name: 'Bitcoin', symbol: activity.symbol }, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, unitPriceInAssetProfileCurrency: activity.unitPrice }) ); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts index 0916e18a4..18b0e6d64 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts @@ -1,7 +1,7 @@ import { activityDummyData, + assetProfileDummyData, loadExportFile, - symbolProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -96,16 +96,16 @@ describe('PortfolioCalculator', () => { (activity) => ({ ...activityDummyData, ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: 4.46, - feeInBaseCurrency: 4.46, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: activity.dataSource, name: 'Bitcoin', symbol: activity.symbol }, + date: parseDate(activity.date), + feeInAssetProfileCurrency: 4.46, + feeInBaseCurrency: 4.46, unitPriceInAssetProfileCurrency: 44558.42 }) ); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts index b55c94b2b..e5798818f 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -85,17 +85,17 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2021-09-01'), - feeInAssetProfileCurrency: 49, - feeInBaseCurrency: 49, - quantity: 0, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'MANUAL', name: 'Account Opening Fee', symbol: '2c463fb3-af07-486e-adb0-8301b3d72141' }, + date: new Date('2021-09-01'), + feeInAssetProfileCurrency: 49, + feeInBaseCurrency: 49, + quantity: 0, type: 'FEE', unitPriceInAssetProfileCurrency: 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 bb61d637e..984dc1154 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 @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -97,17 +97,17 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2023-01-03'), - feeInAssetProfileCurrency: 1, - feeInBaseCurrency: 0.9238, - quantity: 1, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'YAHOO', name: 'Alphabet Inc.', symbol: 'GOOGL' }, + date: new Date('2023-01-03'), + feeInAssetProfileCurrency: 1, + feeInBaseCurrency: 0.9238, + quantity: 1, type: 'BUY', unitPriceInAssetProfileCurrency: 89.12 } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts index 88afc971d..962cfe2d8 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts @@ -1,190 +1,190 @@ -import { - activityDummyData, - loadExportFile, - symbolProfileDummyData, - userDummyData -} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; -import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; -import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; -import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; -import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; -import { parseDate } from '@ghostfolio/common/helper'; -import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; -import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; - -import { Big } from 'big.js'; -import { join } from 'node:path'; - -jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { - return { - CurrentRateService: jest.fn().mockImplementation(() => { - return CurrentRateServiceMock; - }) - }; -}); - -jest.mock( - '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', - () => { - return { - PortfolioSnapshotService: jest.fn().mockImplementation(() => { - return PortfolioSnapshotServiceMock; - }) - }; - } -); - -jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { - return { - RedisCacheService: jest.fn().mockImplementation(() => { - return RedisCacheServiceMock; - }) - }; -}); - -describe('PortfolioCalculator', () => { - let exportResponse: ExportResponse; - - let configurationService: ConfigurationService; - let currentRateService: CurrentRateService; - let exchangeRateDataService: ExchangeRateDataService; - let portfolioCalculatorFactory: PortfolioCalculatorFactory; - let portfolioSnapshotService: PortfolioSnapshotService; - let redisCacheService: RedisCacheService; - - beforeAll(() => { - exportResponse = loadExportFile( - join( - __dirname, - '../../../../../../../test/import/ok/jnug-buy-and-sell-and-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, null); - - redisCacheService = new RedisCacheService(null, null); - - portfolioCalculatorFactory = new PortfolioCalculatorFactory( - configurationService, - currentRateService, - exchangeRateDataService, - portfolioSnapshotService, - redisCacheService - ); - }); - - describe('get current positions', () => { - it.only('with JNUG buy and sell', async () => { - jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime()); - - const activities: Activity[] = exportResponse.activities.map( - (activity) => ({ - ...activityDummyData, - ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - feeInBaseCurrency: activity.fee, - SymbolProfile: { - ...symbolProfileDummyData, - currency: activity.currency, - dataSource: activity.dataSource, - name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares', - symbol: activity.symbol - }, - unitPriceInAssetProfileCurrency: activity.unitPrice - }) - ); - - const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ - activities, - calculationType: PerformanceCalculationType.ROAI, - currency: exportResponse.user.settings.currency, - userId: userDummyData.id - }); - - const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); - - const investments = portfolioCalculator.getInvestments(); - - const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ - data: portfolioSnapshot.historicalData, - groupBy: 'month' - }); - - const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ - data: portfolioSnapshot.historicalData, - groupBy: 'year' - }); - - expect(portfolioSnapshot).toMatchObject({ - currentValueInBaseCurrency: new Big('0'), - errors: [], - hasErrors: false, - positions: [ - { - activitiesCount: 4, - averagePrice: new Big('0'), - currency: 'USD', - dataSource: 'YAHOO', - dateOfFirstActivity: '2025-12-11', - dividend: new Big('0'), - dividendInBaseCurrency: new Big('0'), - fee: new Big('4'), - feeInBaseCurrency: new Big('4'), - grossPerformance: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - grossPerformanceWithCurrencyEffect: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - investment: new Big('0'), - investmentWithCurrencyEffect: new Big('0'), - netPerformance: new Big('39.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4 - netPerformanceWithCurrencyEffectMap: { - max: new Big('39.95') // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4 - }, - marketPrice: 237.8000030517578, - marketPriceInBaseCurrency: 237.8000030517578, - quantity: new Big('0'), - symbol: 'JNUG', - tags: [], - valueInBaseCurrency: new Big('0') - } - ], - totalFeesWithCurrencyEffect: new Big('4'), - totalInterestWithCurrencyEffect: new Big('0'), - totalInvestment: new Big('0'), - totalInvestmentWithCurrencyEffect: new Big('0'), - totalLiabilitiesWithCurrencyEffect: new Big('0') - }); - - expect(investments).toEqual([ - { date: '2025-12-11', investment: new Big('1885.05') }, - { date: '2025-12-18', investment: new Big('2041.1') }, - { date: '2025-12-28', investment: new Big('0') } - ]); - - expect(investmentsByMonth).toEqual([ - { date: '2025-12-01', investment: 0 } - ]); - - expect(investmentsByYear).toEqual([ - { date: '2025-01-01', investment: 0 } - ]); - }); - }); -}); +import { + activityDummyData, + assetProfileDummyData, + loadExportFile, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; +import { join } from 'node:path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let exportResponse: ExportResponse; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + exportResponse = loadExportFile( + join( + __dirname, + '../../../../../../../test/import/ok/jnug-buy-and-sell-and-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, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with JNUG buy and sell', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime()); + + const activities: Activity[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + assetProfile: { + ...assetProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares', + symbol: activity.symbol + }, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, + unitPriceInAssetProfileCurrency: activity.unitPrice + }) + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: exportResponse.user.settings.currency, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 4, + averagePrice: new Big('0'), + currency: 'USD', + dataSource: 'YAHOO', + dateOfFirstActivity: '2025-12-11', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('4'), + feeInBaseCurrency: new Big('4'), + grossPerformance: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) + grossPerformanceWithCurrencyEffect: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + netPerformance: new Big('39.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4 + netPerformanceWithCurrencyEffectMap: { + max: new Big('39.95') // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4 + }, + marketPrice: 237.8000030517578, + marketPriceInBaseCurrency: 237.8000030517578, + quantity: new Big('0'), + symbol: 'JNUG', + tags: [], + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('4'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(investments).toEqual([ + { date: '2025-12-11', investment: new Big('1885.05') }, + { date: '2025-12-18', investment: new Big('2041.1') }, + { date: '2025-12-28', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2025-12-01', investment: 0 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2025-01-01', investment: 0 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts index 4f2058513..94654af61 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -85,17 +85,17 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2023-01-01'), // Date in future - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 1, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'MANUAL', name: 'Loan', symbol: '55196015-1365-4560-aa60-8751ae6d18f8' }, + date: new Date('2023-01-01'), // Date in future + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, type: 'LIABILITY', unitPriceInAssetProfileCurrency: 3000 } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts index 36d1ea1ec..b1f030aae 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -78,49 +78,49 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2024-03-08'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 0.3333333333333333, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'YAHOO', name: 'Microsoft Inc.', symbol: 'MSFT' }, + date: new Date('2024-03-08'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 0.3333333333333333, type: 'BUY', unitPriceInAssetProfileCurrency: 408 }, { ...activityDummyData, - date: new Date('2024-03-13'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 0.6666666666666666, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'YAHOO', name: 'Microsoft Inc.', symbol: 'MSFT' }, + date: new Date('2024-03-13'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 0.6666666666666666, type: 'BUY', unitPriceInAssetProfileCurrency: 400 }, { ...activityDummyData, - date: new Date('2024-03-14'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 1, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'YAHOO', name: 'Microsoft Inc.', symbol: 'MSFT' }, + date: new Date('2024-03-14'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, type: 'SELL', unitPriceInAssetProfileCurrency: 411 } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts index c50ef2b34..91c095623 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -85,33 +85,33 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2021-09-16'), - feeInAssetProfileCurrency: 19, - feeInBaseCurrency: 19, - quantity: 1, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'YAHOO', name: 'Microsoft Inc.', symbol: 'MSFT' }, + date: new Date('2021-09-16'), + feeInAssetProfileCurrency: 19, + feeInBaseCurrency: 19, + quantity: 1, type: 'BUY', unitPriceInAssetProfileCurrency: 298.58 }, { ...activityDummyData, - date: new Date('2021-11-16'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 1, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'YAHOO', name: 'Microsoft Inc.', symbol: 'MSFT' }, + date: new Date('2021-11-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, type: 'DIVIDEND', unitPriceInAssetProfileCurrency: 0.62 } 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 cdfc78906..8ce62db59 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 @@ -1,7 +1,7 @@ import { activityDummyData, + assetProfileDummyData, loadExportFile, - symbolProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -99,16 +99,16 @@ describe('PortfolioCalculator', () => { (activity) => ({ ...activityDummyData, ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - feeInBaseCurrency: activity.fee, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: activity.currency, dataSource: activity.dataSource, name: 'Novartis AG', symbol: activity.symbol }, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, unitPriceInAssetProfileCurrency: activity.unitPrice }) ); 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 2cd754c82..f86b23d9a 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,264 +1,264 @@ -import { - activityDummyData, - loadExportFile, - symbolProfileDummyData, - userDummyData -} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; -import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; -import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; -import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; -import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; -import { parseDate } from '@ghostfolio/common/helper'; -import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; -import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; - -import { Big } from 'big.js'; -import { join } from 'node:path'; - -jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { - return { - CurrentRateService: jest.fn().mockImplementation(() => { - return CurrentRateServiceMock; - }) - }; -}); - -jest.mock( - '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', - () => { - return { - PortfolioSnapshotService: jest.fn().mockImplementation(() => { - return PortfolioSnapshotServiceMock; - }) - }; - } -); - -jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { - return { - RedisCacheService: jest.fn().mockImplementation(() => { - return RedisCacheServiceMock; - }) - }; -}); - -describe('PortfolioCalculator', () => { - let exportResponse: ExportResponse; - - let configurationService: ConfigurationService; - let currentRateService: CurrentRateService; - let exchangeRateDataService: ExchangeRateDataService; - let portfolioCalculatorFactory: PortfolioCalculatorFactory; - let portfolioSnapshotService: PortfolioSnapshotService; - let redisCacheService: RedisCacheService; - - beforeAll(() => { - exportResponse = loadExportFile( - 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, 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[] = exportResponse.activities.map( - (activity) => ({ - ...activityDummyData, - ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - feeInBaseCurrency: activity.fee, - SymbolProfile: { - ...symbolProfileDummyData, - currency: activity.currency, - dataSource: activity.dataSource, - name: 'Novartis AG', - symbol: activity.symbol - }, - unitPriceInAssetProfileCurrency: activity.unitPrice - }) - ); - - const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ - activities, - calculationType: PerformanceCalculationType.ROAI, - currency: exportResponse.user.settings.currency, - userId: userDummyData.id - }); - - const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); - - const investments = portfolioCalculator.getInvestments(); - - const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ - data: portfolioSnapshot.historicalData, - groupBy: 'month' - }); - - const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ - data: portfolioSnapshot.historicalData, - groupBy: 'year' - }); - - expect(portfolioSnapshot.historicalData[0]).toEqual({ - date: '2022-03-06', - investmentValueWithCurrencyEffect: 0, - netPerformance: 0, - netPerformanceInPercentage: 0, - netPerformanceInPercentageWithCurrencyEffect: 0, - netPerformanceWithCurrencyEffect: 0, - netWorth: 0, - totalAccountBalance: 0, - totalInvestment: 0, - totalInvestmentValueWithCurrencyEffect: 0, - value: 0, - valueWithCurrencyEffect: 0 - }); - - /** - * Closing price on 2022-03-07 is unknown, - * hence it uses the last unit price (2022-04-11): 87.8 - */ - expect(portfolioSnapshot.historicalData[1]).toEqual({ - date: '2022-03-07', - investmentValueWithCurrencyEffect: 151.6, - netPerformance: 24, // 2 * (87.8 - 75.8) = 24 - netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438 - netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438 - netPerformanceWithCurrencyEffect: 24, - netWorth: 175.6, // 2 * 87.8 = 175.6 - totalAccountBalance: 0, - totalInvestment: 151.6, - totalInvestmentValueWithCurrencyEffect: 151.6, - value: 175.6, // 2 * 87.8 = 175.6 - valueWithCurrencyEffect: 175.6 - }); - - expect( - portfolioSnapshot.historicalData[ - portfolioSnapshot.historicalData.length - 1 - ] - ).toEqual({ - date: '2022-04-11', - investmentValueWithCurrencyEffect: 0, - netPerformance: 19.86, - netPerformanceInPercentage: 0.13100263852242744, - netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, - netPerformanceWithCurrencyEffect: 19.86, - netWorth: 0, - totalAccountBalance: 0, - totalInvestment: 0, - totalInvestmentValueWithCurrencyEffect: 0, - value: 0, - valueWithCurrencyEffect: 0 - }); - - expect(portfolioSnapshot).toMatchObject({ - currentValueInBaseCurrency: new Big('0'), - errors: [], - hasErrors: false, - positions: [ - { - activitiesCount: 2, - averagePrice: new Big('0'), - currency: 'CHF', - dataSource: 'YAHOO', - dateOfFirstActivity: '2022-03-07', - dividend: new Big('0'), - dividendInBaseCurrency: new Big('0'), - fee: new Big('0'), - feeInBaseCurrency: new Big('0'), - 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'), - valueInBaseCurrency: new Big('0') - } - ], - totalFeesWithCurrencyEffect: new Big('0'), - totalInterestWithCurrencyEffect: new Big('0'), - totalInvestment: new Big('0'), - totalInvestmentWithCurrencyEffect: new Big('0'), - totalLiabilitiesWithCurrencyEffect: new Big('0') - }); - - expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( - expect.objectContaining({ - netPerformance: 19.86, - netPerformanceInPercentage: 0.13100263852242744063, - netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, - netPerformanceWithCurrencyEffect: 19.86, - totalInvestment: 0, - 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 } - ]); - - expect(investmentsByYear).toEqual([ - { date: '2022-01-01', investment: 0 } - ]); - }); - }); -}); +import { + activityDummyData, + assetProfileDummyData, + loadExportFile, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; +import { join } from 'node:path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let exportResponse: ExportResponse; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + exportResponse = loadExportFile( + 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, 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[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + assetProfile: { + ...assetProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: 'Novartis AG', + symbol: activity.symbol + }, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, + unitPriceInAssetProfileCurrency: activity.unitPrice + }) + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: exportResponse.user.settings.currency, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot.historicalData[0]).toEqual({ + date: '2022-03-06', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + /** + * Closing price on 2022-03-07 is unknown, + * hence it uses the last unit price (2022-04-11): 87.8 + */ + expect(portfolioSnapshot.historicalData[1]).toEqual({ + date: '2022-03-07', + investmentValueWithCurrencyEffect: 151.6, + netPerformance: 24, // 2 * (87.8 - 75.8) = 24 + netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438 + netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438 + netPerformanceWithCurrencyEffect: 24, + netWorth: 175.6, // 2 * 87.8 = 175.6 + totalAccountBalance: 0, + totalInvestment: 151.6, + totalInvestmentValueWithCurrencyEffect: 151.6, + value: 175.6, // 2 * 87.8 = 175.6 + valueWithCurrencyEffect: 175.6 + }); + + expect( + portfolioSnapshot.historicalData[ + portfolioSnapshot.historicalData.length - 1 + ] + ).toEqual({ + date: '2022-04-11', + investmentValueWithCurrencyEffect: 0, + netPerformance: 19.86, + netPerformanceInPercentage: 0.13100263852242744, + netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, + netPerformanceWithCurrencyEffect: 19.86, + netWorth: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 2, + averagePrice: new Big('0'), + currency: 'CHF', + dataSource: 'YAHOO', + dateOfFirstActivity: '2022-03-07', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('0'), + feeInBaseCurrency: new Big('0'), + 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'), + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: 19.86, + netPerformanceInPercentage: 0.13100263852242744063, + netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, + netPerformanceWithCurrencyEffect: 19.86, + totalInvestment: 0, + 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 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2022-01-01', investment: 0 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts index 1d6cee0fa..226eaa3d8 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts @@ -1,6 +1,6 @@ import { activityDummyData, - symbolProfileDummyData, + assetProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -85,17 +85,17 @@ describe('PortfolioCalculator', () => { const activities: Activity[] = [ { ...activityDummyData, - date: new Date('2022-01-01'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 1, - SymbolProfile: { - ...symbolProfileDummyData, + assetProfile: { + ...assetProfileDummyData, currency: 'USD', dataSource: 'MANUAL', name: 'Penthouse Apartment', symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde' }, + date: new Date('2022-01-01'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, type: 'BUY', unitPriceInAssetProfileCurrency: 500000 } 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 d5efc4bf2..f79e5bc79 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -192,12 +192,12 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { // Clone orders to keep the original values in this.orders let orders: PortfolioOrderItem[] = cloneDeep( - this.activities.filter(({ SymbolProfile }) => { - return SymbolProfile.symbol === symbol; + this.activities.filter(({ assetProfile }) => { + return assetProfile.symbol === symbol; }) ); - const isCash = orders[0]?.SymbolProfile?.assetSubClass === 'CASH'; + const isCash = orders[0]?.assetProfile?.assetSubClass === 'CASH'; if (orders.length <= 0) { return { @@ -299,32 +299,30 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { }; } + const assetProfile: PortfolioOrderItem['assetProfile'] = { + dataSource, + symbol, + assetSubClass: isCash ? 'CASH' : undefined + }; + // Add a synthetic order at the start and the end date orders.push({ + assetProfile, date: startDateString, fee: new Big(0), feeInBaseCurrency: new Big(0), itemType: 'start', quantity: new Big(0), - SymbolProfile: { - dataSource, - symbol, - assetSubClass: isCash ? 'CASH' : undefined - }, type: 'BUY', unitPrice: unitPriceAtStartDate }); orders.push({ + assetProfile, date: endDateString, fee: new Big(0), feeInBaseCurrency: new Big(0), itemType: 'end', - SymbolProfile: { - dataSource, - symbol, - assetSubClass: isCash ? 'CASH' : undefined - }, quantity: new Big(0), type: 'BUY', unitPrice: unitPriceAtEndDate @@ -357,15 +355,11 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { } } else { orders.push({ + assetProfile, date: dateString, fee: new Big(0), feeInBaseCurrency: new Big(0), quantity: new Big(0), - SymbolProfile: { - dataSource, - symbol, - assetSubClass: isCash ? 'CASH' : undefined - }, type: 'BUY', unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice, unitPriceFromMarketData: diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts index 2dbd68f12..becab8c04 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts @@ -1,13 +1,13 @@ import { Activity } from '@ghostfolio/common/interfaces'; export interface PortfolioOrder extends Pick { + assetProfile: Pick< + Activity['assetProfile'], + 'assetSubClass' | 'currency' | 'dataSource' | 'name' | 'symbol' | 'userId' + >; date: string; fee: Big; feeInBaseCurrency: Big; quantity: Big; - SymbolProfile: Pick< - Activity['SymbolProfile'], - 'assetSubClass' | 'currency' | 'dataSource' | 'name' | 'symbol' | 'userId' - >; unitPrice: Big; } diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index 04142c92b..adddb4013 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -373,15 +373,15 @@ describe('PortfolioService', () => { { account, accountId: account.id, + assetProfile: { symbol: 'AAPL' }, quantity: 1, - SymbolProfile: { symbol: 'AAPL' }, type: 'BUY' }, { account: null, accountId: null, + assetProfile: { symbol: 'BABA' }, quantity: 2, - SymbolProfile: { symbol: 'BABA' }, type: 'BUY' } ], @@ -409,8 +409,8 @@ describe('PortfolioService', () => { { account, accountId: account.id, + assetProfile: { symbol: 'AAPL' }, quantity: 1, - SymbolProfile: { symbol: 'AAPL' }, type: 'BUY' } ], diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index e75e54890..6617b8f9b 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -829,10 +829,9 @@ export class PortfolioService { timeWeightedInvestmentWithCurrencyEffect } = holding; - const activitiesOfHolding = activities.filter(({ SymbolProfile }) => { + const activitiesOfHolding = activities.filter(({ assetProfile }) => { return ( - SymbolProfile.dataSource === dataSource && - SymbolProfile.symbol === symbol + assetProfile.dataSource === dataSource && assetProfile.symbol === symbol ); }); @@ -2056,11 +2055,11 @@ export class PortfolioService { .filter(({ isDraft, type }) => { return isDraft === false && type === activityType; }) - .map(({ currency, quantity, SymbolProfile, unitPrice }) => { + .map(({ assetProfile, currency, quantity, unitPrice }) => { return new Big( this.exchangeRateDataService.toCurrency( new Big(quantity).mul(unitPrice).toNumber(), - currency ?? SymbolProfile.currency, + currency ?? assetProfile.currency, userCurrency ) ); @@ -2198,16 +2197,11 @@ export class PortfolioService { } } - for (const { - account, - quantity, - SymbolProfile, - type - } of ordersByAccount) { + for (const { account, assetProfile, quantity, type } of ordersByAccount) { const currentValueOfSymbolInBaseCurrency = getFactor(type) * quantity * - (portfolioItemsNow[SymbolProfile.symbol]?.marketPriceInBaseCurrency ?? + (portfolioItemsNow[assetProfile.symbol]?.marketPriceInBaseCurrency ?? 0); if (accounts[account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { diff --git a/apps/api/src/app/redis-cache/redis-cache.module.ts b/apps/api/src/app/redis-cache/redis-cache.module.ts index d0e3228b7..8d56c7c51 100644 --- a/apps/api/src/app/redis-cache/redis-cache.module.ts +++ b/apps/api/src/app/redis-cache/redis-cache.module.ts @@ -1,3 +1,4 @@ +import { getRedisConnectionUrl } from '@ghostfolio/api/helper/redis.helper'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -14,16 +15,8 @@ import { RedisCacheService } from './redis-cache.service'; imports: [ConfigurationModule], inject: [ConfigurationService], useFactory: async (configurationService: ConfigurationService) => { - const redisPassword = encodeURIComponent( - configurationService.get('REDIS_PASSWORD') - ); - return { - stores: [ - createKeyv( - `redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}` - ) - ], + stores: [createKeyv(getRedisConnectionUrl(configurationService))], ttl: configurationService.get('CACHE_TTL') }; } diff --git a/apps/api/src/app/symbol/symbol.service.ts b/apps/api/src/app/symbol/symbol.service.ts index 39d279de1..2ace4feeb 100644 --- a/apps/api/src/app/symbol/symbol.service.ts +++ b/apps/api/src/app/symbol/symbol.service.ts @@ -1,6 +1,11 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { + ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, + ghostfolioFearAndGreedIndexSymbolCryptocurrencies, + ghostfolioFearAndGreedIndexSymbolStocks +} from '@ghostfolio/common/config'; import { DATE_FORMAT, getAssetProfileIdentifier @@ -9,6 +14,7 @@ import { DataProviderHistoricalResponse, HistoricalDataItem, LookupResponse, + MarketDataOfMarketsResponse, SymbolItem } from '@ghostfolio/common/interfaces'; import { UserWithSettings } from '@ghostfolio/common/types'; @@ -122,6 +128,46 @@ export class SymbolService { }; } + public async getMarketDataOfMarkets({ + includeHistoricalData + }: { + includeHistoricalData: number; + }): Promise { + const [ + marketDataFearAndGreedIndexCryptocurrencies, + marketDataFearAndGreedIndexStocks + ] = await Promise.all([ + this.get({ + includeHistoricalData, + dataGatheringItem: { + dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, + symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies + }, + useIntradayData: true + }), + this.get({ + includeHistoricalData, + dataGatheringItem: { + dataSource: + this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(), + symbol: ghostfolioFearAndGreedIndexSymbolStocks + }, + useIntradayData: true + }) + ]); + + return { + fearAndGreedIndex: { + CRYPTOCURRENCIES: { + ...marketDataFearAndGreedIndexCryptocurrencies + }, + STOCKS: { + ...marketDataFearAndGreedIndexStocks + } + } + }; + } + public async lookup({ includeIndices = false, query, diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 6346ce43a..7a1fc71dc 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -1,11 +1,16 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; -import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; +import { + HEADER_KEY_IMPERSONATION, + THROTTLE_SIGNUP_LIMIT, + THROTTLE_SIGNUP_TTL +} from '@ghostfolio/common/config'; import { DeleteOwnUserDto, UpdateOwnAccessTokenDto, @@ -37,6 +42,7 @@ import { import { REQUEST } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; import { AuthGuard } from '@nestjs/passport'; +import { Throttle } from '@nestjs/throttler'; import { User as UserModel } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { merge, size } from 'lodash'; @@ -128,6 +134,13 @@ export class UserController { } @Post() + @Throttle({ + default: { + limit: THROTTLE_SIGNUP_LIMIT, + ttl: THROTTLE_SIGNUP_TTL + } + }) + @UseGuards(CustomThrottlerGuard) public async signupUser(): Promise { const isUserSignupEnabled = await this.propertyService.isUserSignupEnabled(); diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 0c159bc1c..4dcf16034 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -535,6 +535,14 @@ export class UserService { user.subscription.offer.label = undefined; } + if ( + !hasRole(user, Role.DEMO) && + (user.provider !== 'ANONYMOUS' || + user.subscription?.type === SubscriptionType.Premium) + ) { + currentPermissions.push(permissions.requestOwnUserDeletion); + } + if (hasRole(user, Role.ADMIN)) { currentPermissions.push(permissions.syncDemoUserAccount); } diff --git a/apps/api/src/guards/custom-throttler.guard.ts b/apps/api/src/guards/custom-throttler.guard.ts new file mode 100644 index 000000000..c4f0e806d --- /dev/null +++ b/apps/api/src/guards/custom-throttler.guard.ts @@ -0,0 +1,21 @@ +import { ExecutionContext, Injectable, Logger } from '@nestjs/common'; +import { ThrottlerException, ThrottlerGuard } from '@nestjs/throttler'; + +@Injectable() +export class CustomThrottlerGuard extends ThrottlerGuard { + private readonly logger = new Logger(CustomThrottlerGuard.name); + + public async canActivate(context: ExecutionContext): Promise { + try { + return await super.canActivate(context); + } catch (error) { + if (error instanceof ThrottlerException) { + throw error; + } + + this.logger.error(error); + + return true; + } + } +} diff --git a/apps/api/src/helper/redis.helper.ts b/apps/api/src/helper/redis.helper.ts new file mode 100644 index 000000000..81fe905eb --- /dev/null +++ b/apps/api/src/helper/redis.helper.ts @@ -0,0 +1,22 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +export function getRedisConnectionOptions( + configurationService: ConfigurationService +) { + return { + db: configurationService.get('REDIS_DB'), + host: configurationService.get('REDIS_HOST'), + password: configurationService.get('REDIS_PASSWORD'), + port: configurationService.get('REDIS_PORT') + }; +} + +export function getRedisConnectionUrl( + configurationService: ConfigurationService +): string { + const { db, host, password, port } = + getRedisConnectionOptions(configurationService); + const encodedPassword = encodeURIComponent(password); + + return `redis://${encodedPassword ? `:${encodedPassword}` : ''}@${host}:${port}/${db}`; +} diff --git a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts index 56d260d4d..4e75ee9b7 100644 --- a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts +++ b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts @@ -78,8 +78,12 @@ export class TransformDataSourceInResponseInterceptor< valueMap, object: data, paths: [ + 'activities[*].assetProfile.dataSource', 'activities[*].dataSource', + + /* @deprecated */ 'activities[*].SymbolProfile.dataSource', + 'assetProfile.dataSource', 'benchmarks[*].dataSource', 'errors[*].dataSource', @@ -88,7 +92,10 @@ export class TransformDataSourceInResponseInterceptor< 'holdings[*].assetProfile.dataSource', 'holdings[*].dataSource', 'items[*].dataSource', + + /* @deprecated */ 'SymbolProfile.dataSource', + 'watchlist[*].dataSource' ] }); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 63185a48b..33ad032e9 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,3 +1,4 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { BULL_BOARD_ROUTE, DEFAULT_HOST, @@ -39,6 +40,8 @@ async function bootstrap() { ) as LogLevel[]; } catch {} + await configApp.close(); + const app = await NestFactory.create(AppModule, { logger: customLogLevels ?? @@ -97,6 +100,23 @@ async function bootstrap() { }); } + const configurationService = app.get(ConfigurationService); + + const trustProxy = configurationService.get('TRUST_PROXY'); + + if (trustProxy) { + app.set('trust proxy', trustProxy); + } + + if ( + configurationService.get('ENABLE_FEATURE_RATE_LIMITING') && + trustProxy === '' + ) { + logger.warn( + 'Rate limiting is enabled, but TRUST_PROXY is not set. If the Ghostfolio application runs behind a reverse proxy, the rate limits are shared across all clients.' + ); + } + const HOST = configService.get('HOST') || DEFAULT_HOST; const PORT = configService.get('PORT') || DEFAULT_PORT; diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index c96ccd946..3c152d5df 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -13,9 +13,31 @@ import { import { Injectable } from '@nestjs/common'; import { DataSource } from '@prisma/client'; -import { bool, cleanEnv, host, json, num, port, str, url } from 'envalid'; +import { + bool, + cleanEnv, + host, + json, + makeValidator, + num, + port, + str, + url +} from 'envalid'; import ms from 'ms'; +const trustProxy = makeValidator((input) => { + if (/^\d+$/.test(input)) { + return Number(input); + } else if (input === 'false') { + return false; + } else if (input === 'true') { + return true; + } + + return input; +}); + @Injectable() export class ConfigurationService { private readonly environmentConfiguration: Environment; @@ -34,6 +56,9 @@ export class ConfigurationService { CACHE_QUOTES_TTL: num({ default: ms('1 minute') }), CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }), DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), + DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS: str({ + default: DataSource.MANUAL + }), DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }), DATA_SOURCES: json({ default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO] @@ -47,6 +72,7 @@ export class ConfigurationService { ENABLE_FEATURE_CRON: bool({ default: true }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }), + ENABLE_FEATURE_RATE_LIMITING: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), @@ -111,6 +137,7 @@ export class ConfigurationService { default: environment.rootUrl }), STRIPE_SECRET_KEY: str({ default: '' }), + TRUST_PROXY: trustProxy({ default: '' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }), TWITTER_API_KEY: str({ default: 'dummyApiKey' }), 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 c6e9c83c1..8723466b0 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -177,6 +177,12 @@ export class DataProviderService implements OnModuleInit { ]; } + public getDataSourceForFearAndGreedIndexStocks(): DataSource { + return DataSource[ + this.configurationService.get('DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS') + ]; + } + public getDataSourceForImport(): DataSource { return DataSource[this.configurationService.get('DATA_SOURCE_IMPORT')]; } diff --git a/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts b/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts index d8ba1ec67..e704f2861 100644 --- a/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts +++ b/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts @@ -64,13 +64,15 @@ export class RapidApiService implements DataProviderInterface { if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) { const fgi = await this.getFearAndGreedIndex(); - return { - [symbol]: { - [format(getYesterday(), DATE_FORMAT)]: { - marketPrice: fgi.previousClose.value + if (fgi) { + return { + [symbol]: { + [format(getYesterday(), DATE_FORMAT)]: { + marketPrice: fgi.previousClose.value + } } - } - }; + }; + } } } catch (error) { throw new Error( @@ -101,14 +103,16 @@ export class RapidApiService implements DataProviderInterface { if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) { const fgi = await this.getFearAndGreedIndex(); - return { - [symbol]: { - currency: undefined, - dataSource: this.getName(), - marketPrice: fgi.now.value, - marketState: 'open' - } - }; + if (fgi) { + return { + [symbol]: { + currency: undefined, + dataSource: this.getName(), + marketPrice: fgi.now.value, + marketState: 'open' + } + }; + } } } catch (error) { this.logger.error(error); diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 89463e201..bb58b01ab 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -13,6 +13,7 @@ export interface Environment extends CleanedEnvAccessors { CACHE_QUOTES_TTL: number; CACHE_TTL: number; DATA_SOURCE_EXCHANGE_RATES: string; + DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS: string; DATA_SOURCE_IMPORT: string; DATA_SOURCES: string[]; DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[]; @@ -22,6 +23,7 @@ export interface Environment extends CleanedEnvAccessors { ENABLE_FEATURE_CRON: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean; + ENABLE_FEATURE_RATE_LIMITING: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_STATISTICS: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean; @@ -56,6 +58,7 @@ export interface Environment extends CleanedEnvAccessors { REQUEST_TIMEOUT: number; ROOT_URL: string; STRIPE_SECRET_KEY: string; + TRUST_PROXY: boolean | number | string; TWITTER_ACCESS_TOKEN: string; TWITTER_ACCESS_TOKEN_SECRET: string; TWITTER_API_KEY: string; 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 5d0968c5c..7157f0856 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -12,10 +12,10 @@ import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Injectable } from '@nestjs/common'; import { + AssetProfileOverrides, DataSource, Prisma, - SymbolProfile, - SymbolProfileOverrides + SymbolProfile } from '@prisma/client'; import { continents, countries } from 'countries-list'; @@ -85,12 +85,12 @@ export class SymbolProfileService { } return { - SymbolProfileOverrides: { + assetProfileOverrides: { upsert: { create: - data as Prisma.SymbolProfileOverridesCreateWithoutSymbolProfileInput, + data as Prisma.AssetProfileOverridesCreateWithoutSymbolProfileInput, update: - data as Prisma.SymbolProfileOverridesUpdateWithoutSymbolProfileInput + data as Prisma.AssetProfileOverridesUpdateWithoutSymbolProfileInput } } }; @@ -112,7 +112,7 @@ export class SymbolProfileService { select: { date: true }, take: 1 }, - SymbolProfileOverrides: true + assetProfileOverrides: true }, where: { OR: aAssetProfileIdentifiers.map(({ dataSource, symbol }) => { @@ -137,7 +137,7 @@ export class SymbolProfileService { _count: { select: { activities: true, watchedBy: true } }, - SymbolProfileOverrides: true + assetProfileOverrides: true }, where: { id: { @@ -174,6 +174,7 @@ export class SymbolProfileService { { dataSource, symbol }: AssetProfileIdentifier, { assetClass, + assetProfileOverrides, assetSubClass, comment, countries, @@ -185,13 +186,13 @@ export class SymbolProfileService { scraperConfiguration, sectors, symbolMapping, - SymbolProfileOverrides, url }: Prisma.SymbolProfileUpdateInput ) { return this.prismaService.symbolProfile.update({ data: { assetClass, + assetProfileOverrides, assetSubClass, comment, countries, @@ -203,7 +204,6 @@ export class SymbolProfileService { scraperConfiguration, sectors, symbolMapping, - SymbolProfileOverrides, url }, where: { dataSource_symbol: { dataSource, symbol } } @@ -216,13 +216,13 @@ export class SymbolProfileService { activities?: { date: Date; }[]; - SymbolProfileOverrides: SymbolProfileOverrides; + assetProfileOverrides: AssetProfileOverrides; })[] ): EnhancedSymbolProfile[] { return symbolProfiles.map((symbolProfile) => { const symbolProfileWithOverrides = applyAssetProfileOverrides( symbolProfile, - symbolProfile.SymbolProfileOverrides + symbolProfile.assetProfileOverrides ); const item = { @@ -252,7 +252,7 @@ export class SymbolProfileService { item.dateOfFirstActivity = symbolProfile.activities?.[0]?.date; delete item.activities; - delete item.SymbolProfileOverrides; + delete item.assetProfileOverrides; return item; }); diff --git a/apps/api/src/services/twitter-bot/twitter-bot.module.ts b/apps/api/src/services/twitter-bot/twitter-bot.module.ts index 80d53169c..bdb1a7988 100644 --- a/apps/api/src/services/twitter-bot/twitter-bot.module.ts +++ b/apps/api/src/services/twitter-bot/twitter-bot.module.ts @@ -1,13 +1,19 @@ import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service'; import { Module } from '@nestjs/common'; @Module({ exports: [TwitterBotService], - imports: [BenchmarkModule, ConfigurationModule, SymbolModule], + imports: [ + BenchmarkModule, + ConfigurationModule, + DataProviderModule, + SymbolModule + ], providers: [TwitterBotService] }) export class TwitterBotModule {} diff --git a/apps/api/src/services/twitter-bot/twitter-bot.service.ts b/apps/api/src/services/twitter-bot/twitter-bot.service.ts index 366b016b6..2dbed82d2 100644 --- a/apps/api/src/services/twitter-bot/twitter-bot.service.ts +++ b/apps/api/src/services/twitter-bot/twitter-bot.service.ts @@ -1,10 +1,8 @@ import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { - ghostfolioFearAndGreedIndexDataSourceStocks, - ghostfolioFearAndGreedIndexSymbolStocks -} from '@ghostfolio/common/config'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ghostfolioFearAndGreedIndexSymbolStocks } from '@ghostfolio/common/config'; import { resolveFearAndGreedIndex, resolveMarketCondition @@ -24,6 +22,7 @@ export class TwitterBotService implements OnModuleInit { public constructor( private readonly benchmarkService: BenchmarkService, private readonly configurationService: ConfigurationService, + private readonly dataProviderService: DataProviderService, private readonly symbolService: SymbolService ) {} @@ -49,7 +48,8 @@ export class TwitterBotService implements OnModuleInit { try { const symbolItem = await this.symbolService.get({ dataGatheringItem: { - dataSource: ghostfolioFearAndGreedIndexDataSourceStocks, + dataSource: + this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(), symbol: ghostfolioFearAndGreedIndexSymbolStocks } }); @@ -59,9 +59,9 @@ export class TwitterBotService implements OnModuleInit { symbolItem.marketPrice ); - let status = `Current market mood is ${emoji} ${text.toLowerCase()} (${ + let status = `Current market mood is ${emoji} ${text.toLowerCase()} (${round( symbolItem.marketPrice - }/100)`; + )}/100)`; const benchmarkListing = await this.getBenchmarkListing(); diff --git a/apps/api/webpack.config.js b/apps/api/webpack.config.js index 2cc38b985..e59eee0bf 100644 --- a/apps/api/webpack.config.js +++ b/apps/api/webpack.config.js @@ -1,6 +1,49 @@ -const { composePlugins, withNx } = require('@nx/webpack'); +const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); +const path = require('path'); -module.exports = composePlugins(withNx(), (config, { options, context }) => { - // Customize webpack config here - return config; +// These options were migrated by @nx/webpack:convert-to-inferred from +// the project.json file and merged with the options in this file +const configValues = { + build: { + default: { + compiler: 'tsc', + deleteOutputPath: false, + main: './src/main.ts', + outputPath: 'dist/apps/api', + outputHashing: 'none', + sourceMap: true, + target: 'node', + tsConfig: './tsconfig.app.json' + }, + production: { + extractLicenses: true, + fileReplacements: [ + { + replace: path.resolve(__dirname, './src/environments/environment.ts'), + with: path.resolve( + __dirname, + './src/environments/environment.prod.ts' + ) + } + ], + generatePackageJson: true, + inspect: false, + optimization: true + } + } +}; + +// Determine the correct configValue to use based on the configuration +const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default'; + +const buildOptions = { + ...configValues.build.default, + ...configValues.build[configuration] +}; + +/** + * @type {import('webpack').WebpackOptionsNormalized} + */ +module.exports = async () => ({ + plugins: [new NxAppWebpackPlugin(buildOptions)] }); diff --git a/apps/client/eslint.config.cjs b/apps/client/eslint.config.cjs index 96ecefd50..bcbe2c1c1 100644 --- a/apps/client/eslint.config.cjs +++ b/apps/client/eslint.config.cjs @@ -47,7 +47,9 @@ module.exports = [ { files: ['**/*.ts', '**/*.tsx'], // Override or add rules here - rules: {} + rules: { + '@typescript-eslint/prefer-nullish-coalescing': 'error' + } }, { files: ['**/*.js', '**/*.jsx'], diff --git a/apps/client/jest.config.ts b/apps/client/jest.config.ts index 04378bdbd..26d772e11 100644 --- a/apps/client/jest.config.ts +++ b/apps/client/jest.config.ts @@ -1,4 +1,3 @@ -/* eslint-disable */ export default { displayName: 'client', diff --git a/apps/client/src/app/app.routes.ts b/apps/client/src/app/app.routes.ts index 9588cee68..2c0591b2c 100644 --- a/apps/client/src/app/app.routes.ts +++ b/apps/client/src/app/app.routes.ts @@ -128,8 +128,7 @@ export const routes: Routes = [ import('./pages/webauthn/webauthn-page.component').then( (c) => c.GfWebauthnPageComponent ), - path: internalRoutes.webauthn.path, - title: internalRoutes.webauthn.title + path: internalRoutes.webauthn.path }, { path: internalRoutes.zen.path, 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 ae0f8fda2..c64405cb6 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 @@ -4,10 +4,7 @@ import { DEFAULT_LOCALE, DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; -import { - canDeleteAssetProfile, - getDateFormatString -} from '@ghostfolio/common/helper'; +import { canDeleteAssetProfile } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, AssetProfileItem, @@ -153,7 +150,6 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { ]; protected readonly canDeleteAssetProfile = canDeleteAssetProfile; protected dataSource = new MatTableDataSource(); - protected defaultDateFormat: string; protected readonly displayedColumns: string[] = []; protected readonly filters$ = new Subject(); protected isLoading = true; @@ -236,10 +232,6 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { .subscribe((state) => { if (state?.user) { this.user = state.user; - - this.defaultDateFormat = getDateFormatString( - this.user.settings.locale - ); } }); diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.html b/apps/client/src/app/components/admin-market-data/admin-market-data.html index 83703b548..ae7adfdd8 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 @@ -147,7 +147,13 @@ First Activity - {{ (element.date | date: defaultDateFormat) ?? '' }} + @if (element.date) { + + } 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 967a289ef..c34e8eb78 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 @@ -550,7 +550,7 @@ export class GfAssetProfileDialogComponent implements OnInit { ) as Record, locale: this.assetProfileForm.controls.scraperConfiguration.controls.locale - ?.value || undefined, + ?.value ?? undefined, mode: this.assetProfileForm.controls.scraperConfiguration.controls.mode ?.value ?? undefined, @@ -599,7 +599,7 @@ export class GfAssetProfileDialogComponent implements OnInit { assetClass: this.assetProfileForm.controls.assetClass.value ?? undefined, assetSubClass: this.assetProfileForm.controls.assetSubClass.value ?? undefined, - comment: this.assetProfileForm.controls.comment.value || undefined, + comment: this.assetProfileForm.controls.comment.value ?? undefined, currency: this.assetProfileForm.controls.currency.value ?? undefined, dataGatheringFrequency: this.assetProfileForm.controls.dataGatheringFrequency.value ?? @@ -607,8 +607,8 @@ export class GfAssetProfileDialogComponent implements OnInit { isActive: isBoolean(this.assetProfileForm.controls.isActive.value) ? this.assetProfileForm.controls.isActive.value : undefined, - name: this.assetProfileForm.controls.name.value || undefined, - url: this.assetProfileForm.controls.url.value || undefined + name: this.assetProfileForm.controls.name.value ?? undefined, + url: this.assetProfileForm.controls.url.value ?? undefined }; try { @@ -743,7 +743,7 @@ export class GfAssetProfileDialogComponent implements OnInit { ) as Record, locale: this.assetProfileForm.controls.scraperConfiguration.controls.locale - ?.value || undefined, + ?.value ?? undefined, mode: this.assetProfileForm.controls.scraperConfiguration.controls .mode?.value, selector: diff --git a/apps/client/src/app/components/admin-overview/admin-overview.component.ts b/apps/client/src/app/components/admin-overview/admin-overview.component.ts index b2c9b11a3..62bc78df1 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.component.ts +++ b/apps/client/src/app/components/admin-overview/admin-overview.component.ts @@ -63,6 +63,7 @@ import { trashOutline } from 'ionicons/icons'; import ms, { StringValue } from 'ms'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -79,6 +80,7 @@ import ms, { StringValue } from 'ms'; MatSnackBarModule, MatSlideToggleModule, MatTableModule, + NgxSkeletonLoaderModule, ReactiveFormsModule, RouterModule ], @@ -90,13 +92,19 @@ export class GfAdminOverviewComponent implements OnInit { protected activitiesCount: number; protected couponDuration: StringValue = '14 days'; protected readonly couponsDataSource = new MatTableDataSource(); - protected readonly couponsDisplayedColumns = ['code', 'duration', 'actions']; + protected readonly couponsDisplayedColumns = [ + 'code', + 'duration', + 'createdAt', + 'actions' + ]; protected hasPermissionForSubscription: boolean; protected hasPermissionForSystemMessage: boolean; protected hasPermissionToSyncDemoUserAccount: boolean; protected hasPermissionToToggleReadOnlyMode: boolean; protected readonly info: InfoItem; protected isDataGatheringEnabled: boolean; + protected isLoading = false; protected readonly permissions = permissions; protected systemMessage: SystemMessage; protected userCount: number; @@ -201,6 +209,7 @@ export class GfAdminOverviewComponent implements OnInit { protected onAddCoupon() { const newCoupon: Coupon = { code: `${ghostfolioPrefix}${this.generateCouponCode(14)}`, + createdAt: new Date().toISOString(), duration: this.couponDuration }; @@ -314,6 +323,8 @@ export class GfAdminOverviewComponent implements OnInit { } private fetchAdminData() { + this.isLoading = true; + this.adminService .fetchAdminData() .pipe(takeUntilDestroyed(this.destroyRef)) @@ -330,6 +341,8 @@ export class GfAdminOverviewComponent implements OnInit { this.userCount = userCount; this.version = version; + this.isLoading = false; + this.changeDetectorRef.markForCheck(); }); } diff --git a/apps/client/src/app/components/admin-overview/admin-overview.html b/apps/client/src/app/components/admin-overview/admin-overview.html index bccedd251..7e05600a6 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.html +++ b/apps/client/src/app/components/admin-overview/admin-overview.html @@ -180,6 +180,28 @@ + + + Creation + + + @if (element.createdAt) { + + } @else { + - + } + + + + + @if (isLoading) { + + } diff --git a/apps/client/src/app/components/admin-settings/admin-settings.component.html b/apps/client/src/app/components/admin-settings/admin-settings.component.html index 76af96c4e..643737c4d 100644 --- a/apps/client/src/app/components/admin-settings/admin-settings.component.html +++ b/apps/client/src/app/components/admin-settings/admin-settings.component.html @@ -21,7 +21,7 @@ Get Access @@ -76,7 +76,7 @@ newpopular } diff --git a/apps/client/src/app/components/admin-users/admin-users.component.ts b/apps/client/src/app/components/admin-users/admin-users.component.ts index 5460745f5..305c48dcf 100644 --- a/apps/client/src/app/components/admin-users/admin-users.component.ts +++ b/apps/client/src/app/components/admin-users/admin-users.component.ts @@ -8,6 +8,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_LOCALE, DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { + canDeleteUser, getCountryName, getDateFnsLocale, getDateFormatString, @@ -27,6 +28,7 @@ import { GfValueComponent } from '@ghostfolio/ui/value'; import { CommonModule } from '@angular/common'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, @@ -67,6 +69,7 @@ import { interval } from 'rxjs'; import { switchMap, tap } from 'rxjs/operators'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, GfPremiumIndicatorComponent, @@ -89,6 +92,7 @@ export class GfAdminUsersComponent implements OnInit { >(); protected defaultDateFormat: string; protected displayedColumns: string[] = []; + protected readonly canDeleteUser = canDeleteUser; protected readonly getCountryName = getCountryName; protected readonly getEmojiFlag = getEmojiFlag; protected hasPermissionForSubscription: boolean; @@ -165,6 +169,8 @@ export class GfAdminUsersComponent implements OnInit { this.user.permissions, permissions.impersonateAllUsers ); + + this.changeDetectorRef.markForCheck(); } }), switchMap(() => this.route.paramMap) @@ -196,6 +202,8 @@ export class GfAdminUsersComponent implements OnInit { pageIndex: this.paginator().pageIndex, showLoading: false }); + + this.changeDetectorRef.markForCheck(); }); } diff --git a/apps/client/src/app/components/admin-users/admin-users.html b/apps/client/src/app/components/admin-users/admin-users.html index 668f39557..7e7e5edeb 100644 --- a/apps/client/src/app/components/admin-users/admin-users.html +++ b/apps/client/src/app/components/admin-users/admin-users.html @@ -241,7 +241,12 @@
- @if (hasPermissionToDeleteOwnUser) { + @if ( + hasPermissionToDeleteOwnUser || hasPermissionToRequestOwnUserDeletion + ) {
-
-
-
Danger Zone
-
- +
Danger Zone
+
+ @if (hasPermissionToDeleteOwnUser) { + - Security Token - - + + - - -
+
+ For security reasons, please delete all activities and accounts + first before your Ghostfolio account can be closed. +
+ } @else if (hasPermissionToRequestOwnUserDeletion) { + Close Account + }
- +
} diff --git a/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts b/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts index 73e09f612..9e341037a 100644 --- a/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts +++ b/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts @@ -1,4 +1,8 @@ -import { getCountryName, getSum } from '@ghostfolio/common/helper'; +import { + canDeleteUser, + getCountryName, + getSum +} from '@ghostfolio/common/helper'; import { AdminUserResponse } from '@ghostfolio/common/interfaces'; import { AdminService, DataService } from '@ghostfolio/ui/services'; import { GfValueComponent } from '@ghostfolio/ui/value'; @@ -9,7 +13,7 @@ import { Component, CUSTOM_ELEMENTS_SCHEMA, DestroyRef, - Inject, + inject, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -49,28 +53,31 @@ import { templateUrl: './user-detail-dialog.html' }) export class GfUserDetailDialogComponent implements OnInit { - public baseCurrency: string; - public readonly getCountryName = getCountryName; - public subscriptionsDataSource = new MatTableDataSource(); - public subscriptionsDisplayedColumns = [ + protected readonly baseCurrency: string; + protected readonly canDeleteUser = canDeleteUser; + protected readonly getCountryName = getCountryName; + protected readonly subscriptionsDataSource = + new MatTableDataSource(); + protected readonly subscriptionsDisplayedColumns = [ 'createdAt', 'type', 'price', 'expiresAt' ]; - public user: AdminUserResponse; - - public constructor( - private adminService: AdminService, - private changeDetectorRef: ChangeDetectorRef, - @Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams, - private dataService: DataService, - private destroyRef: DestroyRef, - public dialogRef: MatDialogRef< - GfUserDetailDialogComponent, - UserDetailDialogResult - > - ) { + protected user: AdminUserResponse; + + protected readonly data = inject(MAT_DIALOG_DATA); + + private readonly adminService = inject(AdminService); + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly dialogRef = + inject>( + MatDialogRef + ); + + public constructor() { this.baseCurrency = this.dataService.fetchInfo().baseCurrency; addIcons({ @@ -98,26 +105,26 @@ export class GfUserDetailDialogComponent implements OnInit { }); } - public deleteUser() { + protected deleteUser() { this.dialogRef.close({ action: 'delete', userId: this.data.userId }); } - public getSum() { + protected getSum() { return getSum( this.subscriptionsDataSource.data .filter(({ price }) => { return price !== null; }) .map(({ price }) => { - return new Big(price); + return new Big(price ?? 0); }) ).toNumber(); } - public getType({ createdAt, expiresAt, price }: Subscription) { + protected getType({ createdAt, expiresAt, price }: Subscription) { if (price) { return $localize`Paid`; } @@ -127,7 +134,7 @@ export class GfUserDetailDialogComponent implements OnInit { : $localize`Coupon`; } - public onClose() { + protected onClose() { this.dialogRef.close(); } } diff --git a/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html b/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html index f549b476f..e2cdb66a5 100644 --- a/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html +++ b/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -16,7 +16,12 @@ + @if (copyValue) { + + } diff --git a/libs/ui/src/lib/notifications/alert-dialog/interfaces/interfaces.ts b/libs/ui/src/lib/notifications/alert-dialog/interfaces/interfaces.ts index fa330c463..7b2c16e52 100644 --- a/libs/ui/src/lib/notifications/alert-dialog/interfaces/interfaces.ts +++ b/libs/ui/src/lib/notifications/alert-dialog/interfaces/interfaces.ts @@ -1,4 +1,5 @@ export interface AlertDialogParams { + copyValue?: string; discardLabel: string; message?: string; title: string; diff --git a/libs/ui/src/lib/notifications/interfaces/interfaces.ts b/libs/ui/src/lib/notifications/interfaces/interfaces.ts index 071597691..dcea7d53c 100644 --- a/libs/ui/src/lib/notifications/interfaces/interfaces.ts +++ b/libs/ui/src/lib/notifications/interfaces/interfaces.ts @@ -1,6 +1,7 @@ import { ConfirmationDialogType } from '@ghostfolio/common/enums'; export interface AlertParams { + copyValue?: string; discardFn?: () => void; discardLabel?: string; message?: string; diff --git a/libs/ui/src/lib/notifications/notification.service.ts b/libs/ui/src/lib/notifications/notification.service.ts index b9a2562f7..389f52180 100644 --- a/libs/ui/src/lib/notifications/notification.service.ts +++ b/libs/ui/src/lib/notifications/notification.service.ts @@ -31,6 +31,7 @@ export class NotificationService { }); dialog.componentInstance.initialize({ + copyValue: aParams.copyValue, discardLabel: aParams.discardLabel, message: aParams.message, title: aParams.title diff --git a/libs/ui/src/lib/page-tabs/page-tabs.component.html b/libs/ui/src/lib/page-tabs/page-tabs.component.html index 2898bcc79..34a5a3e26 100644 --- a/libs/ui/src/lib/page-tabs/page-tabs.component.html +++ b/libs/ui/src/lib/page-tabs/page-tabs.component.html @@ -45,9 +45,15 @@ - -
+ + + +
diff --git a/libs/ui/src/lib/page-tabs/page-tabs.component.scss b/libs/ui/src/lib/page-tabs/page-tabs.component.scss index ffb3ff33c..246a6147d 100644 --- a/libs/ui/src/lib/page-tabs/page-tabs.component.scss +++ b/libs/ui/src/lib/page-tabs/page-tabs.component.scss @@ -56,6 +56,11 @@ padding: 2rem 0; width: 14rem; + .mat-mdc-tab-list, + .mat-mdc-tab-links { + width: 100%; + } + .mat-mdc-tab-links { flex-direction: column; diff --git a/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html index 456cd9940..12867662b 100644 --- a/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html +++ b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html @@ -25,7 +25,10 @@ } {{ lookupItem.symbol | gfSymbol }} · {{ lookupItem.currency }} + >{{ lookupItem.symbol | gfSymbol }} + @if (lookupItem.currency) { + · {{ lookupItem.currency }} + } @if (lookupItem.assetSubClass) { · {{ lookupItem.assetSubClassString }} } diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts index fd4e63f29..eb0265af3 100644 --- a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts @@ -15,18 +15,16 @@ import { ChangeDetectionStrategy, Component, ElementRef, - EventEmitter, - Input, + input, OnChanges, OnDestroy, - Output, - ViewChild + output, + viewChild } from '@angular/core'; import { DataSource } from '@prisma/client'; import { Big } from 'big.js'; import type { ChartData, TooltipOptions } from 'chart.js'; -import { LinearScale } from 'chart.js'; -import { Chart, Tooltip } from 'chart.js'; +import { Chart, LinearScale, Tooltip } from 'chart.js'; import { TreemapController, TreemapElement } from 'chartjs-chart-treemap'; import { isUUID } from 'class-validator'; import { differenceInDays, max } from 'date-fns'; @@ -52,33 +50,31 @@ const { gray, green, red } = OpenColor; export class GfTreemapChartComponent implements AfterViewInit, OnChanges, OnDestroy { - @Input() baseCurrency: string; - @Input() colorScheme: ColorScheme; - @Input() cursor: string; - @Input() dateRange: DateRange; - @Input() holdings: PortfolioPosition[]; - @Input() locale = getLocale(); + public readonly baseCurrency = input.required(); + public readonly colorScheme = input.required(); + public readonly cursor = input.required(); + public readonly dateRange = input.required(); + public readonly holdings = input(); + public readonly locale = input(getLocale()); - @Output() treemapChartClicked = new EventEmitter(); + public readonly treemapChartClicked = output(); - @ViewChild('chartCanvas') chartCanvas: ElementRef; + protected isLoading = true; - public chart: Chart<'treemap'>; - public isLoading = true; + private chart: Chart<'treemap'>; + private readonly chartCanvas = + viewChild.required>('chartCanvas'); public constructor() { Chart.register(LinearScale, Tooltip, TreemapController, TreemapElement); } + public ngAfterViewInit() { - if (this.holdings) { - this.initialize(); - } + this.initialize(); } public ngOnChanges() { - if (this.holdings) { - this.initialize(); - } + this.initialize(); } public ngOnDestroy() { @@ -159,13 +155,19 @@ export class GfTreemapChartComponent } private initialize() { + const holdings = this.holdings(); + + if (!holdings) { + return; + } + this.isLoading = true; const { endDate, startDate } = getIntervalFromDateRange({ - dateRange: this.dateRange + dateRange: this.dateRange() }); - const netPerformancePercentsWithCurrencyEffect = this.holdings.map( + const netPerformancePercentsWithCurrencyEffect = holdings.map( ({ dateOfFirstActivity, netPerformancePercentWithCurrencyEffect }) => { return getAnnualizedPerformancePercent({ daysInMarket: differenceInDays( @@ -301,12 +303,12 @@ export class GfTreemapChartComponent }, spacing: 1, // @ts-expect-error: should be PortfolioPosition[] - tree: this.holdings + tree: this.holdings() } ] }; - if (this.chartCanvas) { + if (this.chartCanvas()) { if (this.chart) { this.chart.data = data; this.chart.options.plugins ??= {}; @@ -315,7 +317,7 @@ export class GfTreemapChartComponent this.chart.update(); } else { - this.chart = new Chart<'treemap'>(this.chartCanvas.nativeElement, { + this.chart = new Chart<'treemap'>(this.chartCanvas().nativeElement, { data, options: { animation: false, @@ -339,9 +341,9 @@ export class GfTreemapChartComponent } catch {} }, onHover: (event, chartElement) => { - if (this.cursor) { + if (this.cursor()) { (event.native?.target as HTMLElement).style.cursor = - chartElement[0] ? this.cursor : 'default'; + chartElement[0] ? this.cursor() : 'default'; } }, plugins: { @@ -359,9 +361,9 @@ export class GfTreemapChartComponent private getTooltipPluginConfiguration(): Partial> { return { ...getTooltipOptions({ - colorScheme: this.colorScheme, - currency: this.baseCurrency, - locale: this.locale + colorScheme: this.colorScheme(), + currency: this.baseCurrency(), + locale: this.locale() }), // @ts-expect-error: no need to set all attributes in callbacks callbacks: { @@ -381,19 +383,19 @@ export class GfTreemapChartComponent return [ `${name ?? symbol} (${allocationInPercentage})`, - `${value?.toLocaleString(this.locale, { + `${value?.toLocaleString(this.locale(), { maximumFractionDigits: 2, minimumFractionDigits: 2 - })} ${this.baseCurrency}`, + })} ${this.baseCurrency()}`, '', $localize`Change` + ' (' + $localize`Performance` + ')', `${sign}${raw._data.netPerformanceWithCurrencyEffect.toLocaleString( - this.locale, + this.locale(), { maximumFractionDigits: 2, minimumFractionDigits: 2 } - )} ${this.baseCurrency} (${netPerformanceInPercentageWithSign})` + )} ${this.baseCurrency()} (${netPerformanceInPercentageWithSign})` ]; } else { return [ diff --git a/nx.json b/nx.json index 6b90f437c..1deb022df 100644 --- a/nx.json +++ b/nx.json @@ -67,6 +67,16 @@ } }, "$schema": "./node_modules/nx/schemas/nx-schema.json", + "plugins": [ + { + "plugin": "@nx/webpack/plugin", + "options": { + "buildTargetName": "build", + "serveTargetName": "serve", + "previewTargetName": "preview" + } + } + ], "targetDefaults": { "build": { "dependsOn": ["^build"], diff --git a/package-lock.json b/package-lock.json index aec72acd4..00128128e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.21.0", + "version": "3.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.21.0", + "version": "3.26.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -29,6 +29,7 @@ "@internationalized/number": "3.6.7", "@ionic/angular": "8.8.12", "@keyv/redis": "5.1.6", + "@nest-lab/throttler-storage-redis": "1.2.0", "@nestjs/bull": "11.0.4", "@nestjs/cache-manager": "3.1.3", "@nestjs/common": "11.1.27", @@ -40,6 +41,7 @@ "@nestjs/platform-express": "11.1.27", "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", + "@nestjs/throttler": "6.5.0", "@openrouter/ai-sdk-provider": "2.9.1", "@prisma/adapter-pg": "7.8.0", "@prisma/client": "7.8.0", @@ -61,7 +63,7 @@ "color": "5.0.3", "cookie-parser": "1.4.7", "countries-and-timezones": "3.9.0", - "countries-list": "3.3.0", + "countries-list": "3.4.0", "countup.js": "2.10.0", "date-fns": "4.4.0", "dotenv": "17.2.3", @@ -70,7 +72,7 @@ "fast-redact": "3.5.0", "fuse.js": "7.3.0", "google-spreadsheet": "3.2.0", - "helmet": "7.0.0", + "helmet": "8.2.0", "http-status-codes": "2.3.0", "ionicons": "8.0.13", "jsonpath": "1.3.0", @@ -115,16 +117,16 @@ "@eslint/js": "9.35.0", "@nestjs/schematics": "11.1.0", "@nestjs/testing": "11.1.27", - "@nx/angular": "23.0.1", - "@nx/eslint-plugin": "23.0.1", - "@nx/jest": "23.0.1", - "@nx/js": "23.0.1", - "@nx/module-federation": "23.0.1", - "@nx/nest": "23.0.1", - "@nx/node": "23.0.1", - "@nx/storybook": "23.0.1", - "@nx/web": "23.0.1", - "@nx/workspace": "23.0.1", + "@nx/angular": "23.0.2", + "@nx/eslint-plugin": "23.0.2", + "@nx/jest": "23.0.2", + "@nx/js": "23.0.2", + "@nx/module-federation": "23.0.2", + "@nx/nest": "23.0.2", + "@nx/node": "23.0.2", + "@nx/storybook": "23.0.2", + "@nx/web": "23.0.2", + "@nx/workspace": "23.0.2", "@prisma/config": "7.8.0", "@schematics/angular": "21.2.6", "@storybook/addon-docs": "10.1.10", @@ -152,7 +154,7 @@ "jest": "30.3.0", "jest-environment-jsdom": "30.2.0", "jest-preset-angular": "16.0.0", - "nx": "23.0.1", + "nx": "23.0.2", "prettier": "3.8.4", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "7.8.0", @@ -164,7 +166,8 @@ "ts-jest": "29.4.0", "ts-node": "10.9.2", "tslib": "2.8.1", - "typescript": "5.9.2" + "typescript": "5.9.2", + "webpack-cli": "7.1.0" }, "engines": { "node": ">=22.18.0" @@ -6955,6 +6958,39 @@ "tslib": "^2.4.0" } }, + "node_modules/@nest-lab/throttler-storage-redis": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@nest-lab/throttler-storage-redis/-/throttler-storage-redis-1.2.0.tgz", + "integrity": "sha512-tMkUyo68NCKTR+zILk+EC35SMYBtDPZY2mCj7ZaCietWGVTnuP4zwq9ERYfvU6kJv6h8teNZrC6MJCmY6/dljw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/throttler": ">=6.0.0", + "ioredis": ">=5.0.0", + "reflect-metadata": "^0.2.1" + }, + "peerDependenciesMeta": { + "@nestjs/common": { + "optional": false + }, + "@nestjs/core": { + "optional": false + }, + "@nestjs/throttler": { + "optional": false + }, + "ioredis": { + "optional": false + }, + "reflect-metadata": { + "optional": false + } + } + }, "node_modules/@nestjs/bull": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-11.0.4.tgz", @@ -7440,6 +7476,17 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", + "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@ngtools/webpack": { "version": "21.2.6", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-21.2.6.tgz", @@ -7788,19 +7835,19 @@ } }, "node_modules/@nx/angular": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/angular/-/angular-23.0.1.tgz", - "integrity": "sha512-/SJEhwGrAOO35fQjRMwLi3RBqzSaPNViCGqiQldeTokzJqtr9h4QVQxVH+mD+zLdwRI4teoC9taekVrsjXcMoA==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/angular/-/angular-23.0.2.tgz", + "integrity": "sha512-2xeTEyMgeUMLN9YFSnlQj38JxGjkOUmkSKvRCcrTJNZFuZpxMxwW/oXkPJ3EBBjryAMWCA/qyAh3Te0dokw0/g==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "23.0.1", - "@nx/eslint": "23.0.1", - "@nx/js": "23.0.1", - "@nx/module-federation": "23.0.1", - "@nx/rspack": "23.0.1", - "@nx/web": "23.0.1", - "@nx/webpack": "23.0.1", + "@nx/devkit": "23.0.2", + "@nx/eslint": "23.0.2", + "@nx/js": "23.0.2", + "@nx/module-federation": "23.0.2", + "@nx/rspack": "23.0.2", + "@nx/web": "23.0.2", + "@nx/webpack": "23.0.2", "@phenomnomnominal/tsquery": "~6.2.0", "@typescript-eslint/type-utils": "^8.0.0", "enquirer": "~2.3.6", @@ -7848,15 +7895,15 @@ } }, "node_modules/@nx/cypress": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/cypress/-/cypress-23.0.1.tgz", - "integrity": "sha512-is3oXDoTd2K4C7b2qq3tzKVctBDOdKsfEHIztCwV7ZO3jd9cWl83fLQUL6WI3WVoafo8ALxARRSe1ri4GkzyFg==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/cypress/-/cypress-23.0.2.tgz", + "integrity": "sha512-+6RbjMA4KHcBNFIbqOF6oKDWwGsqsJaf2uhH69vDM9rfjJnL7j5CKFzLGSO746Nk4BcLqI0ARR6R8T+w0Ees5g==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "23.0.1", - "@nx/eslint": "23.0.1", - "@nx/js": "23.0.1", + "@nx/devkit": "23.0.2", + "@nx/eslint": "23.0.2", + "@nx/js": "23.0.2", "@phenomnomnominal/tsquery": "~6.2.0", "detect-port": "^2.1.0", "semver": "^7.6.3", @@ -7873,9 +7920,9 @@ } }, "node_modules/@nx/devkit": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-23.0.1.tgz", - "integrity": "sha512-A/chuNS1RZwdbRe/Nf+w0qtPEFHLcZNPzo8Abw5mBxyXmy9yvHZpuZuqDbt/lASFU+TEb74xExL1AnKWwqpOIg==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-23.0.2.tgz", + "integrity": "sha512-svkaufC+nucp3stMucqVN0sV4nz9fEgAd33WB9u2fnFWYx/4F0VJcV4ZLyiG3hHrgs2/Gz5K0OnygPqjCsSqMw==", "dev": true, "license": "MIT", "dependencies": { @@ -7885,6 +7932,7 @@ "minimatch": "10.2.5", "semver": "^7.6.3", "tslib": "^2.3.0", + "yaml": "^2.8.3", "yargs-parser": "21.1.1" }, "peerDependencies": { @@ -7902,9 +7950,9 @@ } }, "node_modules/@nx/devkit/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -7944,32 +7992,32 @@ } }, "node_modules/@nx/docker": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/docker/-/docker-23.0.1.tgz", - "integrity": "sha512-H2/wGZa10X2KhM+BzUkZLCI9wDG1kGqDGco1tDWZczKwk9ViOcwV23ljjt5RtIQIson6B7L56pO2F/8WKJ+1CQ==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/docker/-/docker-23.0.2.tgz", + "integrity": "sha512-3eccPjL3kwAndVq7t0V1XQDEkEGz7AhnSzpPwr9IMHgVKqgCIJBbNF1Xi5c3AbPlnjy0GHfnGOeb1/jXf2RuNA==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "23.0.1", + "@nx/devkit": "23.0.2", "enquirer": "~2.3.6", "tslib": "^2.3.0" } }, "node_modules/@nx/eslint": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/eslint/-/eslint-23.0.1.tgz", - "integrity": "sha512-/P+iXDUsXHeqU7NMDviE/tjJkaVWssc7TLcCoJ6TRy/p+U7HaigLx57VAogBIznldHiyL7KbM7Y0fzqo/hPofw==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/eslint/-/eslint-23.0.2.tgz", + "integrity": "sha512-nghKUrUWPj6mZajGkPIC1LxSO+GYWifZjtFnEQU7n5SDffS+QA3mUX5Tr16CZL5tcSLTwpk2/fx3GM/Ji/3DOA==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "23.0.1", - "@nx/js": "23.0.1", + "@nx/devkit": "23.0.2", + "@nx/js": "23.0.2", "semver": "^7.6.3", "tslib": "^2.3.0", "typescript": "~5.9.2" }, "peerDependencies": { - "@nx/jest": "23.0.1", + "@nx/jest": "23.0.2", "@zkochan/js-yaml": "0.0.7", "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" }, @@ -7983,14 +8031,14 @@ } }, "node_modules/@nx/eslint-plugin": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/eslint-plugin/-/eslint-plugin-23.0.1.tgz", - "integrity": "sha512-fZ4fU4fYxvmHYUtiPuh9vg6KItjqaz4c8SHeqOCRvDsbm7NwQidWsGiruk853No5KnmCHfj/En87v/qGOguoGw==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/eslint-plugin/-/eslint-plugin-23.0.2.tgz", + "integrity": "sha512-ckkifbXqmYrA7OgpfxqeqG5feYndN28hfvbMA0yD0nWQFbx24lC6CkzsJJjuIN3RsQa8yrhKIoObYtiMRxdNLQ==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "23.0.1", - "@nx/js": "23.0.1", + "@nx/devkit": "23.0.2", + "@nx/js": "23.0.2", "@phenomnomnominal/tsquery": "~6.2.0", "@typescript-eslint/type-utils": "^8.0.0", "@typescript-eslint/utils": "^8.0.0", @@ -8028,16 +8076,16 @@ } }, "node_modules/@nx/jest": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/jest/-/jest-23.0.1.tgz", - "integrity": "sha512-F5lhjttIExH8hEJ09cqv3Ac1o8GyGTHpD1OLLWnNAYaN/CPpbI16Ix8VLY/fuwJYf6wcm1/vBL3knbv6LABODA==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/jest/-/jest-23.0.2.tgz", + "integrity": "sha512-NGMUJMzEccj0y/yk+ct+OIFNB1RrdC9C6rFBZ3VFJgxbJlSbxCqQXcO+/668CtGlLVPAZe0baASrCVcvKNUGCQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/reporters": "^30.0.2", "@jest/test-result": "^30.0.2", - "@nx/devkit": "23.0.1", - "@nx/js": "23.0.1", + "@nx/devkit": "23.0.2", + "@nx/js": "23.0.2", "@phenomnomnominal/tsquery": "~6.2.0", "identity-obj-proxy": "3.0.0", "jest-config": "^30.0.2", @@ -8103,9 +8151,9 @@ } }, "node_modules/@nx/js": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/js/-/js-23.0.1.tgz", - "integrity": "sha512-H8jw1gk7hA8PCXBFC9ocTBpzuXOTvVQ1gA+OlEBMyKqmUaOLNm7yuoOYozwvLsLlCVY27onohSIS8xIdAR/Zow==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/js/-/js-23.0.2.tgz", + "integrity": "sha512-ixtH09vcr6qFaYloCC8JOMLazxTocws2FnHWNbLOpYaO0Nyh4szh8Ttclky7eiI21XYF9z07+0O7/GFlh8hCBA==", "dev": true, "license": "MIT", "dependencies": { @@ -8116,8 +8164,8 @@ "@babel/preset-env": "^7.23.2", "@babel/preset-typescript": "^7.22.5", "@babel/runtime": "^7.22.6", - "@nx/devkit": "23.0.1", - "@nx/workspace": "23.0.1", + "@nx/devkit": "23.0.2", + "@nx/workspace": "23.0.2", "@zkochan/js-yaml": "0.0.7", "babel-plugin-const-enum": "^1.0.1", "babel-plugin-macros": "^3.1.0", @@ -8181,15 +8229,15 @@ } }, "node_modules/@nx/module-federation": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/module-federation/-/module-federation-23.0.1.tgz", - "integrity": "sha512-scYruTqvCegeTwAHiBL9MNRKb9RaU+XJ3+wxcmZekyNnvcQjmEPPHndE013wHUuwohyEpVFYrVCulbVb+h2Tvg==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/module-federation/-/module-federation-23.0.2.tgz", + "integrity": "sha512-CkSmu0FCb85YU2mNmi96a5csZzXoas9NJZSj5WEvwOWJpfasImpqMrD06ozG27vv6b/TbIPC1MXUfQw5eyxP5g==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "23.0.1", - "@nx/js": "23.0.1", - "@nx/web": "23.0.1", + "@nx/devkit": "23.0.2", + "@nx/js": "23.0.2", + "@nx/web": "23.0.2", "@rspack/core": "1.6.8", "express": "^4.21.2", "http-proxy-middleware": "^3.0.5", @@ -8267,9 +8315,9 @@ } }, "node_modules/@nx/module-federation/node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "dev": true, "license": "MIT", "dependencies": { @@ -8549,17 +8597,17 @@ } }, "node_modules/@nx/nest": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/nest/-/nest-23.0.1.tgz", - "integrity": "sha512-/iSE/XzbOl1C1zDuQgeoKBmQAIJYwAj1PJNrnJKf2fS8Mf1s2wGdWnegNsp7iNmAWvZw3zXxhozFiNzJv/HUzA==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/nest/-/nest-23.0.2.tgz", + "integrity": "sha512-HwawKHpIImnwNfLyULeE+AywB/BIVm9m+ZuoulYP8Gwjvg7Lrq28stnf98sTKEuyGjDDWvTWH4RNHYs0nRXEiA==", "dev": true, "license": "MIT", "dependencies": { "@nestjs/schematics": "^11.0.0", - "@nx/devkit": "23.0.1", - "@nx/eslint": "23.0.1", - "@nx/js": "23.0.1", - "@nx/node": "23.0.1", + "@nx/devkit": "23.0.2", + "@nx/eslint": "23.0.2", + "@nx/js": "23.0.2", + "@nx/node": "23.0.2", "semver": "^7.6.3", "tslib": "^2.3.0" }, @@ -8585,17 +8633,17 @@ } }, "node_modules/@nx/node": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/node/-/node-23.0.1.tgz", - "integrity": "sha512-s13ja7MITncR4M/uTGM786Jqco8lJs/9Kvo7jrKFg0Ct8d5XHbu+JFpulmCGPatsBhFA0E/JHSSfR+D9E7MWxA==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/node/-/node-23.0.2.tgz", + "integrity": "sha512-WugI9xFXFs0GEKaelYDl+kpbPgwtvleSI1gnCxsA8MukjqZlm5cSTB4/+fOQSFaWhTesR0BZW5SasRQBJTB65A==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "23.0.1", - "@nx/docker": "23.0.1", - "@nx/eslint": "23.0.1", - "@nx/jest": "23.0.1", - "@nx/js": "23.0.1", + "@nx/devkit": "23.0.2", + "@nx/docker": "23.0.2", + "@nx/eslint": "23.0.2", + "@nx/jest": "23.0.2", + "@nx/js": "23.0.2", "kill-port": "^1.6.1", "semver": "^7.6.3", "tcp-port-used": "^1.0.2", @@ -8619,9 +8667,9 @@ } }, "node_modules/@nx/nx-darwin-arm64": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-23.0.1.tgz", - "integrity": "sha512-gQJvgPnbI91DBe23Th2CqD9R/S54cPS3C1f0DhyQ8YEf9rR7EEc+sVGjhgVxlhfOk2W7I1Gy6EkXwpN4aDoW4w==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-23.0.2.tgz", + "integrity": "sha512-9sqhZMVFpF+qM7hq6y2xA4gVK+6RdxRioAwHxorhOZRSXdW7Y7NESs5fm8vOmdddlG07QB7sMefOKLrqCV3zGg==", "cpu": [ "arm64" ], @@ -8633,9 +8681,9 @@ ] }, "node_modules/@nx/nx-darwin-x64": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-23.0.1.tgz", - "integrity": "sha512-e/lvzHKN6gpuD7MqEtfH1fOfnR75E55ytYNt8jaRxKI6EvpCq+Q3MunDuh9GQYAkqDrUqE7AhHrHc+eKATVEHw==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-23.0.2.tgz", + "integrity": "sha512-p6L3AvRhRRaR8Bl3jr76/9H04RdWUQbSgB7agK7GB7vqaLI8RifP2lqeaXcAngzjDAjw2EAf0TjOBP+T67hhcg==", "cpu": [ "x64" ], @@ -8647,9 +8695,9 @@ ] }, "node_modules/@nx/nx-freebsd-x64": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-23.0.1.tgz", - "integrity": "sha512-f582OhSYN9qHpA9Ox9qnr3kZSZ7gQHs7crmBUutmbXmZQB2TDS/TlhvYSNnxudpwHR/tuWGi2IOQqa7zGOZj1Q==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-23.0.2.tgz", + "integrity": "sha512-/py4I8Rp2UURses9H/+SQmgPVnHVSJgPimJLhXIfsRavKGu4RS7Ddu1OyNqSkCT3Otic6ImMTtkufURW22KiEQ==", "cpu": [ "x64" ], @@ -8661,9 +8709,9 @@ ] }, "node_modules/@nx/nx-linux-arm-gnueabihf": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-23.0.1.tgz", - "integrity": "sha512-VjhqPc6E7aiI0e+lowrkVbdyulsmP9fgMdcX1mCzXCEu/XZDcUbZ5qveR964cMhvm5qKn0ILJtJOUqZgmOT3Xg==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-23.0.2.tgz", + "integrity": "sha512-xv2IzeiWJFWi4WjK0ocMkP+ze1lDeoPVCg0xOTqVs40gM66V1wVw3EK077gTqU4m0Bq1wUxe6/I8WaIGlkLgug==", "cpu": [ "arm" ], @@ -8675,9 +8723,9 @@ ] }, "node_modules/@nx/nx-linux-arm64-gnu": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-23.0.1.tgz", - "integrity": "sha512-zX2JdHQejZWB3DRgNsh77qOVYaSSjSLuBP2qIqc7EWVlCUnR7Aj3e65PTIps4LxMMmUp4twZA2ezS0rtyK2A4w==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-23.0.2.tgz", + "integrity": "sha512-ckA6hTXST+agxt/HzPGqMss9qFCZhO9b07o8usygb7QFBYQRXFgcYzhTblq4yiTL5ibJmXAGGh98011fLA2MVw==", "cpu": [ "arm64" ], @@ -8689,9 +8737,9 @@ ] }, "node_modules/@nx/nx-linux-arm64-musl": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-23.0.1.tgz", - "integrity": "sha512-9lyhxRNBgNYwHt6paq0OLzoKNoEGF5LnNW2YYrgFY8Cjtsg/Q4pcfZ1vB5o9FX9OmUgUQs3t2d4tU8YDukRUWg==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-23.0.2.tgz", + "integrity": "sha512-PAxBxy7m//cKxUeIb6Sk2X5MJ/wjcJcqCx7/L0p8omTt/y/+q1TGpVy6qmJMPUWzNgAULXtsVRSOK4rmiBcrQQ==", "cpu": [ "arm64" ], @@ -8703,9 +8751,9 @@ ] }, "node_modules/@nx/nx-linux-x64-gnu": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-23.0.1.tgz", - "integrity": "sha512-kVszY2xRyyrCXgdCdM1qG1WUhDjNPZxtdWq86a0TyIRJjfJTP9NHqpyhmvj9c2RdZxKVWHotx6fBJzY6Vn2ZrA==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-23.0.2.tgz", + "integrity": "sha512-gR146Mo+BjhrIU2fNOJeVjX8HEAHrtmc7IyrD0qD9yqyq0l9Mdx92JnMW3yVQaYIMpPJIqsOvHTDIUbTLFrmTA==", "cpu": [ "x64" ], @@ -8717,9 +8765,9 @@ ] }, "node_modules/@nx/nx-linux-x64-musl": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-23.0.1.tgz", - "integrity": "sha512-co71K2n4zcS1SYR8EBRlCvIko7M1YycO2tZL0nrCrga87AF5dzCwx+wEclpyCR/4tNOY3FrACk4gIkVskh3CdA==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-23.0.2.tgz", + "integrity": "sha512-L7JkaoAI0p+DpAi2CNCqPq8nHWkApQw2td0Cyt+stOx/OJlmEteyc/MXyTHBoMPoopNj1wWANJJQm/G1anOLhQ==", "cpu": [ "x64" ], @@ -8731,9 +8779,9 @@ ] }, "node_modules/@nx/nx-win32-arm64-msvc": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-23.0.1.tgz", - "integrity": "sha512-7oma7iy5fbnn+x5AP7SFGMuleAA2R5RZm26dn+faikyQ4PXjoRAikWJJNiOWAeCA0BaMAeVedI6fJeAsVeDUKg==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-23.0.2.tgz", + "integrity": "sha512-B3ePaYeu31ivP3i72Vou5RBYFVrDKCMZgz7Jsax+RvEJa8dNfI+Ynh/PSoXzHudN0YsrekkKbjlxNvp/D6fWFA==", "cpu": [ "arm64" ], @@ -8745,9 +8793,9 @@ ] }, "node_modules/@nx/nx-win32-x64-msvc": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-23.0.1.tgz", - "integrity": "sha512-TE/wvBa2cpkVXmk/AXUQAneong4JReS2hyNpAUONKG1yXU7TDKe0wvn1xQXxAbyspudT9NuCnVtpVuEkRz8S+Q==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-23.0.2.tgz", + "integrity": "sha512-/NiB9w8nYrw7LUkcmwAJ9wis5O+kh3ahSZXMDHVYgFnD8yN7kLql39MNJD+/DGstjNSDvWh45ftqr7mZMi6wpQ==", "cpu": [ "x64" ], @@ -8759,16 +8807,16 @@ ] }, "node_modules/@nx/rspack": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/rspack/-/rspack-23.0.1.tgz", - "integrity": "sha512-Hbn4vUrotIy4EVaPHKQX/ijuuH9+fx3ndaEdzQT0O08iJmVmABozsJ1GmQDYO4aU4qvu3eDsJr1IOQ3jqgp7vQ==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/rspack/-/rspack-23.0.2.tgz", + "integrity": "sha512-Aa/7028o5pPqFbR6qG+kurkrGx+/CfSJiOgwsDz1ND9pd1SYP/Z2J9YXL/tgxsDWMy24FaAJvs4MvHMZvAyvLw==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "23.0.1", - "@nx/js": "23.0.1", - "@nx/module-federation": "23.0.1", - "@nx/web": "23.0.1", + "@nx/devkit": "23.0.2", + "@nx/js": "23.0.2", + "@nx/module-federation": "23.0.2", + "@nx/web": "23.0.2", "@phenomnomnominal/tsquery": "~6.2.0", "autoprefixer": "^10.4.9", "browserslist": "^4.26.0", @@ -8841,9 +8889,9 @@ } }, "node_modules/@nx/rspack/node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "dev": true, "license": "MIT", "dependencies": { @@ -9181,22 +9229,22 @@ } }, "node_modules/@nx/storybook": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/storybook/-/storybook-23.0.1.tgz", - "integrity": "sha512-QNJHgxG4qwHpvpcaJ4sllpmlxHCEJjse1dTBFRGL9U+F71ye/W6vYrmHirKuV9yO1X6DR6D1rQMj6WVTGSnvzw==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/storybook/-/storybook-23.0.2.tgz", + "integrity": "sha512-UG6Vh66L6rfSicjUDyP6dkdkX/TimoXxvmHmUXfO2YZlR/uQXIbkav2KpYloea7NPyjX2jehezCEOrTbINEf6A==", "dev": true, "license": "MIT", "dependencies": { - "@nx/cypress": "23.0.1", - "@nx/devkit": "23.0.1", - "@nx/eslint": "23.0.1", - "@nx/js": "23.0.1", + "@nx/cypress": "23.0.2", + "@nx/devkit": "23.0.2", + "@nx/eslint": "23.0.2", + "@nx/js": "23.0.2", "@phenomnomnominal/tsquery": "~6.2.0", "semver": "^7.6.3", "tslib": "^2.3.0" }, "peerDependencies": { - "@nx/web": "23.0.1", + "@nx/web": "23.0.2", "storybook": ">=8.0.0 <11.0.0" }, "peerDependenciesMeta": { @@ -9206,26 +9254,26 @@ } }, "node_modules/@nx/web": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/web/-/web-23.0.1.tgz", - "integrity": "sha512-0iwqCPJ5A27/XzqF6637dcRmJO3/TY7nQrEobS0yJ4W2xhvCIRwsWmPkuUAZgjd4622yQauHAwPQAnkIEILPiQ==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/web/-/web-23.0.2.tgz", + "integrity": "sha512-PIUMDB79yvt6nj9ypLTCYZfA7N9v8mvWEHRbt43rusrzl3P4wqZIiveuyIstmt4HuvIHW3rRjbZTSs/eYrkqRg==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "23.0.1", - "@nx/js": "23.0.1", + "@nx/devkit": "23.0.2", + "@nx/js": "23.0.2", "detect-port": "^2.1.0", "http-server": "^14.1.0", "picocolors": "^1.1.0", "tslib": "^2.3.0" }, "peerDependencies": { - "@nx/cypress": "23.0.1", - "@nx/eslint": "23.0.1", - "@nx/jest": "23.0.1", - "@nx/playwright": "23.0.1", - "@nx/vite": "23.0.1", - "@nx/webpack": "23.0.1" + "@nx/cypress": "23.0.2", + "@nx/eslint": "23.0.2", + "@nx/jest": "23.0.2", + "@nx/playwright": "23.0.2", + "@nx/vite": "23.0.2", + "@nx/webpack": "23.0.2" }, "peerDependenciesMeta": { "@nx/cypress": { @@ -9249,15 +9297,15 @@ } }, "node_modules/@nx/webpack": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/webpack/-/webpack-23.0.1.tgz", - "integrity": "sha512-0ELnKItRtIlcHAN8AaSY4uwabA62aAFDCvYFqadQCTeN5eBYl1VGfzenWMvGyVZoU7K9dYtPVSkZSUhltvHQgw==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/webpack/-/webpack-23.0.2.tgz", + "integrity": "sha512-TbMBG8mLaD7SImTNKH9CZyhgciS6vJZ6/dPK01aHJ4lamKtnFyRGsktgPkLvhKH9jJqLSWSWb4IWAsoHMe46GA==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.23.2", - "@nx/devkit": "23.0.1", - "@nx/js": "23.0.1", + "@nx/devkit": "23.0.2", + "@nx/js": "23.0.2", "@phenomnomnominal/tsquery": "~6.2.0", "ajv": "^8.0.0", "autoprefixer": "^10.4.9", @@ -9284,7 +9332,7 @@ "source-map-loader": "^5.0.0", "style-loader": "^3.3.0", "terser-webpack-plugin": "^5.3.3", - "ts-loader": "^9.3.1", + "ts-loader": "^9.5.7", "tsconfig-paths-webpack-plugin": "4.2.0", "tslib": "^2.3.0", "webpack-node-externals": "^3.0.0", @@ -9414,17 +9462,17 @@ "license": "MIT" }, "node_modules/@nx/workspace": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@nx/workspace/-/workspace-23.0.1.tgz", - "integrity": "sha512-VdbvMTSEzp3ONZwiy83XEu8ktykC8aEI7M4mqKs5RNKHBFg3jtao2NFo3wDqHqnn1q9Fdaj8EbyUn08BUR5L3w==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/@nx/workspace/-/workspace-23.0.2.tgz", + "integrity": "sha512-7As52HT7l7ypwukR0cKZIDZ771O0hYTDwkfJmzWPHl3YghiROnK3/Xlh/84ITo+4gtCMORUyZXZgQfOi8136ug==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "23.0.1", + "@nx/devkit": "23.0.2", "@zkochan/js-yaml": "0.0.7", "chalk": "^4.1.0", "enquirer": "~2.3.6", - "nx": "23.0.1", + "nx": "23.0.2", "picomatch": "4.0.4", "semver": "^7.6.3", "tslib": "^2.3.0", @@ -15025,17 +15073,45 @@ } }, "node_modules/axios": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", - "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -16986,9 +17062,9 @@ } }, "node_modules/countries-list": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/countries-list/-/countries-list-3.3.0.tgz", - "integrity": "sha512-XRUjS+dcZuNh/fg3+mka3bXgcg4TbQZ1gaK5IJqO6qulerBANl1bmrd20P2dgmPkBpP+5FnejiSF1gd7bgAg+g==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/countries-list/-/countries-list-3.4.0.tgz", + "integrity": "sha512-SonKkjlEGUNz/Ugj6akOMUl8/F6EBVbLq/nQbDKhZo60EFyNgsVIgDP521J9pGhywikV514Ct4BueIqiOnvBBA==", "license": "MIT" }, "node_modules/countup.js": { @@ -17230,9 +17306,9 @@ } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", "dev": true, "funding": [ { @@ -19378,6 +19454,19 @@ "node": ">=18" } }, + "node_modules/envinfo": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -21837,12 +21926,15 @@ } }, "node_modules/helmet": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.0.0.tgz", - "integrity": "sha512-MsIgYmdBh460ZZ8cJC81q4XJknjG567wzEmv46WOBblDb6TUd3z8/GhgmsM9pn8g2B80tAJ4m5/d3Bi1KrSUBQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", "license": "MIT", "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" } }, "node_modules/hono": { @@ -26777,9 +26869,9 @@ "license": "MIT" }, "node_modules/nx": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/nx/-/nx-23.0.1.tgz", - "integrity": "sha512-HnK0Ke8FcPeVQffYm1oyzkLNn7khrI8SeDeC3iyLhw/UEMCB24hjI5JSs6Amlyeb0/GaeiuQuts8NkQKd/NpGA==", + "version": "23.0.2", + "resolved": "https://registry.npmjs.org/nx/-/nx-23.0.2.tgz", + "integrity": "sha512-e5H6ceqj0Z8ovAmtsiHXswwpiNPrr1DRhvJMTpc2AW8G1za9PKxk3bP5josShsIrmGEsOlBNZZsxszXA2+Q2dw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -26792,12 +26884,13 @@ "@tybys/wasm-util": "0.9.0", "@yarnpkg/lockfile": "1.1.0", "@zkochan/js-yaml": "0.0.7", + "agent-base": "6.0.2", "ansi-colors": "4.1.3", "ansi-regex": "5.0.1", "ansi-styles": "4.3.0", "argparse": "2.0.1", "asynckit": "0.4.0", - "axios": "1.16.0", + "axios": "1.16.1", "balanced-match": "4.0.3", "base64-js": "1.5.1", "bl": "4.1.0", @@ -26812,6 +26905,7 @@ "color-convert": "2.0.1", "color-name": "1.1.4", "combined-stream": "1.0.8", + "debug": "4.4.3", "defaults": "1.0.4", "define-lazy-prop": "2.0.0", "delayed-stream": "1.0.0", @@ -26842,6 +26936,7 @@ "has-symbols": "1.1.0", "has-tostringtag": "1.0.2", "hasown": "2.0.4", + "https-proxy-agent": "5.0.1", "ieee754": "1.2.1", "ignore": "7.0.5", "inherits": "2.0.4", @@ -26861,6 +26956,7 @@ "mimic-fn": "2.1.0", "minimatch": "10.2.5", "minimist": "1.2.8", + "ms": "2.1.3", "npm-run-path": "4.0.1", "once": "1.4.0", "onetime": "5.1.2", @@ -26901,16 +26997,16 @@ "nx-cloud": "dist/bin/nx-cloud.js" }, "optionalDependencies": { - "@nx/nx-darwin-arm64": "23.0.1", - "@nx/nx-darwin-x64": "23.0.1", - "@nx/nx-freebsd-x64": "23.0.1", - "@nx/nx-linux-arm-gnueabihf": "23.0.1", - "@nx/nx-linux-arm64-gnu": "23.0.1", - "@nx/nx-linux-arm64-musl": "23.0.1", - "@nx/nx-linux-x64-gnu": "23.0.1", - "@nx/nx-linux-x64-musl": "23.0.1", - "@nx/nx-win32-arm64-msvc": "23.0.1", - "@nx/nx-win32-x64-msvc": "23.0.1" + "@nx/nx-darwin-arm64": "23.0.2", + "@nx/nx-darwin-x64": "23.0.2", + "@nx/nx-freebsd-x64": "23.0.2", + "@nx/nx-linux-arm-gnueabihf": "23.0.2", + "@nx/nx-linux-arm64-gnu": "23.0.2", + "@nx/nx-linux-arm64-musl": "23.0.2", + "@nx/nx-linux-x64-gnu": "23.0.2", + "@nx/nx-linux-x64-musl": "23.0.2", + "@nx/nx-win32-arm64-msvc": "23.0.2", + "@nx/nx-win32-x64-msvc": "23.0.2" }, "peerDependencies": { "@swc-node/register": "^1.11.1", @@ -26976,6 +27072,19 @@ "tslib": "^2.4.0" } }, + "node_modules/nx/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/nx/node_modules/balanced-match": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", @@ -27065,6 +27174,20 @@ "node": ">=0.8.0" } }, + "node_modules/nx/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/nx/node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -27193,6 +27316,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/nx/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nx/node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", @@ -32397,9 +32527,9 @@ } }, "node_modules/svgo": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", - "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.2.tgz", + "integrity": "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==", "dev": true, "license": "MIT", "dependencies": { @@ -33129,15 +33259,15 @@ } }, "node_modules/ts-checker-rspack-plugin": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/ts-checker-rspack-plugin/-/ts-checker-rspack-plugin-1.5.1.tgz", - "integrity": "sha512-h0jF3PAIrG+UA2nZ2OPUEt0NywkpiJkydkoVg/Kd1OFPFlrayNBJoSP0zeNPUpdsFEt7ICX1BjENCfOH9nmMyA==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/ts-checker-rspack-plugin/-/ts-checker-rspack-plugin-1.5.2.tgz", + "integrity": "sha512-YtdNbLnJ9fU6itfQky0K9Lebb/N0TUSbSVUByyoGt27em3hiTCVnfW2MzxFUHpdMv5+7xPmoS8YBwdDyHfEaGQ==", "dev": true, "license": "MIT", "dependencies": { "@rspack/lite-tapable": "^1.1.2", "chokidar": "^3.6.0", - "memfs": "^4.57.7", + "memfs": "^4.57.8", "picocolors": "^1.1.1" }, "peerDependencies": { @@ -33189,14 +33319,14 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-core": { - "version": "4.57.8", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.57.8.tgz", - "integrity": "sha512-YzVbwggV9452VCeHgo0bjsTaUt1O7JE0XpEsPar93nn/+RAwXk0mb1Y+f5EDJ3TRtRCFe+Ck5RuojdfB4jeHVw==", + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.64.0.tgz", + "integrity": "sha512-zs2TAq7Six5jgMuoMNjpspAvOP3mhtgq/k1UyQodEzCtQi/N83y2/y+zcvnZSGp/Rxq96DBN+bValOBQAyn/ew==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.57.8", - "@jsonjoy.com/fs-node-utils": "4.57.8", + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0", "thingies": "^2.5.0" }, "engines": { @@ -33211,15 +33341,15 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-fsa": { - "version": "4.57.8", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.57.8.tgz", - "integrity": "sha512-vmClyvCQMxgqz7uamDiGtRfp4MjzOznk3pcQjCxlIwJcw7TWeyr+bF30hI0x8NxdtNOGMg1pHM74VDIXOeyjuw==", + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.64.0.tgz", + "integrity": "sha512-nMWOVbkLFyEgmXZih3wyvxA9XpgyyqyfrINMHvEFqhi7uqfRl7c9ERJt6yX7vgMPrB9Uo+OJO+Spa0cFzPD01w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.57.8", - "@jsonjoy.com/fs-node-builtins": "4.57.8", - "@jsonjoy.com/fs-node-utils": "4.57.8", + "@jsonjoy.com/fs-core": "4.64.0", + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0", "thingies": "^2.5.0" }, "engines": { @@ -33234,17 +33364,17 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-node": { - "version": "4.57.8", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.57.8.tgz", - "integrity": "sha512-IPEOlDYSnTDYpjQlQg2F8h+eqxKQN3sdbroI0WrteRiQZ462HzVpBo9ZZX485njz4nAacoe3fd4iDiIhk+k5Hg==", + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.64.0.tgz", + "integrity": "sha512-dO+NNkODbUli4uV42bcNrrLvq5rE7SNpdZ5TNd0dtbLsAaNK3MDiIC9lUi+brboGoIjW6vd2fB1qao60nrk5xA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.57.8", - "@jsonjoy.com/fs-node-builtins": "4.57.8", - "@jsonjoy.com/fs-node-utils": "4.57.8", - "@jsonjoy.com/fs-print": "4.57.8", - "@jsonjoy.com/fs-snapshot": "4.57.8", + "@jsonjoy.com/fs-core": "4.64.0", + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0", + "@jsonjoy.com/fs-print": "4.64.0", + "@jsonjoy.com/fs-snapshot": "4.64.0", "glob-to-regex.js": "^1.0.0", "thingies": "^2.5.0" }, @@ -33260,9 +33390,9 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-node-builtins": { - "version": "4.57.8", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.8.tgz", - "integrity": "sha512-mxXSXw8zZwRVakcjLqR2I/psy4gURFSASZS10kKJ2kJw05GC2nXGroGrWVHxwgkxXgQLsFQnB74QaLzsxzdL/w==", + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.64.0.tgz", + "integrity": "sha512-/o7WRFhUWaM/fOrslwLZGnzn4RmRILykn+lAL+mNObqqRNw+CQSiij6hpCeZ+C7buhdoVo7go/OYqzaSUfDYmA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -33277,15 +33407,15 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-node-to-fsa": { - "version": "4.57.8", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.57.8.tgz", - "integrity": "sha512-AWZcT/4+H+iDl4XCukbXrarvwEgOrf/prFI5/7eg4ix9FxqVsZysIDJd1Kjd+AjlCeHKHJOaRqjLd5HiGSCJEw==", + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.64.0.tgz", + "integrity": "sha512-WDD9WVs0hb7UAEKTgZW2f66WDrbj7gIIWwpP3spbLyXa0rghtUaFTB8L4gdR3ZCWwiKIsj38/CNijpVmpnuPUw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-fsa": "4.57.8", - "@jsonjoy.com/fs-node-builtins": "4.57.8", - "@jsonjoy.com/fs-node-utils": "4.57.8" + "@jsonjoy.com/fs-fsa": "4.64.0", + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0" }, "engines": { "node": ">=10.0" @@ -33299,13 +33429,14 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-node-utils": { - "version": "4.57.8", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.57.8.tgz", - "integrity": "sha512-E/bJ7sQAb4pu9nbeJhbULU3WnqWrswte4N9Js/oHt7aHB746S8/XBqKlcbrqIgnD3095XluovNEZuu5ONT230g==", + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.64.0.tgz", + "integrity": "sha512-k5Indsx9hWW9xSF7Y6oSKKwtCUNhzZxadub3owhIlitc+iMRVlPPdX2duTKQWBL3qNWpXya8jykgaaWpheeS4w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.57.8" + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "glob-to-regex.js": "^1.0.1" }, "engines": { "node": ">=10.0" @@ -33319,13 +33450,13 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-print": { - "version": "4.57.8", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.57.8.tgz", - "integrity": "sha512-DfzhOBpmvNu5P/KSe4NNQaOnvNliTdcf0qrh/4EReErF/XUQXYkd0vZl/OiJCm/qjEEo8DWRstliw2/JNS84dA==", + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.64.0.tgz", + "integrity": "sha512-PHZFccchvkhWrwPWHjmVAhbC3vSHCtyZvlZfJJ3ho2bnzl450hXri6/8e6pbkWdH+SkmLXNml0sV8e5HDAfxKw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-utils": "4.57.8", + "@jsonjoy.com/fs-node-utils": "4.64.0", "tree-dump": "^1.1.0" }, "engines": { @@ -33340,14 +33471,14 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-snapshot": { - "version": "4.57.8", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.57.8.tgz", - "integrity": "sha512-L+eqKaWOHLDaiMv1dh/EWQ4hA+o6xAhWSumTo3Teg7OM18jU/KE13/e8Mfal+eAZ/pSl4wIhKHcDiwapJzC8Wg==", + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.64.0.tgz", + "integrity": "sha512-oM7UDeL83q6NBzzsfKAsYKXKVXlykKFqqOLh4xZZKAzzROTlInkPbc6LTDGThEOnPiFiUzA7tYziHG9xavd76Q==", "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/buffers": "^17.65.0", - "@jsonjoy.com/fs-node-utils": "4.57.8", + "@jsonjoy.com/fs-node-utils": "4.64.0", "@jsonjoy.com/json-pack": "^17.65.0", "@jsonjoy.com/util": "^17.65.0" }, @@ -33497,20 +33628,20 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/memfs": { - "version": "4.57.8", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.8.tgz", - "integrity": "sha512-bApYhn8BLpFAnAQmFfEl/NPN+8qx5Ar3V4Qt3ek23mVwBEElzV7c6XoPkb/PCG8ZFpowCEpHcPwMFTwHS7tSMA==", + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.64.0.tgz", + "integrity": "sha512-Kw72fgY7Wn+sD8KmtNWSafl1dz0UvAsE/PHs3YVfLiaZuA3HxNm9sRLqAu0ATiBGJvME1PxZXbBZPv5GycDeAw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.57.8", - "@jsonjoy.com/fs-fsa": "4.57.8", - "@jsonjoy.com/fs-node": "4.57.8", - "@jsonjoy.com/fs-node-builtins": "4.57.8", - "@jsonjoy.com/fs-node-to-fsa": "4.57.8", - "@jsonjoy.com/fs-node-utils": "4.57.8", - "@jsonjoy.com/fs-print": "4.57.8", - "@jsonjoy.com/fs-snapshot": "4.57.8", + "@jsonjoy.com/fs-core": "4.64.0", + "@jsonjoy.com/fs-fsa": "4.64.0", + "@jsonjoy.com/fs-node": "4.64.0", + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-to-fsa": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0", + "@jsonjoy.com/fs-print": "4.64.0", + "@jsonjoy.com/fs-snapshot": "4.64.0", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", "glob-to-regex.js": "^1.0.1", @@ -34613,6 +34744,101 @@ } } }, + "node_modules/webpack-cli": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-7.1.0.tgz", + "integrity": "sha512-pSJ5p5PkXRD88sfCq5Wo+coc42QykwRu5Md0DyESj0rT6PPPA2wTNabpHPKgqH8EMkfTDo3IWx3iiNXMu8XDBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "^1.1.0", + "commander": "^14.0.3", + "cross-spawn": "^7.0.6", + "envinfo": "^7.21.0", + "import-local": "^3.2.0", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^6.0.1" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "js-yaml": "^4.0.0 || ^5.0.0", + "json5": "^2.2.3", + "toml": "^3.0.0 || ^4.0.0", + "webpack": "^5.101.0", + "webpack-bundle-analyzer": "^4.0.0 || ^5.0.0", + "webpack-dev-server": "^5.0.0" + }, + "peerDependenciesMeta": { + "js-yaml": { + "optional": true + }, + "json5": { + "optional": true + }, + "toml": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/@discoveryjs/json-ext": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-1.1.0.tgz", + "integrity": "sha512-Xc3VhU02wqZ1HvHRJUwL09HkZSTvidqY5Ya0NXBSYOxAp+Ln9dcJr9fySI+CkONzP3PekQo9WdzCv0PGER/mOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/webpack-cli/node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack-cli/node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, "node_modules/webpack-dev-middleware": { "version": "7.4.5", "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.5.tgz", diff --git a/package.json b/package.json index a082e7c96..384ca8a8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.21.0", + "version": "3.26.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", @@ -73,6 +73,7 @@ "@internationalized/number": "3.6.7", "@ionic/angular": "8.8.12", "@keyv/redis": "5.1.6", + "@nest-lab/throttler-storage-redis": "1.2.0", "@nestjs/bull": "11.0.4", "@nestjs/cache-manager": "3.1.3", "@nestjs/common": "11.1.27", @@ -84,6 +85,7 @@ "@nestjs/platform-express": "11.1.27", "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", + "@nestjs/throttler": "6.5.0", "@openrouter/ai-sdk-provider": "2.9.1", "@prisma/adapter-pg": "7.8.0", "@prisma/client": "7.8.0", @@ -105,7 +107,7 @@ "color": "5.0.3", "cookie-parser": "1.4.7", "countries-and-timezones": "3.9.0", - "countries-list": "3.3.0", + "countries-list": "3.4.0", "countup.js": "2.10.0", "date-fns": "4.4.0", "dotenv": "17.2.3", @@ -114,7 +116,7 @@ "fast-redact": "3.5.0", "fuse.js": "7.3.0", "google-spreadsheet": "3.2.0", - "helmet": "7.0.0", + "helmet": "8.2.0", "http-status-codes": "2.3.0", "ionicons": "8.0.13", "jsonpath": "1.3.0", @@ -159,16 +161,16 @@ "@eslint/js": "9.35.0", "@nestjs/schematics": "11.1.0", "@nestjs/testing": "11.1.27", - "@nx/angular": "23.0.1", - "@nx/eslint-plugin": "23.0.1", - "@nx/jest": "23.0.1", - "@nx/js": "23.0.1", - "@nx/module-federation": "23.0.1", - "@nx/nest": "23.0.1", - "@nx/node": "23.0.1", - "@nx/storybook": "23.0.1", - "@nx/web": "23.0.1", - "@nx/workspace": "23.0.1", + "@nx/angular": "23.0.2", + "@nx/eslint-plugin": "23.0.2", + "@nx/jest": "23.0.2", + "@nx/js": "23.0.2", + "@nx/module-federation": "23.0.2", + "@nx/nest": "23.0.2", + "@nx/node": "23.0.2", + "@nx/storybook": "23.0.2", + "@nx/web": "23.0.2", + "@nx/workspace": "23.0.2", "@prisma/config": "7.8.0", "@schematics/angular": "21.2.6", "@storybook/addon-docs": "10.1.10", @@ -196,7 +198,7 @@ "jest": "30.3.0", "jest-environment-jsdom": "30.2.0", "jest-preset-angular": "16.0.0", - "nx": "23.0.1", + "nx": "23.0.2", "prettier": "3.8.4", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "7.8.0", @@ -208,7 +210,8 @@ "ts-jest": "29.4.0", "ts-node": "10.9.2", "tslib": "2.8.1", - "typescript": "5.9.2" + "typescript": "5.9.2", + "webpack-cli": "7.1.0" }, "engines": { "node": ">=22.18.0" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 233a63688..733399506 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -90,6 +90,21 @@ model ApiKey { @@index([userId]) } +model AssetProfileOverrides { + assetClass AssetClass? + assetSubClass AssetSubClass? + countries Json? @default("[]") + holdings Json? @default("[]") + name String? + sectors Json? @default("[]") + symbolProfile SymbolProfile @relation(fields: [symbolProfileId], onDelete: Cascade, references: [id]) + symbolProfileId String @id + updatedAt DateTime @updatedAt + url String? + + @@map("SymbolProfileOverrides") +} + model AssetProfileResolution { createdAt DateTime @default(now()) currency String @@ -187,32 +202,32 @@ model Settings { model SymbolProfile { activities Order[] assetClass AssetClass? + assetProfileOverrides AssetProfileOverrides? assetSubClass AssetSubClass? comment String? countries Json? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) currency String cusip String? - dataGatheringFrequency DataGatheringFrequency @default(DAILY) + dataGatheringFrequency DataGatheringFrequency @default(DAILY) dataSource DataSource figi String? figiComposite String? figiShareClass String? - holdings Json? @default("[]") - id String @id @default(uuid()) - isActive Boolean @default(true) + holdings Json? @default("[]") + id String @id @default(uuid()) + isActive Boolean @default(true) isin String? name String? - updatedAt DateTime @updatedAt scraperConfiguration Json? sectors Json? symbol String symbolMapping Json? + updatedAt DateTime @updatedAt url String? - user User? @relation(fields: [userId], onDelete: Cascade, references: [id]) + user User? @relation(fields: [userId], onDelete: Cascade, references: [id]) userId String? - watchedBy User[] @relation("UserWatchlist") - SymbolProfileOverrides SymbolProfileOverrides? + watchedBy User[] @relation("UserWatchlist") @@unique([dataSource, symbol]) @@index([assetClass]) @@ -226,19 +241,6 @@ model SymbolProfile { @@index([symbol]) } -model SymbolProfileOverrides { - assetClass AssetClass? - assetSubClass AssetSubClass? - countries Json? @default("[]") - holdings Json? @default("[]") - name String? - sectors Json? @default("[]") - symbolProfileId String @id - updatedAt DateTime @updatedAt - url String? - SymbolProfile SymbolProfile @relation(fields: [symbolProfileId], onDelete: Cascade, references: [id]) -} - model Subscription { createdAt DateTime @default(now()) expiresAt DateTime diff --git a/skills-lock.json b/skills-lock.json index 0ecf51f96..06bf64189 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -7,6 +7,12 @@ "skillPath": "angular-developer/SKILL.md", "computedHash": "28eb592b92e5a24c4e3a1c0229a854069f0b8c49bed7b8d2bf6b852812dbe214" }, + "karpathy-guidelines": { + "source": "multica-ai/andrej-karpathy-skills", + "sourceType": "github", + "skillPath": "skills/karpathy-guidelines/SKILL.md", + "computedHash": "41e8ca055bbde13d240776a14a076a59614057200340c243130a76ba4e64cac8" + }, "nestjs-best-practices": { "source": "kadajett/agent-nestjs-skills", "sourceType": "github",