diff --git a/CHANGELOG.md b/CHANGELOG.md index c476bacce..e8308e4c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,130 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added a hint about delayed market data to the markets overview +- Added the asset profile count per data provider to the endpoint `GET api/v1/admin` + +### Changed + +- Harmonized the data providers management style of the admin control panel +- Extended the data providers management of the admin control panel by the asset profile count +- Restricted the permissions of the demo user +- Renamed `Order` to `activities` in the `User` database schema +- Removed the deprecated endpoint `GET api/v1/admin/market-data/:dataSource/:symbol` +- Removed the deprecated endpoint `POST api/v1/admin/market-data/:dataSource/:symbol` +- Removed the deprecated endpoint `PUT api/v1/admin/market-data/:dataSource/:symbol/:dateString` +- Improved the language localization for Catalan (`ca`) +- Improved the language localization for Chinese (`zh`) +- Improved the language localization for Dutch (`nl`) +- Improved the language localization for French (`fr`) +- Improved the language localization for German (`de`) +- Improved the language localization for Italian (`it`) +- Upgraded `countup.js` from version `2.8.0` to `2.8.2` +- Upgraded `nestjs` from version `10.4.15` to `11.0.12` +- Upgraded `twitter-api-v2` from version `1.14.2` to `1.23.0` +- Upgraded `yahoo-finance2` from version `2.11.3` to `3.3.2` + +### Fixed + +- Displayed the button to fetch the current market price only if the activity is not in a custom currency +- Fixed an issue in the watchlist endpoint (`POST`) related to the `HasPermissionGuard` + +## 2.161.0 - 2025-05-06 + +### Added + +- Extended the endpoint to get a holding by the date of the last all time high and the current change to the all time high + +### Changed + +- Renamed `Order` to `activities` in the `SymbolProfile` database schema +- Improved the language localization for Turkish (`tr`) + +### Fixed + +- Fixed an issue in the performance calculation on the date of an activity when the unit price differs from the market price +- Fixed the horizontal overflow in the table of the benchmark component + +## 2.160.0 - 2025-05-04 + +### Added + +- Added the watchlist to the features page +- Extended the content of the Frequently Asked Questions (FAQ) pages + +### Changed + +- Moved the watchlist from experimental to general availability +- Deprecated the endpoint to get a portfolio position in favor of get a holding +- Deprecated the endpoint to update portfolio position tags in favor of update holding tags +- Renamed `Account` to `accounts` in the `Platform` database schema +- Upgraded `prisma` from version `6.6.0` to `6.7.0` + +### Fixed + +- Fixed an issue with the fee calculations related to activities in a custom currency + +## 2.159.0 - 2025-05-02 + +### Added + +- Extended the watchlist by the date of the last all time high, the current change to the all time high and the current market condition (experimental) +- Added support for the impersonation mode in the watchlist (experimental) + +### Changed + +- Improved the language localization for Français (`fr`) +- Upgraded `bootstrap` from version `4.6.0` to `4.6.2` + +### Fixed + +- Fixed the currency code validation by allowing `GBp` + +## 2.158.0 - 2025-04-30 + +### Added + +- Added support to delete an asset from the watchlist (experimental) + +### Changed + +- Renamed `Order` to `activities` in the `Account` database schema +- Improved the language localization for German (`de`) + +### Fixed + +- Fixed an issue with the saving of activities with type `INTEREST`, `ITEM` and `LIABILITY` + +## 2.157.1 - 2025-04-29 + +### Added + +- Introduced a watchlist to follow assets (experimental) + +### Changed + +- Changed the column label from _Index_ to _Name_ in the benchmark component +- Extended the data providers management of the admin control panel +- Improved the language localization for German (`de`) + +## 2.156.0 - 2025-04-27 + ### Changed - Improved the error message of the currency code validation - Tightened the currency code validation by requiring uppercase letters +- Respected the watcher count for the delete asset profiles checkbox in the historical market data table of the admin control panel +- Improved the language localization for Français (`fr`) +- Upgraded `ngx-skeleton-loader` from version `10.0.0` to `11.0.0` +- Upgraded `Nx` from version `20.8.0` to `20.8.1` ### Fixed +- Fixed an issue with the investment calculation for activities in a custom currency - Improved the file selector of the activities import functionality to accept case-insensitive file extensions (`.CSV` and `.JSON`) +- Fixed the missing localization for "someone" on the public page ## 2.155.0 - 2025-04-23 diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index c0f4dac6a..1d8f9ab27 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -9,7 +9,7 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation/imp import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { AccountBalancesResponse, - Accounts + AccountsResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; import type { @@ -57,17 +57,17 @@ export class AccountController { @HasPermission(permissions.deleteAccount) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async deleteAccount(@Param('id') id: string): Promise { - const account = await this.accountService.accountWithOrders( + const account = await this.accountService.accountWithActivities( { id_userId: { id, userId: this.request.user.id } }, - { Order: true } + { activities: true } ); - if (!account || account?.Order.length > 0) { + if (!account || account?.activities.length > 0) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), StatusCodes.FORBIDDEN @@ -87,10 +87,10 @@ export class AccountController { @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getAllAccounts( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Query('dataSource') filterByDataSource?: string, @Query('symbol') filterBySymbol?: string - ): Promise { + ): Promise { const impersonationUserId = await this.impersonationService.validateImpersonationId(impersonationId); @@ -110,7 +110,7 @@ export class AccountController { @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(RedactValuesInResponseInterceptor) public async getAccountById( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Param('id') id: string ): Promise { const impersonationUserId = diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index 74b612b7e..56186b1be 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -40,12 +40,12 @@ export class AccountService { return account; } - public async accountWithOrders( + public async accountWithActivities( accountWhereUniqueInput: Prisma.AccountWhereUniqueInput, accountInclude: Prisma.AccountInclude ): Promise< Account & { - Order?: Order[]; + activities?: Order[]; } > { return this.prismaService.account.findUnique({ @@ -63,8 +63,8 @@ export class AccountService { orderBy?: Prisma.AccountOrderByWithRelationInput; }): Promise< (Account & { + activities?: Order[]; balances?: AccountBalance[]; - Order?: Order[]; Platform?: Platform; })[] > { @@ -141,7 +141,7 @@ export class AccountService { public async getAccounts(aUserId: string): Promise { const accounts = await this.accounts({ - include: { Order: true, Platform: true }, + include: { activities: true, Platform: true }, orderBy: { name: 'asc' }, where: { userId: aUserId } }); @@ -149,15 +149,15 @@ export class AccountService { return accounts.map((account) => { let transactionCount = 0; - for (const order of account.Order) { - if (!order.isDraft) { + for (const { isDraft } of account.activities) { + if (!isDraft) { transactionCount += 1; } } const result = { ...account, transactionCount }; - delete result.Order; + delete result.activities; return result; }); diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index b3293095f..a584e13ff 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -3,7 +3,6 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard' import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; -import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { @@ -16,7 +15,6 @@ import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { AdminData, AdminMarketData, - AdminMarketDataDetails, AdminUsers, EnhancedSymbolProfile } from '@ghostfolio/common/interfaces'; @@ -50,8 +48,6 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AdminService } from './admin.service'; import { UpdateAssetProfileDto } from './update-asset-profile.dto'; -import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto'; -import { UpdateMarketDataDto } from './update-market-data.dto'; @Controller('admin') export class AdminController { @@ -60,7 +56,6 @@ export class AdminController { private readonly apiService: ApiService, private readonly dataGatheringService: DataGatheringService, private readonly manualService: ManualService, - private readonly marketDataService: MarketDataService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} @@ -68,7 +63,7 @@ export class AdminController { @HasPermission(permissions.accessAdminControl) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getAdminData(): Promise { - return this.adminService.get(); + return this.adminService.get({ user: this.request.user }); } @HasPermission(permissions.accessAdminControl) @@ -246,19 +241,6 @@ export class AdminController { }); } - /** - * @deprecated - */ - @Get('market-data/:dataSource/:symbol') - @HasPermission(permissions.accessAdminControl) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - public async getMarketDataBySymbol( - @Param('dataSource') dataSource: DataSource, - @Param('symbol') symbol: string - ): Promise { - return this.adminService.getMarketDataBySymbol({ dataSource, symbol }); - } - @HasPermission(permissions.accessAdminControl) @Post('market-data/:dataSource/:symbol/test') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -285,58 +267,6 @@ export class AdminController { } } - /** - * @deprecated - */ - @HasPermission(permissions.accessAdminControl) - @Post('market-data/:dataSource/:symbol') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - public async updateMarketData( - @Body() data: UpdateBulkMarketDataDto, - @Param('dataSource') dataSource: DataSource, - @Param('symbol') symbol: string - ) { - const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map( - ({ date, marketPrice }) => ({ - dataSource, - marketPrice, - symbol, - date: parseISO(date), - state: 'CLOSE' - }) - ); - - return this.marketDataService.updateMany({ - data: dataBulkUpdate - }); - } - - /** - * @deprecated - */ - @HasPermission(permissions.accessAdminControl) - @Put('market-data/:dataSource/:symbol/:dateString') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - public async update( - @Param('dataSource') dataSource: DataSource, - @Param('dateString') dateString: string, - @Param('symbol') symbol: string, - @Body() data: UpdateMarketDataDto - ) { - const date = parseISO(dateString); - - return this.marketDataService.updateMarketData({ - data: { marketPrice: data.marketPrice, state: 'CLOSE' }, - where: { - dataSource_date_symbol: { - dataSource, - date, - symbol - } - } - }); - } - @HasPermission(permissions.accessAdminControl) @Post('profile-data/:dataSource/:symbol') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 393ecdffd..46f3257d0 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -29,7 +29,7 @@ import { Filter } from '@ghostfolio/common/interfaces'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; -import { MarketDataPreset } from '@ghostfolio/common/types'; +import { MarketDataPreset, UserWithSettings } from '@ghostfolio/common/types'; import { BadRequestException, @@ -134,14 +134,36 @@ export class AdminService { } } - public async get(): Promise { + public async get({ user }: { user: UserWithSettings }): Promise { + const dataSources = await this.dataProviderService.getDataSources({ user }); + const [settings, transactionCount, userCount] = await Promise.all([ this.propertyService.get(), this.prismaService.order.count(), this.countUsersWithAnalytics() ]); + const dataProviders = await Promise.all( + dataSources.map(async (dataSource) => { + const dataProviderInfo = this.dataProviderService + .getDataProvider(dataSource) + .getDataProviderInfo(); + + const assetProfileCount = await this.prismaService.symbolProfile.count({ + where: { + dataSource + } + }); + + return { + ...dataProviderInfo, + assetProfileCount + }; + }) + ); + return { + dataProviders, settings, transactionCount, userCount, @@ -220,7 +242,7 @@ export class AdminService { if (sortColumn === 'activitiesCount') { orderBy = { - Order: { + activities: { _count: sortDirection } }; @@ -238,7 +260,15 @@ export class AdminService { where, select: { _count: { - select: { Order: true } + select: { + activities: true, + watchedBy: true + } + }, + activities: { + orderBy: [{ date: 'asc' }], + select: { date: true }, + take: 1 }, assetClass: true, assetSubClass: true, @@ -250,11 +280,6 @@ export class AdminService { isActive: true, isUsedByUsersWithSubscription: true, name: true, - Order: { - orderBy: [{ date: 'asc' }], - select: { date: true }, - take: 1 - }, scraperConfiguration: true, sectors: true, symbol: true, @@ -302,6 +327,7 @@ export class AdminService { assetProfiles.map( async ({ _count, + activities, assetClass, assetSubClass, comment, @@ -312,7 +338,6 @@ export class AdminService { isActive, isUsedByUsersWithSubscription, name, - Order, sectors, symbol, SymbolProfileOverrides, @@ -375,10 +400,11 @@ export class AdminService { symbol, marketDataItemCount, sectorsCount, - activitiesCount: _count.Order, - date: Order?.[0]?.date, + activitiesCount: _count.activities, + date: activities?.[0]?.date, isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription, + watchedByCount: _count.watchedBy, tags }; } @@ -648,7 +674,7 @@ export class AdminService { select: { _count: { select: { - Order: { + activities: { where: { User: { subscriptions: { @@ -669,7 +695,7 @@ export class AdminService { } }); - return _count.Order > 0; + return _count.activities > 0; } } } @@ -759,6 +785,7 @@ export class AdminService { isActive: true, name: symbol, sectorsCount: 0, + watchedByCount: 0, tags: [] }; } @@ -800,7 +827,7 @@ export class AdminService { where, select: { _count: { - select: { Account: true, Order: true } + select: { Account: true, activities: true } }, Analytics: { select: { @@ -848,10 +875,10 @@ export class AdminService { role, subscription, accountCount: _count.Account || 0, + activityCount: _count.activities || 0, country: Analytics?.country, dailyApiRequests: Analytics?.dataProviderGhostfolioDailyRequests || 0, - lastActivity: Analytics?.updatedAt, - transactionCount: _count.Order || 0 + lastActivity: Analytics?.updatedAt }; } ); diff --git a/apps/api/src/app/auth/api-key.strategy.ts b/apps/api/src/app/auth/api-key.strategy.ts index ace7fb245..e99d6aed7 100644 --- a/apps/api/src/app/auth/api-key.strategy.ts +++ b/apps/api/src/app/auth/api-key.strategy.ts @@ -21,37 +21,31 @@ export class ApiKeyStrategy extends PassportStrategy( private readonly prismaService: PrismaService, private readonly userService: UserService ) { - super( - { header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, - true, - async (apiKey: string, done: (error: any, user?: any) => void) => { - try { - const user = await this.validateApiKey(apiKey); - - if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { - if (hasRole(user, 'INACTIVE')) { - throw new HttpException( - getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), - StatusCodes.TOO_MANY_REQUESTS - ); - } + super({ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, false); + } - await this.prismaService.analytics.upsert({ - create: { User: { connect: { id: user.id } } }, - update: { - activityCount: { increment: 1 }, - lastRequestAt: new Date() - }, - where: { userId: user.id } - }); - } + public async validate(apiKey: string) { + const user = await this.validateApiKey(apiKey); - done(null, user); - } catch (error) { - done(error, null); - } + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + if (hasRole(user, 'INACTIVE')) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); } - ); + + await this.prismaService.analytics.upsert({ + create: { User: { connect: { id: user.id } } }, + update: { + activityCount: { increment: 1 }, + lastRequestAt: new Date() + }, + where: { userId: user.id } + }); + } + + return user; } private async validateApiKey(apiKey: string) { diff --git a/apps/api/src/app/endpoints/ai/ai.module.ts b/apps/api/src/app/endpoints/ai/ai.module.ts index 584f29956..b6f9941ad 100644 --- a/apps/api/src/app/endpoints/ai/ai.module.ts +++ b/apps/api/src/app/endpoints/ai/ai.module.ts @@ -8,6 +8,7 @@ import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; @@ -27,6 +28,7 @@ import { AiService } from './ai.service'; controllers: [AiController], imports: [ ApiModule, + BenchmarkModule, ConfigurationModule, DataProviderModule, ExchangeRateDataModule, 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 7281697bd..bdaecf718 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,5 +1,6 @@ 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'; import { GetAssetProfileParams, GetDividendsParams, @@ -327,10 +328,15 @@ export class GhostfolioService { } private getDataProviderInfo(): DataProviderInfo { + const ghostfolioDataProviderService = new GhostfolioDataProviderService( + this.configurationService, + this.propertyService + ); + return { + ...ghostfolioDataProviderService.getDataProviderInfo(), isPremium: false, - name: 'Ghostfolio Premium', - url: 'https://ghostfol.io' + name: 'Ghostfolio Premium' }; } diff --git a/apps/api/src/app/endpoints/public/public.module.ts b/apps/api/src/app/endpoints/public/public.module.ts index 9b43522c1..cf4fd3d18 100644 --- a/apps/api/src/app/endpoints/public/public.module.ts +++ b/apps/api/src/app/endpoints/public/public.module.ts @@ -9,6 +9,7 @@ import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; @@ -25,6 +26,7 @@ import { PublicController } from './public.controller'; controllers: [PublicController], imports: [ AccessModule, + BenchmarkModule, DataProviderModule, ExchangeRateDataModule, ImpersonationModule, diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts index 0d25172c8..2a8ea9875 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts @@ -2,7 +2,9 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; -import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; +import { WatchlistResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; import { RequestWithUser } from '@ghostfolio/common/types'; @@ -11,6 +13,7 @@ import { Controller, Delete, Get, + Headers, HttpException, Inject, Param, @@ -29,13 +32,14 @@ import { WatchlistService } from './watchlist.service'; @Controller('watchlist') export class WatchlistController { public constructor( + private readonly impersonationService: ImpersonationService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly watchlistService: WatchlistService ) {} @Post() @HasPermission(permissions.createWatchlistItem) - @UseGuards(AuthGuard('jwt')) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async createWatchlistItem(@Body() data: CreateWatchlistItemDto) { return this.watchlistService.createWatchlistItem({ @@ -53,13 +57,13 @@ export class WatchlistController { @Param('dataSource') dataSource: DataSource, @Param('symbol') symbol: string ) { - const watchlistItem = await this.watchlistService - .getWatchlistItems(this.request.user.id) - .then((items) => { - return items.find((item) => { - return item.dataSource === dataSource && item.symbol === symbol; - }); - }); + const watchlistItems = await this.watchlistService.getWatchlistItems( + this.request.user.id + ); + + const watchlistItem = watchlistItems.find((item) => { + return item.dataSource === dataSource && item.symbol === symbol; + }); if (!watchlistItem) { throw new HttpException( @@ -79,7 +83,18 @@ export class WatchlistController { @HasPermission(permissions.readWatchlist) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(TransformDataSourceInResponseInterceptor) - public async getWatchlistItems(): Promise { - return this.watchlistService.getWatchlistItems(this.request.user.id); + public async getWatchlistItems( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string + ): Promise { + const impersonationUserId = + await this.impersonationService.validateImpersonationId(impersonationId); + + const watchlist = await this.watchlistService.getWatchlistItems( + impersonationUserId || this.request.user.id + ); + + return { + watchlist + }; } } diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.module.ts b/apps/api/src/app/endpoints/watchlist/watchlist.module.ts index 15115888b..ce9ae12bb 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.module.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.module.ts @@ -1,6 +1,12 @@ import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; +import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { Module } from '@nestjs/common'; @@ -10,7 +16,13 @@ import { WatchlistService } from './watchlist.service'; @Module({ controllers: [WatchlistController], imports: [ + BenchmarkModule, + DataGatheringModule, + DataProviderModule, + ImpersonationModule, + MarketDataModule, PrismaModule, + SymbolProfileModule, TransformDataSourceInRequestModule, TransformDataSourceInResponseModule ], diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts index fdb9dd97a..36a498e1d 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts @@ -1,12 +1,24 @@ +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; -import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { WatchlistResponse } from '@ghostfolio/common/interfaces'; -import { Injectable, NotFoundException } from '@nestjs/common'; -import { DataSource } from '@prisma/client'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { DataSource, Prisma } from '@prisma/client'; @Injectable() export class WatchlistService { - public constructor(private readonly prismaService: PrismaService) {} + public constructor( + private readonly benchmarkService: BenchmarkService, + private readonly dataGatheringService: DataGatheringService, + private readonly dataProviderService: DataProviderService, + private readonly marketDataService: MarketDataService, + private readonly prismaService: PrismaService, + private readonly symbolProfileService: SymbolProfileService + ) {} public async createWatchlistItem({ dataSource, @@ -24,11 +36,26 @@ export class WatchlistService { }); if (!symbolProfile) { - throw new NotFoundException( - `Asset profile not found for ${symbol} (${dataSource})` + const assetProfiles = await this.dataProviderService.getAssetProfiles([ + { dataSource, symbol } + ]); + + if (!assetProfiles[symbol]?.currency) { + throw new BadRequestException( + `Asset profile not found for ${symbol} (${dataSource})` + ); + } + + await this.symbolProfileService.add( + assetProfiles[symbol] as Prisma.SymbolProfileCreateInput ); } + await this.dataGatheringService.gatherSymbol({ + dataSource, + symbol + }); + await this.prismaService.user.update({ data: { watchlist: { @@ -64,7 +91,7 @@ export class WatchlistService { public async getWatchlistItems( userId: string - ): Promise { + ): Promise { const user = await this.prismaService.user.findUnique({ select: { watchlist: { @@ -74,6 +101,50 @@ export class WatchlistService { where: { id: userId } }); - return user.watchlist ?? []; + const [assetProfiles, quotes] = await Promise.all([ + this.symbolProfileService.getSymbolProfiles(user.watchlist), + this.dataProviderService.getQuotes({ + items: user.watchlist.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }) + }) + ]); + + const watchlist = await Promise.all( + user.watchlist.map(async ({ dataSource, symbol }) => { + const assetProfile = assetProfiles.find((profile) => { + return profile.dataSource === dataSource && profile.symbol === symbol; + }); + + const allTimeHigh = await this.marketDataService.getMax({ + dataSource, + symbol + }); + + const performancePercent = + this.benchmarkService.calculateChangeInPercentage( + allTimeHigh?.marketPrice, + quotes[symbol]?.marketPrice + ); + + return { + dataSource, + symbol, + marketCondition: + this.benchmarkService.getMarketCondition(performancePercent), + name: assetProfile?.name, + performances: { + allTimeHigh: { + performancePercent, + date: allTimeHigh?.date + } + } + }; + }) + ); + + return watchlist.sort((a, b) => { + return a.name.localeCompare(b.name); + }); } } diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 0e73e04bb..c78d5b1fe 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -49,8 +49,8 @@ export class ImportService { symbol }: AssetProfileIdentifier): Promise { try { - const { firstBuyDate, historicalData, orders } = - await this.portfolioService.getPosition(dataSource, undefined, symbol); + const { activities, firstBuyDate, historicalData } = + await this.portfolioService.getHolding(dataSource, undefined, symbol); const [[assetProfile], dividends] = await Promise.all([ this.symbolProfileService.getSymbolProfiles([ @@ -68,7 +68,7 @@ export class ImportService { }) ]); - const accounts = orders + const accounts = activities .filter(({ Account }) => { return !!Account; }) @@ -88,7 +88,7 @@ export class ImportService { const value = new Big(quantity).mul(marketPrice).toNumber(); const date = parseDate(dateString); - const isDuplicate = orders.some((activity) => { + const isDuplicate = activities.some((activity) => { return ( activity.accountId === Account?.id && activity.SymbolProfile.currency === assetProfile.currency && @@ -118,6 +118,7 @@ export class ImportService { createdAt: undefined, fee: 0, feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, id: assetProfile.id, isDraft: false, SymbolProfile: assetProfile, @@ -126,7 +127,8 @@ export class ImportService { unitPrice: marketPrice, unitPriceInAssetProfileCurrency: marketPrice, updatedAt: undefined, - userId: Account?.userId + userId: Account?.userId, + valueInBaseCurrency: value }; }) ); @@ -167,9 +169,9 @@ export class ImportService { for (const account of accountsDto) { // Check if there is any existing account with the same ID - const accountWithSameId = existingAccounts.find( - (existingAccount) => existingAccount.id === account.id - ); + const accountWithSameId = existingAccounts.find((existingAccount) => { + return existingAccount.id === account.id; + }); // If there is no account or if the account belongs to a different user then create a new account if (!accountWithSameId || accountWithSameId.userId !== user.id) { diff --git a/apps/api/src/app/order/interfaces/activities.interface.ts b/apps/api/src/app/order/interfaces/activities.interface.ts index 0c25c8ef8..c605c4787 100644 --- a/apps/api/src/app/order/interfaces/activities.interface.ts +++ b/apps/api/src/app/order/interfaces/activities.interface.ts @@ -12,11 +12,13 @@ export interface Activity extends Order { Account?: AccountWithPlatform; error?: ActivityError; feeInAssetProfileCurrency: number; + feeInBaseCurrency: number; SymbolProfile?: EnhancedSymbolProfile; tags?: Tag[]; unitPriceInAssetProfileCurrency: number; updateAccountBalance?: boolean; value: number; + valueInBaseCurrency: number; } export interface ActivityError { diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts index 907335aa0..2c4a58596 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/order/order.controller.ts @@ -97,7 +97,7 @@ export class OrderController { @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getAllOrders( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, @Query('dataSource') filterByDataSource?: string, @@ -150,7 +150,7 @@ export class OrderController { @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getOrderById( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Param('id') id: string ): Promise { const impersonationUserId = diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 73289711e..b322e82ab 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -124,7 +124,7 @@ export class OrderService { userId: string; } ): Promise { - let Account: Prisma.AccountCreateNestedOneWithoutOrderInput; + let Account: Prisma.AccountCreateNestedOneWithoutActivitiesInput; if (data.accountId) { Account = { @@ -581,31 +581,46 @@ export class OrderService { const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); + const [ + feeInAssetProfileCurrency, + feeInBaseCurrency, + unitPriceInAssetProfileCurrency, + valueInBaseCurrency + ] = await Promise.all([ + this.exchangeRateDataService.toCurrencyAtDate( + order.fee, + order.currency ?? order.SymbolProfile.currency, + order.SymbolProfile.currency, + order.date + ), + this.exchangeRateDataService.toCurrencyAtDate( + order.fee, + order.currency ?? order.SymbolProfile.currency, + userCurrency, + order.date + ), + this.exchangeRateDataService.toCurrencyAtDate( + order.unitPrice, + order.currency ?? order.SymbolProfile.currency, + order.SymbolProfile.currency, + order.date + ), + this.exchangeRateDataService.toCurrencyAtDate( + value, + order.currency ?? order.SymbolProfile.currency, + userCurrency, + order.date + ) + ]); + return { ...order, + feeInAssetProfileCurrency, + feeInBaseCurrency, + unitPriceInAssetProfileCurrency, value, - feeInAssetProfileCurrency: - await this.exchangeRateDataService.toCurrencyAtDate( - order.fee, - order.currency ?? order.SymbolProfile.currency, - order.SymbolProfile.currency, - order.date - ), - SymbolProfile: assetProfile, - unitPriceInAssetProfileCurrency: - await this.exchangeRateDataService.toCurrencyAtDate( - order.unitPrice, - order.currency ?? order.SymbolProfile.currency, - order.SymbolProfile.currency, - order.date - ), - valueInBaseCurrency: - await this.exchangeRateDataService.toCurrencyAtDate( - value, - order.currency ?? order.SymbolProfile.currency, - userCurrency, - order.date - ) + valueInBaseCurrency, + SymbolProfile: assetProfile }; }) ); diff --git a/apps/api/src/app/platform/platform.service.ts b/apps/api/src/app/platform/platform.service.ts index db827569d..200b4de00 100644 --- a/apps/api/src/app/platform/platform.service.ts +++ b/apps/api/src/app/platform/platform.service.ts @@ -54,7 +54,7 @@ export class PlatformService { await this.prismaService.platform.findMany({ include: { _count: { - select: { Account: true } + select: { accounts: true } } } }); @@ -64,7 +64,7 @@ export class PlatformService { id, name, url, - accountCount: _count.Account + accountCount: _count.accounts }; }); } 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 2c9f7b6f3..6208eb7d7 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 @@ -7,10 +7,13 @@ export const activityDummyData = { createdAt: new Date(), currency: undefined, fee: undefined, + feeInAssetProfileCurrency: undefined, + feeInBaseCurrency: undefined, id: undefined, isDraft: false, symbolProfileId: undefined, unitPrice: undefined, + unitPriceInAssetProfileCurrency: undefined, updatedAt: new Date(), userId: undefined, value: undefined, diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 295577ead..d576f642b 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -955,8 +955,8 @@ export abstract class PortfolioCalculator { let lastTransactionPoint: TransactionPoint = null; for (const { - fee, date, + fee, quantity, SymbolProfile, tags, 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 b123647c1..1c16ca49d 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts @@ -194,5 +194,83 @@ describe('PortfolioCalculator', () => { { date: '2021-12-01', investment: 0 } ]); }); + + it.only('with BALN.SW buy (with unit price lower than closing price)', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.55, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 135.0 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const snapshotOnBuyDate = portfolioSnapshot.historicalData.find( + ({ date }) => { + return date === '2021-11-30'; + } + ); + + // Closing price on 2021-11-30: 136.6 + expect(snapshotOnBuyDate?.netPerformanceWithCurrencyEffect).toEqual(1.65); // 2 * (136.6 - 135.0) - 1.55 = 1.65 + }); + + it.only('with BALN.SW buy (with unit price lower than closing price), calculated on buy date', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-11-30').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.55, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 135.0 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const snapshotOnBuyDate = portfolioSnapshot.historicalData.find( + ({ date }) => { + return date === '2021-11-30'; + } + ); + + // Closing price on 2021-11-30: 136.6 + expect(snapshotOnBuyDate?.netPerformanceWithCurrencyEffect).toEqual(1.65); // 2 * (136.6 - 135.0) - 1.55 = 1.65 + }); }); }); 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 new file mode 100644 index 000000000..e6ff2fea3 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts @@ -0,0 +1,249 @@ +import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + loadActivityExportFile, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; +import { join } from 'path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let activityDtos: CreateOrderDto[]; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + activityDtos = loadActivityExportFile( + join(__dirname, '../../../../../../../test/import/ok-btceur.json') + ); + }); + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService, + null + ); + }); + + describe('get current positions', () => { + it.only('with BTCUSD buy (in EUR)', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); + + const activities: Activity[] = activityDtos.map((activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: 4.46, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: activity.dataSource, + name: 'Bitcoin', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: 44558.42 + })); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + expect(portfolioSnapshot.historicalData[0]).toEqual({ + date: '2021-12-11', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 0, + timeWeightedPerformanceInPercentage: 0, + timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + /** + * Closing price on 2021-12-12: 50098.3 + */ + expect(portfolioSnapshot.historicalData[1]).toEqual({ + date: '2021-12-12', + investmentValueWithCurrencyEffect: 44558.42, + netPerformance: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42 + netPerformanceInPercentage: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412 + netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412 + netPerformanceWithCurrencyEffect: 5535.42, + netWorth: 50098.3, // 1 * 50098.3 = 50098.3 + timeWeightedPerformanceInPercentage: 0, + timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, + totalAccountBalance: 0, + totalInvestment: 44558.42, + totalInvestmentValueWithCurrencyEffect: 44558.42, + value: 50098.3, // 1 * 50098.3 = 50098.3 + valueWithCurrencyEffect: 50098.3 + }); + + expect( + portfolioSnapshot.historicalData[ + portfolioSnapshot.historicalData.length - 1 + ] + ).toEqual({ + date: '2022-01-14', + investmentValueWithCurrencyEffect: 0, + netPerformance: -1463.18, + netPerformanceInPercentage: -0.032837340282712, + netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712, + netPerformanceWithCurrencyEffect: -1463.18, + netWorth: 43099.7, + timeWeightedPerformanceInPercentage: -0.13969735500006986, + timeWeightedPerformanceInPercentageWithCurrencyEffect: + -0.13969735500006986, + totalAccountBalance: 0, + totalInvestment: 44558.42, + totalInvestmentValueWithCurrencyEffect: 44558.42, + value: 43099.7, + valueWithCurrencyEffect: 43099.7 + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('43099.7'), + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('44558.42'), + currency: 'USD', + dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('4.46'), + feeInBaseCurrency: new Big('4.46'), + firstBuyDate: '2021-12-12', + grossPerformance: new Big('-1458.72'), + grossPerformancePercentage: new Big('-0.03273724696701543726'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '-0.03273724696701543726' + ), + grossPerformanceWithCurrencyEffect: new Big('-1458.72'), + investment: new Big('44558.42'), + investmentWithCurrencyEffect: new Big('44558.42'), + netPerformance: new Big('-1463.18'), + netPerformancePercentage: new Big('-0.03283734028271199921'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('-0.03283734028271199921') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('-1463.18') + }, + marketPrice: 43099.7, + marketPriceInBaseCurrency: 43099.7, + quantity: new Big('1'), + symbol: 'BTCUSD', + tags: [], + timeWeightedInvestment: new Big('44558.42'), + timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'), + transactionCount: 1, + valueInBaseCurrency: new Big('43099.7') + } + ], + totalFeesWithCurrencyEffect: new Big('4.46'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('44558.42'), + totalInvestmentWithCurrencyEffect: new Big('44558.42'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(investments).toEqual([ + { date: '2021-12-12', investment: new Big('44558.42') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2021-12-01', investment: 44558.42 }, + { date: '2022-01-01', investment: 0 } + ]); + }); + }); +}); 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 new file mode 100644 index 000000000..f5199bbfa --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts @@ -0,0 +1,253 @@ +import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { + activityDummyData, + loadActivityExportFile, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; +import { join } from 'path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let activityDtos: CreateOrderDto[]; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + let orderService: OrderService; + + beforeAll(() => { + activityDtos = loadActivityExportFile( + join(__dirname, '../../../../../../../test/import/ok-btcusd.json') + ); + }); + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + orderService = new OrderService(null, null, null, null, null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService, + orderService + ); + }); + + describe('get current positions', () => { + it.only('with BTCUSD buy (in USD)', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); + + const activities: Activity[] = activityDtos.map((activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: 4.46, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: activity.dataSource, + name: 'Bitcoin', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: 44558.42 + })); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + expect(portfolioSnapshot.historicalData[0]).toEqual({ + date: '2021-12-11', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 0, + timeWeightedPerformanceInPercentage: 0, + timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + /** + * Closing price on 2021-12-12: 50098.3 + */ + expect(portfolioSnapshot.historicalData[1]).toEqual({ + date: '2021-12-12', + investmentValueWithCurrencyEffect: 44558.42, + netPerformance: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42 + netPerformanceInPercentage: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412 + netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412 + netPerformanceWithCurrencyEffect: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42 + netWorth: 50098.3, // 1 * 50098.3 = 50098.3 + timeWeightedPerformanceInPercentage: 0, + timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, + totalAccountBalance: 0, + totalInvestment: 44558.42, + totalInvestmentValueWithCurrencyEffect: 44558.42, + value: 50098.3, // 1 * 50098.3 = 50098.3 + valueWithCurrencyEffect: 50098.3 + }); + + expect( + portfolioSnapshot.historicalData[ + portfolioSnapshot.historicalData.length - 1 + ] + ).toEqual({ + date: '2022-01-14', + investmentValueWithCurrencyEffect: 0, + netPerformance: -1463.18, + netPerformanceInPercentage: -0.032837340282712, + netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712, + netPerformanceWithCurrencyEffect: -1463.18, + netWorth: 43099.7, + timeWeightedPerformanceInPercentage: -0.13969735500006986, + timeWeightedPerformanceInPercentageWithCurrencyEffect: + -0.13969735500006986, + totalAccountBalance: 0, + totalInvestment: 44558.42, + totalInvestmentValueWithCurrencyEffect: 44558.42, + value: 43099.7, + valueWithCurrencyEffect: 43099.7 + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('43099.7'), + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('44558.42'), + currency: 'USD', + dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('4.46'), + feeInBaseCurrency: new Big('4.46'), + firstBuyDate: '2021-12-12', + grossPerformance: new Big('-1458.72'), + grossPerformancePercentage: new Big('-0.03273724696701543726'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '-0.03273724696701543726' + ), + grossPerformanceWithCurrencyEffect: new Big('-1458.72'), + investment: new Big('44558.42'), + investmentWithCurrencyEffect: new Big('44558.42'), + netPerformance: new Big('-1463.18'), + netPerformancePercentage: new Big('-0.03283734028271199921'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('-0.03283734028271199921') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('-1463.18') + }, + marketPrice: 43099.7, + marketPriceInBaseCurrency: 43099.7, + quantity: new Big('1'), + symbol: 'BTCUSD', + tags: [], + timeWeightedInvestment: new Big('44558.42'), + timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'), + transactionCount: 1, + valueInBaseCurrency: new Big('43099.7') + } + ], + totalFeesWithCurrencyEffect: new Big('4.46'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('44558.42'), + totalInvestmentWithCurrencyEffect: new Big('44558.42'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(investments).toEqual([ + { date: '2021-12-12', investment: new Big('44558.42') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2021-12-01', investment: 44558.42 }, + { date: '2022-01-01', investment: 0 } + ]); + }); + }); +}); 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 2dcb12753..a4095cde7 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -11,7 +11,6 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { parseDate } from '@ghostfolio/common/helper'; @@ -49,18 +48,6 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { }; }); -jest.mock( - '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', - () => { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - ExchangeRateDataService: jest.fn().mockImplementation(() => { - return ExchangeRateDataServiceMock; - }) - }; - } -); - describe('PortfolioCalculator', () => { let configurationService: ConfigurationService; let currentRateService: CurrentRateService; 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 0c691f7b2..cb28d7460 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -148,21 +148,25 @@ describe('PortfolioCalculator', () => { 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: 0, - netPerformanceInPercentage: 0, - netPerformanceInPercentageWithCurrencyEffect: 0, - netPerformanceWithCurrencyEffect: 0, + netPerformance: 24, + netPerformanceInPercentage: 0.158311345646438, + netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, + netPerformanceWithCurrencyEffect: 24, timeWeightedPerformanceInPercentage: 0, timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, - netWorth: 151.6, + netWorth: 175.6, totalAccountBalance: 0, totalInvestment: 151.6, totalInvestmentValueWithCurrencyEffect: 151.6, - value: 151.6, - valueWithCurrencyEffect: 151.6 + value: 175.6, // 2 * 87.8 = 175.6 + valueWithCurrencyEffect: 175.6 }); expect( @@ -176,8 +180,9 @@ describe('PortfolioCalculator', () => { netPerformanceInPercentage: 0.13100263852242744, netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, netPerformanceWithCurrencyEffect: 19.86, - timeWeightedPerformanceInPercentage: 0.13100263852242744, - timeWeightedPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, + timeWeightedPerformanceInPercentage: -0.02357630979498861, + timeWeightedPerformanceInPercentageWithCurrencyEffect: + -0.02357630979498861, netWorth: 0, totalAccountBalance: 0, totalInvestment: 0, 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 86b2e8471..f0f01425a 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -458,12 +458,19 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { ); } + const marketPriceInBaseCurrency = + order.unitPriceFromMarketData?.mul(currentExchangeRate ?? 1) ?? + new Big(0); + const marketPriceInBaseCurrencyWithCurrencyEffect = + order.unitPriceFromMarketData?.mul(exchangeRateAtOrderDate ?? 1) ?? + new Big(0); + const valueOfInvestmentBeforeTransaction = totalUnits.mul( - order.unitPriceInBaseCurrency + marketPriceInBaseCurrency ); const valueOfInvestmentBeforeTransactionWithCurrencyEffect = - totalUnits.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect); + totalUnits.mul(marketPriceInBaseCurrencyWithCurrencyEffect); if (!investmentAtStartDate && i >= indexOfStartOrder) { investmentAtStartDate = totalInvestment ?? new Big(0); @@ -560,10 +567,10 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type))); - const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency); + const valueOfInvestment = totalUnits.mul(marketPriceInBaseCurrency); const valueOfInvestmentWithCurrencyEffect = totalUnits.mul( - order.unitPriceInBaseCurrencyWithCurrencyEffect + marketPriceInBaseCurrencyWithCurrencyEffect ); const grossPerformanceFromSell = @@ -703,17 +710,23 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { investmentValuesWithCurrencyEffect[order.date] ?? new Big(0) ).add(transactionInvestmentWithCurrencyEffect); + // If duration is effectively zero (first day), use the actual investment as the base. + // Otherwise, use the calculated time-weighted average. timeWeightedInvestmentValues[order.date] = - totalInvestmentDays > 0 + totalInvestmentDays > Number.EPSILON ? sumOfTimeWeightedInvestments.div(totalInvestmentDays) - : new Big(0); + : totalInvestment.gt(0) + ? totalInvestment + : new Big(0); timeWeightedInvestmentValuesWithCurrencyEffect[order.date] = - totalInvestmentDays > 0 + totalInvestmentDays > Number.EPSILON ? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div( totalInvestmentDays ) - : new Big(0); + : totalInvestmentWithCurrencyEffect.gt(0) + ? totalInvestmentWithCurrencyEffect + : new Big(0); } if (PortfolioCalculator.ENABLE_LOGGING) { diff --git a/apps/api/src/app/portfolio/current-rate.service.mock.ts b/apps/api/src/app/portfolio/current-rate.service.mock.ts index fab25ae2d..4b4b8f00e 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -47,6 +47,10 @@ function mockGetValue(symbol: string, date: Date) { return { marketPrice: 14156.4 }; } else if (isSameDay(parseDate('2018-01-01'), date)) { return { marketPrice: 13657.2 }; + } else if (isSameDay(parseDate('2021-12-12'), date)) { + return { marketPrice: 50098.3 }; + } else if (isSameDay(parseDate('2022-01-14'), date)) { + return { marketPrice: 43099.7 }; } return { marketPrice: 0 }; diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 0dda4dd3b..c8547600b 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -23,6 +23,7 @@ import { import { PortfolioDetails, PortfolioDividends, + PortfolioHoldingResponse, PortfolioHoldingsResponse, PortfolioInvestments, PortfolioPerformanceResponse, @@ -59,7 +60,6 @@ import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; import { Big } from 'big.js'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; -import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface'; import { PortfolioService } from './portfolio.service'; import { UpdateHoldingTagsDto } from './update-holding-tags.dto'; @@ -386,6 +386,32 @@ export class PortfolioController { return { dividends }; } + @Get('holding/:dataSource/:symbol') + @UseInterceptors(RedactValuesInResponseInterceptor) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getHolding( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + const holding = await this.portfolioService.getHolding( + dataSource, + impersonationId, + symbol + ); + + if (!holding) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return holding; + } + @Get('holdings') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(RedactValuesInResponseInterceptor) @@ -611,6 +637,9 @@ export class PortfolioController { return performanceInformation; } + /** + * @deprecated + */ @Get('position/:dataSource/:symbol') @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @@ -620,8 +649,8 @@ export class PortfolioController { @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Param('dataSource') dataSource: DataSource, @Param('symbol') symbol: string - ): Promise { - const holding = await this.portfolioService.getPosition( + ): Promise { + const holding = await this.portfolioService.getHolding( dataSource, impersonationId, symbol @@ -662,7 +691,7 @@ export class PortfolioController { } @HasPermission(permissions.updateOrder) - @Put('position/:dataSource/:symbol/tags') + @Put('holding/:dataSource/:symbol/tags') @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async updateHoldingTags( @@ -671,7 +700,42 @@ export class PortfolioController { @Param('dataSource') dataSource: DataSource, @Param('symbol') symbol: string ): Promise { - const holding = await this.portfolioService.getPosition( + const holding = await this.portfolioService.getHolding( + dataSource, + impersonationId, + symbol + ); + + if (!holding) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + await this.portfolioService.updateTags({ + dataSource, + impersonationId, + symbol, + tags: data.tags, + userId: this.request.user.id + }); + } + + /** + * @deprecated + */ + @HasPermission(permissions.updateOrder) + @Put('position/:dataSource/:symbol/tags') + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updatePositionTags( + @Body() data: UpdateHoldingTagsDto, + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + const holding = await this.portfolioService.getHolding( dataSource, impersonationId, symbol diff --git a/apps/api/src/app/portfolio/portfolio.module.ts b/apps/api/src/app/portfolio/portfolio.module.ts index 7ae74ee5f..0f64b2f6a 100644 --- a/apps/api/src/app/portfolio/portfolio.module.ts +++ b/apps/api/src/app/portfolio/portfolio.module.ts @@ -9,6 +9,7 @@ import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redac import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; @@ -33,6 +34,7 @@ import { RulesService } from './rules.service'; imports: [ AccessModule, ApiModule, + BenchmarkModule, ConfigurationModule, DataGatheringModule, DataProviderModule, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 3cfa7ae83..34956a96b 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -21,6 +21,7 @@ import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe'; import { RegionalMarketClusterRiskJapan } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/japan'; import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/north-america'; +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; @@ -36,12 +37,13 @@ import { } from '@ghostfolio/common/config'; import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper'; import { - Accounts, + AccountsResponse, EnhancedSymbolProfile, Filter, HistoricalDataItem, InvestmentItem, PortfolioDetails, + PortfolioHoldingResponse, PortfolioInvestments, PortfolioPerformanceResponse, PortfolioPosition, @@ -88,7 +90,6 @@ import { isEmpty, uniqBy } from 'lodash'; import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; -import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface'; import { RulesService } from './rules.service'; const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json'); @@ -101,6 +102,7 @@ export class PortfolioService { public constructor( private readonly accountBalanceService: AccountBalanceService, private readonly accountService: AccountService, + private readonly benchmarkService: BenchmarkService, private readonly calculatorFactory: PortfolioCalculatorFactory, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, @@ -141,7 +143,7 @@ export class PortfolioService { } if (filterByDataSource && filterBySymbol) { - where.Order = { + where.activities = { some: { SymbolProfile: { AND: [ @@ -156,7 +158,7 @@ export class PortfolioService { const [accounts, details] = await Promise.all([ this.accountService.accounts({ where, - include: { Order: true, Platform: true }, + include: { activities: true, Platform: true }, orderBy: { name: 'asc' } }), this.getDetails({ @@ -172,8 +174,8 @@ export class PortfolioService { return accounts.map((account) => { let transactionCount = 0; - for (const order of account.Order) { - if (!order.isDraft) { + for (const { isDraft } of account.activities) { + if (!isDraft) { transactionCount += 1; } } @@ -197,7 +199,7 @@ export class PortfolioService { ) }; - delete result.Order; + delete result.activities; return result; }); @@ -212,7 +214,7 @@ export class PortfolioService { filters?: Filter[]; userId: string; withExcludedAccounts?: boolean; - }): Promise { + }): Promise { const accounts = await this.getAccounts({ filters, userId, @@ -644,11 +646,11 @@ export class PortfolioService { } @LogPerformance - public async getPosition( + public async getHolding( aDataSource: DataSource, aImpersonationId: string, aSymbol: string - ): Promise { + ): Promise { const userId = await this.getUserId(aImpersonationId, this.request.user.id); const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); @@ -661,6 +663,7 @@ export class PortfolioService { if (activities.length === 0) { return { + activities: [], averagePrice: undefined, dataProviderInfo: undefined, stakeRewards: undefined, @@ -676,13 +679,13 @@ export class PortfolioService { historicalData: [], investment: undefined, marketPrice: undefined, - maxPrice: undefined, - minPrice: undefined, + marketPriceMax: undefined, + marketPriceMin: undefined, netPerformance: undefined, netPerformancePercent: undefined, netPerformancePercentWithCurrencyEffect: undefined, netPerformanceWithCurrencyEffect: undefined, - orders: [], + performances: undefined, quantity: undefined, SymbolProfile: undefined, tags: [], @@ -728,7 +731,7 @@ export class PortfolioService { transactionCount } = position; - const activitiesOfPosition = activities.filter(({ SymbolProfile }) => { + const activitiesOfHolding = activities.filter(({ SymbolProfile }) => { return ( SymbolProfile.dataSource === dataSource && SymbolProfile.symbol === symbol @@ -772,8 +775,18 @@ export class PortfolioService { ); const historicalDataArray: HistoricalDataItem[] = []; - let maxPrice = Math.max(activitiesOfPosition[0].unitPrice, marketPrice); - let minPrice = Math.min(activitiesOfPosition[0].unitPrice, marketPrice); + let marketPriceMax = Math.max( + activitiesOfHolding[0].unitPriceInAssetProfileCurrency, + marketPrice + ); + let marketPriceMaxDate = + marketPrice > activitiesOfHolding[0].unitPriceInAssetProfileCurrency + ? new Date() + : activitiesOfHolding[0].date; + let marketPriceMin = Math.min( + activitiesOfHolding[0].unitPriceInAssetProfileCurrency, + marketPrice + ); if (historicalData[aSymbol]) { let j = -1; @@ -811,27 +824,40 @@ export class PortfolioService { quantity: currentQuantity }); - maxPrice = Math.max(marketPrice ?? 0, maxPrice); - minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice); + if (marketPrice > marketPriceMax) { + marketPriceMax = marketPrice; + marketPriceMaxDate = parseISO(date); + } + marketPriceMin = Math.min( + marketPrice ?? Number.MAX_SAFE_INTEGER, + marketPriceMin + ); } } else { // Add historical entry for buy date, if no historical data available historicalDataArray.push({ - averagePrice: activitiesOfPosition[0].unitPrice, + averagePrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency, date: firstBuyDate, - marketPrice: activitiesOfPosition[0].unitPrice, - quantity: activitiesOfPosition[0].quantity + marketPrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency, + quantity: activitiesOfHolding[0].quantity }); } + const performancePercent = + this.benchmarkService.calculateChangeInPercentage( + marketPriceMax, + marketPrice + ); + return { firstBuyDate, marketPrice, - maxPrice, - minPrice, + marketPriceMax, + marketPriceMin, SymbolProfile, tags, transactionCount, + activities: activitiesOfHolding, averagePrice: averagePrice.toNumber(), dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], stakeRewards: stakeRewards.toNumber(), @@ -861,7 +887,12 @@ export class PortfolioService { ]?.toNumber(), netPerformanceWithCurrencyEffect: position.netPerformanceWithCurrencyEffectMap?.['max']?.toNumber(), - orders: activitiesOfPosition, + performances: { + allTimeHigh: { + performancePercent, + date: marketPriceMaxDate + } + }, quantity: quantity.toNumber(), value: this.exchangeRateDataService.toCurrency( quantity.mul(marketPrice ?? 0).toNumber(), @@ -900,8 +931,9 @@ export class PortfolioService { } const historicalDataArray: HistoricalDataItem[] = []; - let maxPrice = marketPrice; - let minPrice = marketPrice; + let marketPriceMax = marketPrice; + let marketPriceMaxDate = new Date(); + let marketPriceMin = marketPrice; for (const [date, { marketPrice }] of Object.entries( historicalData[aSymbol] @@ -911,15 +943,28 @@ export class PortfolioService { value: marketPrice }); - maxPrice = Math.max(marketPrice ?? 0, maxPrice); - minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice); + if (marketPrice > marketPriceMax) { + marketPriceMax = marketPrice; + marketPriceMaxDate = parseISO(date); + } + marketPriceMin = Math.min( + marketPrice ?? Number.MAX_SAFE_INTEGER, + marketPriceMin + ); } + const performancePercent = + this.benchmarkService.calculateChangeInPercentage( + marketPriceMax, + marketPrice + ); + return { marketPrice, - maxPrice, - minPrice, + marketPriceMax, + marketPriceMin, SymbolProfile, + activities: [], averagePrice: 0, dataProviderInfo: undefined, stakeRewards: 0, @@ -938,7 +983,12 @@ export class PortfolioService { netPerformancePercent: undefined, netPerformancePercentWithCurrencyEffect: undefined, netPerformanceWithCurrencyEffect: undefined, - orders: [], + performances: { + allTimeHigh: { + performancePercent, + date: marketPriceMaxDate + } + }, quantity: 0, tags: [], transactionCount: undefined, @@ -948,7 +998,7 @@ export class PortfolioService { } @LogPerformance - public async getPositions({ + public async getHoldings({ dateRange = 'max', filters, impersonationId @@ -1214,7 +1264,7 @@ export class PortfolioService { const rules: PortfolioReportResponse['rules'] = { accountClusterRisk: - summary.ordersCount > 0 + summary.activityCount > 0 ? await this.rulesService.evaluate( [ new AccountClusterRiskCurrentInvestment( @@ -1230,7 +1280,7 @@ export class PortfolioService { ) : undefined, assetClassClusterRisk: - summary.ordersCount > 0 + summary.activityCount > 0 ? await this.rulesService.evaluate( [ new AssetClassClusterRiskEquity( @@ -1246,7 +1296,7 @@ export class PortfolioService { ) : undefined, currencyClusterRisk: - summary.ordersCount > 0 + summary.activityCount > 0 ? await this.rulesService.evaluate( [ new CurrencyClusterRiskBaseCurrencyCurrentInvestment( @@ -1262,7 +1312,7 @@ export class PortfolioService { ) : undefined, economicMarketClusterRisk: - summary.ordersCount > 0 + summary.activityCount > 0 ? await this.rulesService.evaluate( [ new EconomicMarketClusterRiskDevelopedMarkets( @@ -1303,7 +1353,7 @@ export class PortfolioService { userSettings ), regionalMarketClusterRisk: - summary.ordersCount > 0 + summary.activityCount > 0 ? await this.rulesService.evaluate( [ new RegionalMarketClusterRiskAsiaPacific( @@ -1625,6 +1675,9 @@ export class PortfolioService { netPerformanceWithCurrencyEffect, totalBuy, totalSell, + activityCount: activities.filter(({ type }) => { + return ['BUY', 'SELL'].includes(type); + }).length, committedFunds: committedFunds.toNumber(), currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), @@ -1652,9 +1705,6 @@ export class PortfolioService { interest: interest.toNumber(), items: valuables.toNumber(), liabilities: liabilities.toNumber(), - ordersCount: activities.filter(({ type }) => { - return ['BUY', 'SELL'].includes(type); - }).length, totalInvestment: totalInvestment.toNumber(), totalValueInBaseCurrency: netWorth }; 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 5411309bd..d0e3228b7 100644 --- a/apps/api/src/app/redis-cache/redis-cache.module.ts +++ b/apps/api/src/app/redis-cache/redis-cache.module.ts @@ -1,17 +1,16 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { createKeyv } from '@keyv/redis'; import { CacheModule } from '@nestjs/cache-manager'; import { Module } from '@nestjs/common'; -import { redisStore } from 'cache-manager-redis-yet'; -import type { RedisClientOptions } from 'redis'; import { RedisCacheService } from './redis-cache.service'; @Module({ exports: [RedisCacheService], imports: [ - CacheModule.registerAsync({ + CacheModule.registerAsync({ imports: [ConfigurationModule], inject: [ConfigurationService], useFactory: async (configurationService: ConfigurationService) => { @@ -20,10 +19,13 @@ import { RedisCacheService } from './redis-cache.service'; ); return { - store: redisStore, - ttl: configurationService.get('CACHE_TTL'), - url: `redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}` - } as RedisClientOptions; + stores: [ + createKeyv( + `redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}` + ) + ], + ttl: configurationService.get('CACHE_TTL') + }; } }), ConfigurationModule diff --git a/apps/api/src/app/redis-cache/redis-cache.service.ts b/apps/api/src/app/redis-cache/redis-cache.service.ts index 51db93ec6..97d71ae61 100644 --- a/apps/api/src/app/redis-cache/redis-cache.service.ts +++ b/apps/api/src/app/redis-cache/redis-cache.service.ts @@ -2,20 +2,18 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, Filter } from '@ghostfolio/common/interfaces'; -import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager'; import { Inject, Injectable, Logger } from '@nestjs/common'; -import { Milliseconds } from 'cache-manager'; -import { RedisCache } from 'cache-manager-redis-yet'; import { createHash } from 'crypto'; import ms from 'ms'; @Injectable() export class RedisCacheService { public constructor( - @Inject(CACHE_MANAGER) private readonly cache: RedisCache, + @Inject(CACHE_MANAGER) private readonly cache: Cache, private readonly configurationService: ConfigurationService ) { - const client = cache.store.client; + const client = cache.stores[0]; client.on('error', (error) => { Logger.error(error, 'RedisCacheService'); @@ -27,13 +25,33 @@ export class RedisCacheService { } public async getKeys(aPrefix?: string): Promise { - let prefix = aPrefix; - - if (prefix) { - prefix = `${prefix}*`; + const keys: string[] = []; + const prefix = aPrefix; + + this.cache.stores[0].deserialize = (value) => { + try { + return JSON.parse(value); + } catch (error: any) { + if (error instanceof SyntaxError) { + Logger.debug( + `Failed to parse json, returning the value as String: ${value}`, + 'RedisCacheService' + ); + + return value; + } else { + throw error; + } + } + }; + + for await (const [key] of this.cache.stores[0].iterator({})) { + if ((prefix && key.startsWith(prefix)) || !prefix) { + keys.push(key); + } } - return this.cache.store.keys(prefix); + return keys; } public getPortfolioSnapshotKey({ @@ -62,10 +80,8 @@ export class RedisCacheService { public async isHealthy() { try { - const client = this.cache.store.client; - const isHealthy = await Promise.race([ - client.ping(), + this.getKeys(), new Promise((_, reject) => setTimeout( () => reject(new Error('Redis health check timeout')), @@ -93,16 +109,14 @@ export class RedisCacheService { `${this.getPortfolioSnapshotKey({ userId })}` ); - for (const key of keys) { - await this.remove(key); - } + return this.cache.mdel(keys); } public async reset() { - return this.cache.reset(); + return this.cache.clear(); } - public async set(key: string, value: string, ttl?: Milliseconds) { + public async set(key: string, value: string, ttl?: number) { return this.cache.set( key, value, diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index b6b31dada..87c82fa0b 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -356,18 +356,20 @@ export class UserService { new Date(), user.createdAt ); - let frequency = 10; + let frequency = 7; - if (daysSinceRegistration > 365) { + if (daysSinceRegistration > 720) { + frequency = 1; + } else if (daysSinceRegistration > 360) { frequency = 2; } else if (daysSinceRegistration > 180) { frequency = 3; } else if (daysSinceRegistration > 60) { frequency = 4; } else if (daysSinceRegistration > 30) { - frequency = 6; + frequency = 5; } else if (daysSinceRegistration > 15) { - frequency = 8; + frequency = 6; } if (Analytics?.activityCount % frequency === 1) { @@ -380,6 +382,7 @@ export class UserService { permissions.createAccess, permissions.createMarketDataOfOwnAssetProfile, permissions.createOwnTag, + permissions.createWatchlistItem, permissions.readAiPrompt, permissions.readMarketDataOfOwnAssetProfile, permissions.updateMarketDataOfOwnAssetProfile @@ -391,9 +394,11 @@ export class UserService { // Reset holdings view mode user.Settings.settings.holdingsViewMode = undefined; } else if (user.subscription?.type === 'Premium') { - currentPermissions.push(permissions.createApiKey); - currentPermissions.push(permissions.enableDataProviderGhostfolio); - currentPermissions.push(permissions.reportDataGlitch); + if (!hasRole(user, Role.DEMO)) { + currentPermissions.push(permissions.createApiKey); + currentPermissions.push(permissions.enableDataProviderGhostfolio); + currentPermissions.push(permissions.reportDataGlitch); + } currentPermissions = without( currentPermissions, diff --git a/apps/api/src/assets/sitemap.xml b/apps/api/src/assets/sitemap.xml index fc1e89dba..2343f6c01 100644 --- a/apps/api/src/assets/sitemap.xml +++ b/apps/api/src/assets/sitemap.xml @@ -590,11 +590,9 @@ ${currentDate}T00:00:00+00:00 --> - ${personalFinanceTools} diff --git a/apps/api/src/helper/object.helper.spec.ts b/apps/api/src/helper/object.helper.spec.ts index b0370fa3f..d7caf9bc9 100644 --- a/apps/api/src/helper/object.helper.spec.ts +++ b/apps/api/src/helper/object.helper.spec.ts @@ -1515,6 +1515,7 @@ describe('redactAttributes', () => { } }, summary: { + activityCount: 29, annualizedPerformancePercent: 0.16690880197786, annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876, cash: null, @@ -1538,7 +1539,6 @@ describe('redactAttributes', () => { interest: null, items: null, liabilities: null, - ordersCount: 29, totalInvestment: null, totalValueInBaseCurrency: null, currentNetWorth: null @@ -3018,6 +3018,7 @@ describe('redactAttributes', () => { } }, summary: { + activityCount: 29, annualizedPerformancePercent: 0.16690880197786, annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876, cash: null, @@ -3041,7 +3042,6 @@ describe('redactAttributes', () => { interest: null, items: null, liabilities: null, - ordersCount: 29, totalInvestment: null, totalValueInBaseCurrency: null, currentNetWorth: null diff --git a/apps/api/src/models/interfaces/portfolio.interface.ts b/apps/api/src/models/interfaces/portfolio.interface.ts deleted file mode 100644 index b369202cd..000000000 --- a/apps/api/src/models/interfaces/portfolio.interface.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { PortfolioItem, Position } from '@ghostfolio/common/interfaces'; - -import { Order } from '../order'; - -export interface PortfolioInterface { - get(aDate?: Date): PortfolioItem[]; - - getFees(): number; - - getPositions(aDate: Date): { - [symbol: string]: Position; - }; - - getSymbols(aDate?: Date): string[]; - - getTotalBuy(): number; - - getTotalSell(): number; - - getOrders(): Order[]; - - getValue(aDate?: Date): number; -} diff --git a/apps/api/src/models/order.ts b/apps/api/src/models/order.ts deleted file mode 100644 index 6e6762101..000000000 --- a/apps/api/src/models/order.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces'; - -import { Account, SymbolProfile, Type as ActivityType } from '@prisma/client'; -import { v4 as uuidv4 } from 'uuid'; - -export class Order { - private account: Account; - private currency: string; - private fee: number; - private date: string; - private id: string; - private isDraft: boolean; - private quantity: number; - private symbol: string; - private symbolProfile: SymbolProfile; - private total: number; - private type: ActivityType; - private unitPrice: number; - - public constructor(data: IOrder) { - this.account = data.account; - this.currency = data.currency; - this.fee = data.fee; - this.date = data.date; - this.id = data.id || uuidv4(); - this.isDraft = data.isDraft; - this.quantity = data.quantity; - this.symbol = data.symbol; - this.symbolProfile = data.symbolProfile; - this.type = data.type; - this.unitPrice = data.unitPrice; - - this.total = this.quantity * data.unitPrice; - } - - public getAccount() { - return this.account; - } - - public getCurrency() { - return this.currency; - } - - public getDate() { - return this.date; - } - - public getFee() { - return this.fee; - } - - public getId() { - return this.id; - } - - public getIsDraft() { - return this.isDraft; - } - - public getQuantity() { - return this.quantity; - } - - public getSymbol() { - return this.symbol; - } - - getSymbolProfile() { - return this.symbolProfile; - } - - public getTotal() { - return this.total; - } - - public getType() { - return this.type; - } - - public getUnitPrice() { - return this.unitPrice; - } -} diff --git a/apps/api/src/services/benchmark/benchmark.service.ts b/apps/api/src/services/benchmark/benchmark.service.ts index 95cb9e5d2..f37f26bfc 100644 --- a/apps/api/src/services/benchmark/benchmark.service.ts +++ b/apps/api/src/services/benchmark/benchmark.service.ts @@ -212,6 +212,18 @@ export class BenchmarkService { }; } + public getMarketCondition( + aPerformanceInPercent: number + ): Benchmark['marketCondition'] { + if (aPerformanceInPercent >= 0) { + return 'ALL_TIME_HIGH'; + } else if (aPerformanceInPercent <= -0.2) { + return 'BEAR_MARKET'; + } else { + return 'NEUTRAL_MARKET'; + } + } + private async calculateAndCacheBenchmarks({ enableSharing = false }): Promise { @@ -302,16 +314,4 @@ export class BenchmarkService { return benchmarks; } - - private getMarketCondition( - aPerformanceInPercent: number - ): Benchmark['marketCondition'] { - if (aPerformanceInPercent >= 0) { - return 'ALL_TIME_HIGH'; - } else if (aPerformanceInPercent <= -0.2) { - return 'BEAR_MARKET'; - } else { - return 'NEUTRAL_MARKET'; - } - } } diff --git a/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts b/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts index f9593f0d0..1e8f7eefa 100644 --- a/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts +++ b/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts @@ -52,6 +52,7 @@ export class AlphaVantageService implements DataProviderInterface { public getDataProviderInfo(): DataProviderInfo { return { + dataSource: DataSource.ALPHA_VANTAGE, isPremium: false, name: 'Alpha Vantage', url: 'https://www.alphavantage.co' diff --git a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts index d53355b9c..7776ff46c 100644 --- a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts +++ b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts @@ -92,6 +92,7 @@ export class CoinGeckoService implements DataProviderInterface { public getDataProviderInfo(): DataProviderInfo { return { + dataSource: DataSource.COINGECKO, isPremium: false, name: 'CoinGecko', url: 'https://coingecko.com' diff --git a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts index 64bbeebb5..94a466742 100644 --- a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts @@ -18,11 +18,13 @@ import { } from '@prisma/client'; import { isISIN } from 'class-validator'; import { countries } from 'countries-list'; -import yahooFinance from 'yahoo-finance2'; -import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface'; +import YahooFinance from 'yahoo-finance2'; +import type { Price } from 'yahoo-finance2/esm/src/modules/quoteSummary-iface'; @Injectable() export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { + private readonly yahooFinance = new YahooFinance(); + public constructor( private readonly cryptocurrencyService: CryptocurrencyService ) {} @@ -99,8 +101,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { if (response.dataSource === 'YAHOO') { yahooSymbol = symbol; } else { - const { quotes } = await yahooFinance.search(response.isin); - yahooSymbol = quotes[0].symbol; + const { quotes } = await this.yahooFinance.search(response.isin); + yahooSymbol = quotes[0].symbol as string; } const { countries, sectors, url } = @@ -165,10 +167,10 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { if (isISIN(symbol)) { try { - const { quotes } = await yahooFinance.search(symbol); + const { quotes } = await this.yahooFinance.search(symbol); if (quotes?.[0]?.symbol) { - symbol = quotes[0].symbol; + symbol = quotes[0].symbol as string; } } catch {} } else if (symbol?.endsWith(`-${DEFAULT_CURRENCY}`)) { @@ -177,7 +179,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { symbol = this.convertToYahooFinanceSymbol(symbol); } - const assetProfile = await yahooFinance.quoteSummary(symbol, { + const assetProfile = await this.yahooFinance.quoteSummary(symbol, { modules: ['price', 'summaryProfile', 'topHoldings'] }); @@ -206,7 +208,10 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { for (const sectorWeighting of assetProfile.topHoldings ?.sectorWeightings ?? []) { for (const [sector, weight] of Object.entries(sectorWeighting)) { - response.sectors.push({ weight, name: this.parseSector(sector) }); + response.sectors.push({ + name: this.parseSector(sector), + weight: weight as number + }); } } } else if ( 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 3d45308aa..24e26e748 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -27,6 +27,7 @@ import { LookupItem, LookupResponse } from '@ghostfolio/common/interfaces'; +import { hasRole } from '@ghostfolio/common/permissions'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import { Inject, Injectable, Logger } from '@nestjs/common'; @@ -170,6 +171,7 @@ export class DataProviderService { let dataSourcesKey: 'DATA_SOURCES' | 'DATA_SOURCES_LEGACY' = 'DATA_SOURCES'; if ( + !hasRole(user, 'ADMIN') && isBefore(user.createdAt, new Date('2025-03-23')) && this.configurationService.get('DATA_SOURCES_LEGACY')?.length > 0 ) { @@ -186,7 +188,7 @@ export class DataProviderService { PROPERTY_API_KEY_GHOSTFOLIO )) as string; - if (ghostfolioApiKey) { + if (ghostfolioApiKey || hasRole(user, 'ADMIN')) { dataSources.push('GHOSTFOLIO'); } @@ -693,6 +695,7 @@ export class DataProviderService { lookupItem.dataProviderInfo.isPremium = false; } + lookupItem.dataProviderInfo.dataSource = undefined; lookupItem.dataProviderInfo.name = undefined; lookupItem.dataProviderInfo.url = undefined; } else { diff --git a/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts index 376b8f159..ddb94bb1a 100644 --- a/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts +++ b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts @@ -68,6 +68,7 @@ export class EodHistoricalDataService implements DataProviderInterface { public getDataProviderInfo(): DataProviderInfo { return { + dataSource: DataSource.EOD_HISTORICAL_DATA, isPremium: true, name: 'EOD Historical Data', url: 'https://eodhd.com' diff --git a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts index 4e42201d0..d0e674c4d 100644 --- a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts +++ b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts @@ -223,6 +223,7 @@ export class FinancialModelingPrepService implements DataProviderInterface { public getDataProviderInfo(): DataProviderInfo { return { + dataSource: DataSource.FINANCIAL_MODELING_PREP, isPremium: true, name: 'Financial Modeling Prep', url: 'https://financialmodelingprep.com/developer/docs' diff --git a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts index 097464e2f..90354ace5 100644 --- a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts +++ b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts @@ -92,9 +92,10 @@ export class GhostfolioService implements DataProviderInterface { public getDataProviderInfo(): DataProviderInfo { return { + dataSource: DataSource.GHOSTFOLIO, isPremium: true, name: 'Ghostfolio', - url: 'https://ghostfo.io' + url: 'https://ghostfol.io' }; } diff --git a/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts b/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts index 0c466972d..f067f042c 100644 --- a/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts +++ b/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts @@ -47,6 +47,7 @@ export class GoogleSheetsService implements DataProviderInterface { public getDataProviderInfo(): DataProviderInfo { return { + dataSource: DataSource.GOOGLE_SHEETS, isPremium: false, name: 'Google Sheets', url: 'https://docs.google.com/spreadsheets' diff --git a/apps/api/src/services/data-provider/manual/manual.service.ts b/apps/api/src/services/data-provider/manual/manual.service.ts index f12966991..e6763d797 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -65,6 +65,7 @@ export class ManualService implements DataProviderInterface { public getDataProviderInfo(): DataProviderInfo { return { + dataSource: DataSource.MANUAL, isPremium: false }; } 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 7762be426..05f1c0e5d 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 @@ -43,6 +43,7 @@ export class RapidApiService implements DataProviderInterface { public getDataProviderInfo(): DataProviderInfo { return { + dataSource: DataSource.RAPID_API, isPremium: false, name: 'Rapid API', url: 'https://rapidapi.com' diff --git a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts index 6b42c9283..d5a132b41 100644 --- a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts @@ -24,16 +24,19 @@ import { import { Injectable, Logger } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; import { addDays, format, isSameDay } from 'date-fns'; -import yahooFinance from 'yahoo-finance2'; -import { ChartResultArray } from 'yahoo-finance2/dist/esm/src/modules/chart'; +import YahooFinance from 'yahoo-finance2'; +import { ChartResultArray } from 'yahoo-finance2/esm/src/modules/chart'; import { HistoricalDividendsResult, HistoricalHistoryResult -} from 'yahoo-finance2/dist/esm/src/modules/historical'; -import { Quote } from 'yahoo-finance2/dist/esm/src/modules/quote'; +} from 'yahoo-finance2/esm/src/modules/historical'; +import { Quote } from 'yahoo-finance2/esm/src/modules/quote'; +import { SearchQuoteNonYahoo } from 'yahoo-finance2/script/src/modules/search'; @Injectable() export class YahooFinanceService implements DataProviderInterface { + private readonly yahooFinance = new YahooFinance(); + public constructor( private readonly cryptocurrencyService: CryptocurrencyService, private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService @@ -51,6 +54,7 @@ export class YahooFinanceService implements DataProviderInterface { public getDataProviderInfo(): DataProviderInfo { return { + dataSource: DataSource.YAHOO, isPremium: false, name: 'Yahoo Finance', url: 'https://finance.yahoo.com' @@ -69,7 +73,7 @@ export class YahooFinanceService implements DataProviderInterface { try { const historicalResult = this.convertToDividendResult( - await yahooFinance.chart( + await this.yahooFinance.chart( this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( symbol ), @@ -118,7 +122,7 @@ export class YahooFinanceService implements DataProviderInterface { try { const historicalResult = this.convertToHistoricalResult( - await yahooFinance.chart( + await this.yahooFinance.chart( this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( symbol ), @@ -187,7 +191,7 @@ export class YahooFinanceService implements DataProviderInterface { >[] = []; try { - quotes = await yahooFinance.quote(yahooFinanceSymbols); + quotes = await this.yahooFinance.quote(yahooFinanceSymbols); } catch (error) { Logger.error(error, 'YahooFinanceService'); @@ -243,13 +247,15 @@ export class YahooFinanceService implements DataProviderInterface { quoteTypes.push('INDEX'); } - const searchResult = await yahooFinance.search(query); + const searchResult = await this.yahooFinance.search(query); const quotes = searchResult.quotes - .filter((quote) => { - // Filter out undefined symbols - return quote.symbol; - }) + .filter( + (quote): quote is Exclude => { + // Filter out undefined symbols + return !!quote.symbol; + } + ) .filter(({ quoteType, symbol }) => { return ( (quoteType === 'CRYPTOCURRENCY' && @@ -275,7 +281,7 @@ export class YahooFinanceService implements DataProviderInterface { return true; }); - const marketData = await yahooFinance.quote( + const marketData = await this.yahooFinance.quote( quotes.map(({ symbol }) => { return symbol; }) @@ -335,7 +341,7 @@ export class YahooFinanceService implements DataProviderInterface { private async getQuotesWithQuoteSummary(aYahooFinanceSymbols: string[]) { const quoteSummaryPromises = aYahooFinanceSymbols.map((symbol) => { - return yahooFinance.quoteSummary(symbol).catch(() => { + return this.yahooFinance.quoteSummary(symbol).catch(() => { Logger.error( `Could not get quote summary for ${symbol}`, 'YahooFinanceService' diff --git a/apps/api/src/services/interfaces/interfaces.ts b/apps/api/src/services/interfaces/interfaces.ts index fa7fc4d09..0eaa149a3 100644 --- a/apps/api/src/services/interfaces/interfaces.ts +++ b/apps/api/src/services/interfaces/interfaces.ts @@ -4,26 +4,7 @@ import { } from '@ghostfolio/common/interfaces'; import { MarketState } from '@ghostfolio/common/types'; -import { - Account, - DataSource, - SymbolProfile, - Type as ActivityType -} from '@prisma/client'; - -export interface IOrder { - account: Account; - currency: string; - date: string; - fee: number; - id?: string; - isDraft: boolean; - quantity: number; - symbol: string; - symbolProfile: SymbolProfile; - type: ActivityType; - unitPrice: number; -} +import { DataSource } from '@prisma/client'; export interface IDataProviderHistoricalResponse { marketPrice: number; diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts index 9fbb06e56..8d8c5e06a 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts @@ -482,13 +482,13 @@ export class DataGatheringService { await this.prismaService.symbolProfile.findMany({ orderBy: [{ symbol: 'asc' }], select: { - dataSource: true, - id: true, - Order: { + activities: { orderBy: [{ date: 'asc' }], select: { date: true }, take: 1 }, + dataSource: true, + id: true, scraperConfiguration: true, symbol: true }, @@ -508,7 +508,7 @@ export class DataGatheringService { ); }) .map((symbolProfile) => { - let date = symbolProfile.Order?.[0]?.date ?? startDate; + let date = symbolProfile.activities?.[0]?.date ?? startDate; if (benchmarkAssetProfileIdMap[symbolProfile.id]) { date = this.getEarliestDate(startDate); 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 4a2d54b17..a46f71900 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -31,7 +31,7 @@ export class SymbolProfileService { }) { return this.prismaService.symbolProfile.findMany({ include: { - Order: { + activities: { include: { User: true } @@ -39,8 +39,7 @@ export class SymbolProfileService { }, orderBy: [{ symbol: 'asc' }], where: { - isActive: true, - Order: withUserSubscription + activities: withUserSubscription ? { some: { User: { @@ -54,7 +53,8 @@ export class SymbolProfileService { subscriptions: { none: { expiresAt: { gt: new Date() } } } } } - } + }, + isActive: true } }); } @@ -67,9 +67,9 @@ export class SymbolProfileService { .findMany({ include: { _count: { - select: { Order: true } + select: { activities: true } }, - Order: { + activities: { orderBy: { date: 'asc' }, @@ -118,7 +118,7 @@ export class SymbolProfileService { .findMany({ include: { _count: { - select: { Order: true } + select: { activities: true } }, SymbolProfileOverrides: true, tags: true @@ -196,8 +196,8 @@ export class SymbolProfileService { private enhanceSymbolProfiles( symbolProfiles: (SymbolProfile & { - _count: { Order: number }; - Order?: { + _count: { activities: number }; + activities?: { date: Date; }[]; tags?: Tag[]; @@ -223,11 +223,11 @@ export class SymbolProfileService { tags: symbolProfile?.tags }; - item.activitiesCount = symbolProfile._count.Order; + item.activitiesCount = symbolProfile._count.activities; delete item._count; - item.dateOfFirstActivity = symbolProfile.Order?.[0]?.date; - delete item.Order; + item.dateOfFirstActivity = symbolProfile.activities?.[0]?.date; + delete item.activities; if (item.SymbolProfileOverrides) { item.assetClass = diff --git a/apps/api/src/validators/is-currency-code.ts b/apps/api/src/validators/is-currency-code.ts index d04da7808..771818b05 100644 --- a/apps/api/src/validators/is-currency-code.ts +++ b/apps/api/src/validators/is-currency-code.ts @@ -1,4 +1,4 @@ -import { DERIVED_CURRENCIES } from '@ghostfolio/common/config'; +import { isDerivedCurrency } from '@ghostfolio/common/helper'; import { registerDecorator, @@ -28,17 +28,11 @@ export class IsExtendedCurrencyConstraint return '$property must be a valid ISO4217 currency code'; } - public validate(currency: any) { - // Return true if currency is a standard ISO 4217 code or a derived currency + public validate(currency: string) { + // Return true if currency is a derived currency or a standard ISO 4217 code return ( - this.isUpperCase(currency) && - (isISO4217CurrencyCode(currency) || - [ - ...DERIVED_CURRENCIES.map((derivedCurrency) => { - return derivedCurrency.currency; - }), - 'USX' - ].includes(currency)) + isDerivedCurrency(currency) || + (this.isUpperCase(currency) && isISO4217CurrencyCode(currency)) ); } diff --git a/apps/client/src/app/app.component.html b/apps/client/src/app/app.component.html index 6f39c824d..d5e56b517 100644 --- a/apps/client/src/app/app.component.html +++ b/apps/client/src/app/app.component.html @@ -171,6 +171,9 @@ Català --> +
  • + Chinese +
  • Deutsch
  • @@ -203,11 +206,6 @@ Українська --> - 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 9024998d6..76e647126 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.html +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.html @@ -55,7 +55,8 @@ adminMarketDataService.hasPermissionToDeleteAssetProfile({ activitiesCount: element.activitiesCount, isBenchmark: element.isBenchmark, - symbol: element.symbol + symbol: element.symbol, + watchedByCount: element.watchedByCount }) ) { ) { + symbol, + watchedByCount + }: Pick< + AdminMarketDataItem, + 'activitiesCount' | 'isBenchmark' | 'symbol' | 'watchedByCount' + >) { return ( activitiesCount === 0 && !isBenchmark && !isDerivedCurrency(getCurrencyFromSymbol(symbol)) && !isRootCurrency(getCurrencyFromSymbol(symbol)) && - !symbol.startsWith(ghostfolioScraperApiSymbolPrefix) + !symbol.startsWith(ghostfolioScraperApiSymbolPrefix) && + watchedByCount === 0 ); } } diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index 425da8865..9ebbe7724 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html @@ -270,20 +270,20 @@
    Sectors
    Countries
    } diff --git a/apps/client/src/app/components/admin-platform/admin-platform.component.html b/apps/client/src/app/components/admin-platform/admin-platform.component.html index c71594e45..47fee3c8a 100644 --- a/apps/client/src/app/components/admin-platform/admin-platform.component.html +++ b/apps/client/src/app/components/admin-platform/admin-platform.component.html @@ -1,115 +1,94 @@ -
    -
    -
    - - - - - + +
    - Name - - @if (element.url) { - - } - {{ element.name }} -
    + + + - - - - + + + + - - - - + + + + - - - - + + + + - - -
    + Name + + @if (element.url) { + + } + {{ element.name }} + - Url - - {{ element.url }} - + Url + + {{ element.url }} + - Accounts - - {{ element.accountCount }} - + Accounts + + {{ element.accountCount }} + - - - -
    - -
    -
    + + + +
    + +
    +
    -
    -
    -
    + + + 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 305d6ce49..2dcdefdd0 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 @@ -1,65 +1,108 @@
    -
    +

    Data Providers

    - - -
    -
    - - @if (isGhostfolioApiKeyValid === false) { - Early Access + + + Name + + +
    + +
    + @if (isGhostfolioDataProvider(element)) { + + Ghostfolio Premium + + @if (isGhostfolioApiKeyValid === false) { + Early Access + } + + @if (isGhostfolioApiKeyValid === true) { +
    + + Valid until + {{ + ghostfolioApiStatus?.subscription?.expiresAt + | date: defaultDateFormat + }} + +
    + } + } @else { + {{ element.name }} } - Ghostfolio Premium - - +
    +
    + + + + + + Asset Profiles + + + {{ element.assetProfileCount }} + + + + + + + @if (isGhostfolioDataProvider(element)) { @if (isGhostfolioApiKeyValid === true) { -
    - - Valid until - {{ - ghostfolioApiStatus?.subscription?.expiresAt - | date: defaultDateFormat - }} -
    + + + {{ ghostfolioApiStatus.dailyRequests }} + of + {{ ghostfolioApiStatus.dailyRequestsMax }} + daily requests + } -
    -
    + } + + + + + + + + @if (isGhostfolioDataProvider(element)) { @if (isGhostfolioApiKeyValid === true) { -
    -
    - {{ ghostfolioApiStatus.dailyRequests }} - of - {{ ghostfolioApiStatus.dailyRequestsMax }} - daily requests -
    - + + - - - -
    + } @else if (isGhostfolioApiKeyValid === false) { } -
    -
    -
    -
    + } + + + + + + + @if (isLoading) { + + }
    diff --git a/apps/client/src/app/components/admin-settings/admin-settings.component.scss b/apps/client/src/app/components/admin-settings/admin-settings.component.scss index 5d4e87f30..c08ba95bc 100644 --- a/apps/client/src/app/components/admin-settings/admin-settings.component.scss +++ b/apps/client/src/app/components/admin-settings/admin-settings.component.scss @@ -1,3 +1,15 @@ :host { display: block; + + .mat-mdc-progress-bar { + --mdc-linear-progress-active-indicator-height: 0.5rem; + --mdc-linear-progress-track-height: 0.5rem; + border-radius: 0.25rem; + + ::ng-deep { + .mdc-linear-progress__buffer-bar { + background-color: rgb(var(--palette-background-unselected-chip)); + } + } + } } diff --git a/apps/client/src/app/components/admin-settings/admin-settings.component.ts b/apps/client/src/app/components/admin-settings/admin-settings.component.ts index be077c0e6..5c071c60c 100644 --- a/apps/client/src/app/components/admin-settings/admin-settings.component.ts +++ b/apps/client/src/app/components/admin-settings/admin-settings.component.ts @@ -10,6 +10,7 @@ import { import { getDateFormatString } from '@ghostfolio/common/helper'; import { DataProviderGhostfolioStatusResponse, + DataProviderInfo, User } from '@ghostfolio/common/interfaces'; @@ -21,10 +22,12 @@ import { OnInit } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; +import { MatTableDataSource } from '@angular/material/table'; import { DeviceDetectorService } from 'ngx-device-detector'; import { catchError, filter, of, Subject, takeUntil } from 'rxjs'; import { GfGhostfolioPremiumApiDialogComponent } from './ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component'; +import { GhostfolioPremiumApiDialogParams } from './ghostfolio-premium-api-dialog/interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -34,9 +37,12 @@ import { GfGhostfolioPremiumApiDialogComponent } from './ghostfolio-premium-api- standalone: false }) export class AdminSettingsComponent implements OnDestroy, OnInit { + public dataSource = new MatTableDataSource(); public defaultDateFormat: string; + public displayedColumns = ['name', 'assetProfileCount', 'status', 'actions']; public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse; public isGhostfolioApiKeyValid: boolean; + public isLoading = false; public pricingUrl: string; private deviceType: string; @@ -80,6 +86,10 @@ export class AdminSettingsComponent implements OnDestroy, OnInit { this.initialize(); } + public isGhostfolioDataProvider(provider: DataProviderInfo): boolean { + return provider.dataSource === 'GHOSTFOLIO'; + } + public onRemoveGhostfolioApiKey() { this.notificationService.confirm({ confirmFn: () => { @@ -101,9 +111,8 @@ export class AdminSettingsComponent implements OnDestroy, OnInit { autoFocus: false, data: { deviceType: this.deviceType, - pricingUrl: this.pricingUrl, - user: this.user - }, + pricingUrl: this.pricingUrl + } as GhostfolioPremiumApiDialogParams, height: this.deviceType === 'mobile' ? '98vh' : undefined, width: this.deviceType === 'mobile' ? '100vw' : '50rem' } @@ -123,24 +132,45 @@ export class AdminSettingsComponent implements OnDestroy, OnInit { } private initialize() { - this.adminService - .fetchGhostfolioDataProviderStatus() - .pipe( - catchError(() => { - this.isGhostfolioApiKeyValid = false; + this.isLoading = true; - this.changeDetectorRef.markForCheck(); + this.dataSource = new MatTableDataSource(); + + this.adminService + .fetchAdminData() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ dataProviders, settings }) => { + const filteredProviders = dataProviders.filter(({ dataSource }) => { + return dataSource !== 'MANUAL'; + }); + + this.dataSource = new MatTableDataSource(filteredProviders); + + this.adminService + .fetchGhostfolioDataProviderStatus( + settings[PROPERTY_API_KEY_GHOSTFOLIO] as string + ) + .pipe( + catchError(() => { + this.isGhostfolioApiKeyValid = false; + + this.changeDetectorRef.markForCheck(); + + return of(null); + }), + filter((status) => { + return status !== null; + }), + takeUntil(this.unsubscribeSubject) + ) + .subscribe((status) => { + this.ghostfolioApiStatus = status; + this.isGhostfolioApiKeyValid = true; + + this.changeDetectorRef.markForCheck(); + }); - return of(null); - }), - filter((status) => { - return status !== null; - }), - takeUntil(this.unsubscribeSubject) - ) - .subscribe((status) => { - this.ghostfolioApiStatus = status; - this.isGhostfolioApiKeyValid = true; + this.isLoading = false; this.changeDetectorRef.markForCheck(); }); diff --git a/apps/client/src/app/components/admin-settings/admin-settings.module.ts b/apps/client/src/app/components/admin-settings/admin-settings.module.ts index 5a5c39cde..706f20a87 100644 --- a/apps/client/src/app/components/admin-settings/admin-settings.module.ts +++ b/apps/client/src/app/components/admin-settings/admin-settings.module.ts @@ -1,13 +1,16 @@ import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module'; import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.module'; +import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; -import { MatCardModule } from '@angular/material/card'; import { MatMenuModule } from '@angular/material/menu'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatTableModule } from '@angular/material/table'; import { RouterModule } from '@angular/router'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { AdminSettingsComponent } from './admin-settings.component'; @@ -17,10 +20,13 @@ import { AdminSettingsComponent } from './admin-settings.component'; CommonModule, GfAdminPlatformModule, GfAdminTagModule, + GfAssetProfileIconComponent, GfPremiumIndicatorComponent, MatButtonModule, - MatCardModule, MatMenuModule, + MatProgressBarModule, + MatTableModule, + NgxSkeletonLoaderModule, RouterModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA] diff --git a/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html b/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html index d3b0985fa..017133f5b 100644 --- a/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html +++ b/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html @@ -7,8 +7,8 @@ />
    -

    - The official +

    + Early access to the official data provider for self-hosters, offering 80’000+ tickers from over 50 exchanges, is - coming soon! -

    -

    - Want to stay updated? Click below to get notified as soon as it’s available. + ready now!

    Notify meGet Early Access +
    + or +
    + - } + I have an API key +
    diff --git a/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/interfaces/interfaces.ts index 157a6f414..0c629599e 100644 --- a/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/interfaces/interfaces.ts @@ -1,7 +1,4 @@ -import { User } from '@ghostfolio/common/interfaces'; - export interface GhostfolioPremiumApiDialogParams { deviceType: string; pricingUrl: string; - user: User; } diff --git a/apps/client/src/app/components/admin-tag/admin-tag.component.html b/apps/client/src/app/components/admin-tag/admin-tag.component.html index e76255e50..b5f995c0d 100644 --- a/apps/client/src/app/components/admin-tag/admin-tag.component.html +++ b/apps/client/src/app/components/admin-tag/admin-tag.component.html @@ -1,121 +1,100 @@ -
    -
    -
    - - - - - - + +
    - Name - - {{ element.name }} -
    + + + + - - - - + + + + - - - - - - - - + + + + + + + + - - - - + + + + - - -
    + Name + + {{ element.name }} + - User - - {{ element.userId }} - + User + + {{ element.userId }} + - Activities - - {{ element.activityCount }} - - Holdings - - {{ element.holdingCount }} - + Activities + + {{ element.activityCount }} + + Holdings + + {{ element.holdingCount }} + - - - -
    - -
    -
    + + + +
    + +
    +
    -
    -
    -
    + + + 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 56b2e0eac..1a4125d84 100644 --- a/apps/client/src/app/components/admin-users/admin-users.html +++ b/apps/client/src/app/components/admin-users/admin-users.html @@ -142,7 +142,7 @@ diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html index 6cf0ca305..b14d142f4 100644 --- a/apps/client/src/app/components/header/header.component.html +++ b/apps/client/src/app/components/header/header.component.html @@ -312,7 +312,7 @@ >About Ghostfolio
    - + diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts index dc0d3fe4f..0c998db28 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts @@ -106,8 +106,8 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { public investmentPrecision = 2; public marketDataItems: MarketData[] = []; public marketPrice: number; - public maxPrice: number; - public minPrice: number; + public marketPriceMax: number; + public marketPriceMin: number; public netPerformance: number; public netPerformancePrecision = 2; public netPerformancePercent: number; @@ -233,8 +233,8 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { stakeRewards, investment, marketPrice, - maxPrice, - minPrice, + marketPriceMax, + marketPriceMin, netPerformance, netPerformancePercent, netPerformancePercentWithCurrencyEffect, @@ -301,8 +301,8 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { } this.marketPrice = marketPrice; - this.maxPrice = maxPrice; - this.minPrice = minPrice; + this.marketPriceMax = marketPriceMax; + this.marketPriceMin = marketPriceMin; this.netPerformance = netPerformance; if ( diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html index 80bfb65cd..e149ee8bb 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -106,11 +106,11 @@ [locale]="data.locale" [ngClass]="{ 'text-danger': - minPrice?.toFixed(2) === marketPrice?.toFixed(2) && - maxPrice?.toFixed(2) !== minPrice?.toFixed(2) + marketPriceMin?.toFixed(2) === marketPrice?.toFixed(2) && + marketPriceMax?.toFixed(2) !== marketPriceMin?.toFixed(2) }" [unit]="SymbolProfile?.currency" - [value]="minPrice" + [value]="marketPriceMin" >Minimum Price
    @@ -122,11 +122,11 @@ [locale]="data.locale" [ngClass]="{ 'text-success': - maxPrice?.toFixed(2) === marketPrice?.toFixed(2) && - maxPrice?.toFixed(2) !== minPrice?.toFixed(2) + marketPriceMax?.toFixed(2) === marketPrice?.toFixed(2) && + marketPriceMax?.toFixed(2) !== marketPriceMin?.toFixed(2) }" [unit]="SymbolProfile?.currency" - [value]="maxPrice" + [value]="marketPriceMax" >Maximum Price
    @@ -263,11 +263,11 @@
    @@ -275,11 +275,11 @@
    } diff --git a/apps/client/src/app/components/home-market/home-market.html b/apps/client/src/app/components/home-market/home-market.html index 2fcdb5716..189c87c8f 100644 --- a/apps/client/src/app/components/home-market/home-market.html +++ b/apps/client/src/app/components/home-market/home-market.html @@ -36,6 +36,14 @@ [locale]="user?.settings?.locale || undefined" [user]="user" /> + @if (benchmarks?.length > 0) { +
    + + Calculations are based on delayed market data and may not be + displayed in real-time. +
    + } diff --git a/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.scss b/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.ts b/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.ts new file mode 100644 index 000000000..722f680c3 --- /dev/null +++ b/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.ts @@ -0,0 +1,92 @@ +import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete'; + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + OnInit +} from '@angular/core'; +import { + AbstractControl, + FormBuilder, + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + ValidationErrors, + Validators +} from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { Subject } from 'rxjs'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'h-100' }, + imports: [ + CommonModule, + FormsModule, + GfSymbolAutocompleteComponent, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + ReactiveFormsModule + ], + selector: 'gf-create-watchlist-item-dialog', + styleUrls: ['./create-watchlist-item-dialog.component.scss'], + templateUrl: 'create-watchlist-item-dialog.html' +}) +export class CreateWatchlistItemDialogComponent implements OnInit, OnDestroy { + public createWatchlistItemForm: FormGroup; + + private unsubscribeSubject = new Subject(); + + public constructor( + public readonly dialogRef: MatDialogRef, + public readonly formBuilder: FormBuilder + ) {} + + public ngOnInit() { + this.createWatchlistItemForm = this.formBuilder.group( + { + searchSymbol: new FormControl(null, [Validators.required]) + }, + { + validators: this.validator + } + ); + } + + public onCancel() { + this.dialogRef.close(); + } + + public onSubmit() { + this.dialogRef.close({ + dataSource: + this.createWatchlistItemForm.get('searchSymbol').value.dataSource, + symbol: this.createWatchlistItemForm.get('searchSymbol').value.symbol + }); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private validator(control: AbstractControl): ValidationErrors { + const searchSymbolControl = control.get('searchSymbol'); + + if ( + searchSymbolControl.valid && + searchSymbolControl.value.dataSource && + searchSymbolControl.value.symbol + ) { + return { incomplete: false }; + } + + return { incomplete: true }; + } +} diff --git a/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.html b/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.html new file mode 100644 index 000000000..dd59a9309 --- /dev/null +++ b/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.html @@ -0,0 +1,25 @@ +
    +

    Add asset to watchlist

    +
    + + Name, symbol or ISIN + + +
    +
    + + +
    +
    diff --git a/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/interfaces/interfaces.ts new file mode 100644 index 000000000..c0f74d022 --- /dev/null +++ b/apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/interfaces/interfaces.ts @@ -0,0 +1,4 @@ +export interface CreateWatchlistItemDialogParams { + deviceType: string; + locale: string; +} diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts b/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts new file mode 100644 index 000000000..5c0b3fa50 --- /dev/null +++ b/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts @@ -0,0 +1,185 @@ +import { DataService } from '@ghostfolio/client/services/data.service'; +import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { + AssetProfileIdentifier, + Benchmark, + User +} from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { BenchmarkTrend } from '@ghostfolio/common/types'; +import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; +import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + CUSTOM_ELEMENTS_SCHEMA, + OnDestroy, + OnInit +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { DeviceDetectorService } from 'ngx-device-detector'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { CreateWatchlistItemDialogComponent } from './create-watchlist-item-dialog/create-watchlist-item-dialog.component'; +import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/interfaces/interfaces'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + GfBenchmarkComponent, + GfPremiumIndicatorComponent, + MatButtonModule, + RouterModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-home-watchlist', + styleUrls: ['./home-watchlist.scss'], + templateUrl: './home-watchlist.html' +}) +export class HomeWatchlistComponent implements OnDestroy, OnInit { + public deviceType: string; + public hasImpersonationId: boolean; + public hasPermissionToCreateWatchlistItem: boolean; + public hasPermissionToDeleteWatchlistItem: boolean; + public user: User; + public watchlist: Benchmark[]; + + private unsubscribeSubject = new Subject(); + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService, + private deviceService: DeviceDetectorService, + private dialog: MatDialog, + private impersonationStorageService: ImpersonationStorageService, + private route: ActivatedRoute, + private router: Router, + private userService: UserService + ) { + this.deviceType = this.deviceService.getDeviceInfo().deviceType; + + this.impersonationStorageService + .onChangeHasImpersonation() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((impersonationId) => { + this.hasImpersonationId = !!impersonationId; + }); + + this.route.queryParams + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((params) => { + if (params['createWatchlistItemDialog']) { + this.openCreateWatchlistItemDialog(); + } + }); + + this.userService.stateChanged + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((state) => { + if (state?.user) { + this.user = state.user; + + this.hasPermissionToCreateWatchlistItem = + !this.hasImpersonationId && + hasPermission( + this.user.permissions, + permissions.createWatchlistItem + ); + this.hasPermissionToDeleteWatchlistItem = + !this.hasImpersonationId && + hasPermission( + this.user.permissions, + permissions.deleteWatchlistItem + ); + + this.changeDetectorRef.markForCheck(); + } + }); + } + + public ngOnInit() { + this.loadWatchlistData(); + } + + public onWatchlistItemDeleted({ + dataSource, + symbol + }: AssetProfileIdentifier) { + this.dataService + .deleteWatchlistItem({ dataSource, symbol }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe({ + next: () => { + return this.loadWatchlistData(); + } + }); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private loadWatchlistData() { + this.dataService + .fetchWatchlist() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ watchlist }) => { + this.watchlist = watchlist.map( + ({ dataSource, marketCondition, name, performances, symbol }) => ({ + dataSource, + marketCondition, + name, + performances, + symbol, + trend50d: 'UNKNOWN' as BenchmarkTrend, + trend200d: 'UNKNOWN' as BenchmarkTrend + }) + ); + + this.changeDetectorRef.markForCheck(); + }); + } + + private openCreateWatchlistItemDialog() { + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + const dialogRef = this.dialog.open(CreateWatchlistItemDialogComponent, { + autoFocus: false, + data: { + deviceType: this.deviceType, + locale: this.user?.settings?.locale + } as CreateWatchlistItemDialogParams, + width: this.deviceType === 'mobile' ? '100vw' : '50rem' + }); + + dialogRef + .afterClosed() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ dataSource, symbol } = {}) => { + if (dataSource && symbol) { + this.dataService + .postWatchlistItem({ dataSource, symbol }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe({ + next: () => this.loadWatchlistData() + }); + } + + this.router.navigate(['.'], { relativeTo: this.route }); + }); + }); + } +} diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.html b/apps/client/src/app/components/home-watchlist/home-watchlist.html new file mode 100644 index 000000000..9149eab91 --- /dev/null +++ b/apps/client/src/app/components/home-watchlist/home-watchlist.html @@ -0,0 +1,35 @@ +
    +

    + + Watchlist + @if (user?.subscription?.type === 'Basic') { + + } + +

    +
    +
    + +
    +
    +
    +@if (!hasImpersonationId && hasPermissionToCreateWatchlistItem) { +
    + + + +
    +} diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.scss b/apps/client/src/app/components/home-watchlist/home-watchlist.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/apps/client/src/app/components/home-watchlist/home-watchlist.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html index cc6b5283c..926450085 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -7,11 +7,11 @@
    - {{ summary?.ordersCount }} - {summary?.ordersCount, plural, + {{ summary?.activityCount }} + {summary?.activityCount, plural, =1 {activity} other {activities} } diff --git a/apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component.ts b/apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component.ts index d98c54719..201a63927 100644 --- a/apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component.ts +++ b/apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component.ts @@ -1,5 +1,14 @@ -import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Inject, + OnInit +} from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import ms from 'ms'; +import { interval, Subject } from 'rxjs'; +import { take, takeUntil, tap } from 'rxjs/operators'; import { SubscriptionInterstitialDialogParams } from './interfaces/interfaces'; @@ -11,20 +20,47 @@ import { SubscriptionInterstitialDialogParams } from './interfaces/interfaces'; templateUrl: 'subscription-interstitial-dialog.html', standalone: false }) -export class SubscriptionInterstitialDialog { - private readonly VARIANTS_COUNT = 2; +export class SubscriptionInterstitialDialog implements OnInit { + private static readonly SKIP_BUTTON_DELAY_IN_SECONDS = 5; + private static readonly VARIANTS_COUNT = 2; + public remainingSkipButtonDelay = + SubscriptionInterstitialDialog.SKIP_BUTTON_DELAY_IN_SECONDS; public routerLinkPricing = ['/' + $localize`:snake-case:pricing`]; public variantIndex: number; + private unsubscribeSubject = new Subject(); + public constructor( + private changeDetectorRef: ChangeDetectorRef, @Inject(MAT_DIALOG_DATA) public data: SubscriptionInterstitialDialogParams, public dialogRef: MatDialogRef ) { - this.variantIndex = Math.floor(Math.random() * this.VARIANTS_COUNT); + this.variantIndex = Math.floor( + Math.random() * SubscriptionInterstitialDialog.VARIANTS_COUNT + ); + } + + public ngOnInit() { + interval(ms('1 second')) + .pipe( + take(SubscriptionInterstitialDialog.SKIP_BUTTON_DELAY_IN_SECONDS), + tap(() => { + this.remainingSkipButtonDelay--; + + this.changeDetectorRef.markForCheck(); + }), + takeUntil(this.unsubscribeSubject) + ) + .subscribe(); } public closeDialog() { this.dialogRef.close({}); } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } } diff --git a/apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html b/apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html index b9b96f9a8..92d9da835 100644 --- a/apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html +++ b/apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html @@ -51,7 +51,16 @@
    - +
    - ) } - @if (user?.settings?.isExperimentalFeatures) { - Chinese (Community) - } + Chinese (Community) Español (Community)source code is fully available as open source software (OSS). Thanks to our generous - Ghostfolio Premium users - and sponsors we - have the ability to run a free, limited plan for novice + Ghostfolio Premium + users and + sponsors we have + the ability to run a free, limited plan for novice investors. @@ -82,8 +87,11 @@ By offering - Ghostfolio Premium, a - subscription plan with a managed hosting service and enhanced + Ghostfolio Premium, a subscription plan with a managed hosting service and enhanced features, we fund our business while providing added value to our users. @@ -105,7 +113,11 @@ Any support for Ghostfolio is welcome. Be it with a - Ghostfolio Premium + Ghostfolio Premium subscription to finance the hosting infrastructure, a positive rating in the How do I start? - You can sign up via the “Get Started” button at the top of the page. You have multiple options to join - Ghostfolio: Create an account with a security token or - Google Sign. We will guide you to set up your portfolio. + You can sign up via the + Get Started button at the top + of the page. You have multiple options to join Ghostfolio: Create an + account with a security token or Google Sign. We will guide you + to set up your portfolio. @@ -38,8 +38,8 @@ > Yes, it is! Our - pricing page details - everything you get for free.pricing page + details everything you get for free. @@ -49,12 +49,20 @@ > Ghostfolio Premium is a fully - managed Ghostfolio cloud offering for ambitious investors. Revenue is - used to cover the costs of the hosting infrastructure and to fund - ongoing development. It is the Open Source code base with some extras - like the markets overview and - a professional data provider.Ghostfolio Premium + is a fully managed Ghostfolio cloud offering for ambitious investors. + Revenue is used to cover the costs of the hosting infrastructure and + to fund ongoing development. It is the Open Source code base with some + extras like the + markets overview and a + professional data provider. @@ -65,8 +73,15 @@ > Yes, you can try - Ghostfolio Premium by signing - up for Ghostfolio and applying for a trial (see + Ghostfolio Premium + by signing up for Ghostfolio and applying for a trial (see Membership). It is easy, free and there is no commitment. You can stop using it at any time. No, Ghostfolio Premium does - not include auto-renewal. Upon expiration, you can choose whether to - start a new subscription.No, + Ghostfolio Premium + does not include auto-renewal. Upon expiration, you can choose whether + to start a new subscription. @if (user?.subscription?.type === 'Premium') { diff --git a/apps/client/src/app/pages/faq/saas/saas-page.module.ts b/apps/client/src/app/pages/faq/saas/saas-page.module.ts index 4ddcf9d24..81cd2b524 100644 --- a/apps/client/src/app/pages/faq/saas/saas-page.module.ts +++ b/apps/client/src/app/pages/faq/saas/saas-page.module.ts @@ -1,3 +1,5 @@ +import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; + import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { MatCardModule } from '@angular/material/card'; @@ -7,7 +9,12 @@ import { SaasPageComponent } from './saas-page.component'; @NgModule({ declarations: [SaasPageComponent], - imports: [CommonModule, MatCardModule, SaasPageRoutingModule], + imports: [ + CommonModule, + GfPremiumIndicatorComponent, + MatCardModule, + SaasPageRoutingModule + ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class SaasPageModule {} diff --git a/apps/client/src/app/pages/faq/self-hosting/self-hosting-page.component.ts b/apps/client/src/app/pages/faq/self-hosting/self-hosting-page.component.ts index 387187be0..f0ff7dbc3 100644 --- a/apps/client/src/app/pages/faq/self-hosting/self-hosting-page.component.ts +++ b/apps/client/src/app/pages/faq/self-hosting/self-hosting-page.component.ts @@ -9,6 +9,10 @@ import { Subject } from 'rxjs'; standalone: false }) export class SelfHostingPageComponent implements OnDestroy { + public pricingUrl = + `https://ghostfol.io/${document.documentElement.lang}/` + + $localize`:snake-case:pricing`; + private unsubscribeSubject = new Subject(); public ngOnDestroy() { diff --git a/apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html b/apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html index dcb48aa54..a30854156 100644 --- a/apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html +++ b/apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html @@ -140,6 +140,25 @@ providers are considered experimental. + + + Can I get access to a professional data provider? + + Yes, access to a professional data provider is included with a + Ghostfolio Premium + subscription via an API key. + How do I set up a benchmark? diff --git a/apps/client/src/app/pages/faq/self-hosting/self-hosting-page.module.ts b/apps/client/src/app/pages/faq/self-hosting/self-hosting-page.module.ts index 65f9969a3..931f24aa6 100644 --- a/apps/client/src/app/pages/faq/self-hosting/self-hosting-page.module.ts +++ b/apps/client/src/app/pages/faq/self-hosting/self-hosting-page.module.ts @@ -1,3 +1,5 @@ +import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; + import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { MatCardModule } from '@angular/material/card'; @@ -7,7 +9,12 @@ import { SelfHostingPageComponent } from './self-hosting-page.component'; @NgModule({ declarations: [SelfHostingPageComponent], - imports: [CommonModule, MatCardModule, SelfHostingPageRoutingModule], + imports: [ + CommonModule, + GfPremiumIndicatorComponent, + MatCardModule, + SelfHostingPageRoutingModule + ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class SelfHostingPageModule {} diff --git a/apps/client/src/app/pages/features/features-page.html b/apps/client/src/app/pages/features/features-page.html index aa2ae3725..7d8f3eda0 100644 --- a/apps/client/src/app/pages/features/features-page.html +++ b/apps/client/src/app/pages/features/features-page.html @@ -175,10 +175,15 @@
    -

    Dark Mode

    +

    + Static Analysis + @if (hasPermissionForSubscription) { + + } +

    - Ghostfolio automatically switches to a dark color theme based - on your operating system's preferences. + Identify potential risks in your portfolio with Ghostfolio + X-ray, the static portfolio analysis.

    @@ -188,10 +193,14 @@
    -

    Zen Mode

    +

    + Watchlist + @if (hasPermissionForSubscription) { + + } +

    - Keep calm and activate Zen Mode if the markets are going - crazy. + Follow assets you are interested in closely on your watchlist.

    @@ -221,15 +230,23 @@
    -

    - Static Analysis - @if (hasPermissionForSubscription) { - - } -

    +

    Dark Mode

    - Identify potential risks in your portfolio with Ghostfolio - X-ray, the static portfolio analysis. + Ghostfolio automatically switches to a dark color theme based + on your operating system's preferences. +

    +
    +
    +
    +
    +
    + + +
    +

    Zen Mode

    +

    + Keep calm and activate Zen Mode if the markets are going + crazy.

    @@ -243,9 +260,8 @@

    Use Ghostfolio in multiple languages: English, - - Dutch, French, German, Italian, Polish, Portuguese, Spanish - and Turkish + Chinese, Dutch, French, German, Italian, Polish, Portuguese, + Spanish and Turkish are currently supported.

    diff --git a/apps/client/src/app/pages/home/home-page-routing.module.ts b/apps/client/src/app/pages/home/home-page-routing.module.ts index f50b55192..9a915f0b3 100644 --- a/apps/client/src/app/pages/home/home-page-routing.module.ts +++ b/apps/client/src/app/pages/home/home-page-routing.module.ts @@ -2,6 +2,7 @@ import { HomeHoldingsComponent } from '@ghostfolio/client/components/home-holdin import { HomeMarketComponent } from '@ghostfolio/client/components/home-market/home-market.component'; import { HomeOverviewComponent } from '@ghostfolio/client/components/home-overview/home-overview.component'; import { HomeSummaryComponent } from '@ghostfolio/client/components/home-summary/home-summary.component'; +import { HomeWatchlistComponent } from '@ghostfolio/client/components/home-watchlist/home-watchlist.component'; import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; import { NgModule } from '@angular/core'; @@ -36,6 +37,11 @@ const routes: Routes = [ path: 'market', component: HomeMarketComponent, title: $localize`Markets` + }, + { + path: 'watchlist', + component: HomeWatchlistComponent, + title: $localize`Watchlist` } ], component: HomePageComponent, diff --git a/apps/client/src/app/pages/home/home-page.component.ts b/apps/client/src/app/pages/home/home-page.component.ts index e307884f8..ec49143a8 100644 --- a/apps/client/src/app/pages/home/home-page.component.ts +++ b/apps/client/src/app/pages/home/home-page.component.ts @@ -48,12 +48,18 @@ export class HomePageComponent implements OnDestroy, OnInit { label: $localize`Summary`, path: ['/home', 'summary'] }, + { + iconName: 'bookmark-outline', + label: $localize`Watchlist`, + path: ['/home', 'watchlist'] + }, { iconName: 'newspaper-outline', label: $localize`Markets`, path: ['/home', 'market'] } ]; + this.user = state.user; this.changeDetectorRef.markForCheck(); diff --git a/apps/client/src/app/pages/home/home-page.module.ts b/apps/client/src/app/pages/home/home-page.module.ts index 045cfa8c0..32f031e4e 100644 --- a/apps/client/src/app/pages/home/home-page.module.ts +++ b/apps/client/src/app/pages/home/home-page.module.ts @@ -2,6 +2,7 @@ import { GfHomeHoldingsModule } from '@ghostfolio/client/components/home-holding import { GfHomeMarketModule } from '@ghostfolio/client/components/home-market/home-market.module'; import { GfHomeOverviewModule } from '@ghostfolio/client/components/home-overview/home-overview.module'; import { GfHomeSummaryModule } from '@ghostfolio/client/components/home-summary/home-summary.module'; +import { HomeWatchlistComponent } from '@ghostfolio/client/components/home-watchlist/home-watchlist.component'; import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; @@ -20,6 +21,7 @@ import { HomePageComponent } from './home-page.component'; GfHomeOverviewModule, GfHomeSummaryModule, HomePageRoutingModule, + HomeWatchlistComponent, MatTabsModule, RouterModule ], diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts index 819bc78fb..1025b04d8 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts @@ -39,6 +39,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { return { id: assetSubClass, label: translate(assetSubClass) }; }); public currencies: string[] = []; + public currencyOfAssetProfile: string; public currentMarketPrice = null; public defaultDateFormat: string; public isLoading = false; @@ -63,8 +64,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { ) {} public ngOnInit() { - this.mode = this.data.activity.id ? 'update' : 'create'; + this.currencyOfAssetProfile = this.data.activity?.SymbolProfile?.currency; this.locale = this.data.user?.settings?.locale; + this.mode = this.data.activity?.id ? 'update' : 'create'; + this.dateAdapter.setLocale(this.locale); const { currencies, platforms } = this.dataService.fetchInfo(); @@ -214,7 +217,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { this.activityForm.get('type').value ) ) { - this.updateSymbol(); + this.updateAssetProfile(); } this.changeDetectorRef.markForCheck(); @@ -242,7 +245,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { .get('dataSource') .removeValidators(Validators.required); this.activityForm.get('dataSource').updateValueAndValidity(); - this.activityForm.get('fee').reset(); + this.activityForm.get('fee').setValue(0); this.activityForm.get('name').setValidators(Validators.required); this.activityForm.get('name').updateValueAndValidity(); this.activityForm.get('quantity').setValue(1); @@ -252,11 +255,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { this.activityForm.get('searchSymbol').updateValueAndValidity(); this.activityForm.get('updateAccountBalance').disable(); this.activityForm.get('updateAccountBalance').setValue(false); - } else if ( - type === 'FEE' || - type === 'INTEREST' || - type === 'LIABILITY' - ) { + } else if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) { this.activityForm .get('accountId') .removeValidators(Validators.required); @@ -275,12 +274,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { .removeValidators(Validators.required); this.activityForm.get('dataSource').updateValueAndValidity(); - if ( - (type === 'FEE' && this.activityForm.get('fee').value === 0) || - type === 'INTEREST' || - type === 'LIABILITY' - ) { - this.activityForm.get('fee').reset(); + if (['INTEREST', 'LIABILITY'].includes(type)) { + this.activityForm.get('fee').setValue(0); } this.activityForm.get('name').setValidators(Validators.required); @@ -288,7 +283,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { if (type === 'FEE') { this.activityForm.get('quantity').setValue(0); - } else if (type === 'INTEREST' || type === 'LIABILITY') { + } else if (['INTEREST', 'LIABILITY'].includes(type)) { this.activityForm.get('quantity').setValue(1); } @@ -409,7 +404,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { this.dialogRef.close(activity); } else { - (activity as UpdateOrderDto).id = this.data.activity.id; + (activity as UpdateOrderDto).id = this.data.activity?.id; await validateObjectForForm({ classDto: UpdateOrderDto, @@ -434,7 +429,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { this.unsubscribeSubject.complete(); } - private updateSymbol() { + private updateAssetProfile() { this.isLoading = true; this.changeDetectorRef.markForCheck(); @@ -462,6 +457,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { this.activityForm.get('dataSource').setValue(dataSource); } + this.currencyOfAssetProfile = currency; this.currentMarketPrice = marketPrice; this.isLoading = false; diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html index b47d83a4c..f3e9704b8 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html @@ -241,8 +241,10 @@
    @if ( + currencyOfAssetProfile === + activityForm.get('currencyOfUnitPrice').value && currentMarketPrice && - (data.activity.type === 'BUY' || data.activity.type === 'SELL') && + ['BUY', 'SELL'].includes(data.activity.type) && isToday(activityForm.get('date')?.value) ) {