diff --git a/CHANGELOG.md b/CHANGELOG.md index 9826a04f0..f14b51dae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,36 @@ 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`) - Improved the language localization for Portuguese (`pt`) +- 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 diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index 8512d8409..1d8f9ab27 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -87,7 +87,7 @@ 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 { @@ -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/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index d8507bbb0..736f6da33 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 ) {} @@ -214,19 +209,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) @@ -253,58 +235,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 79beedea9..35b4ea73d 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -143,15 +143,30 @@ export class AdminService { 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, - dataProviders: dataSources.map((dataSource) => { - return this.dataProviderService - .getDataProvider(dataSource) - .getDataProviderInfo(); - }), version: environment.version }; } @@ -806,7 +821,7 @@ export class AdminService { where, select: { _count: { - select: { Account: true, Order: true } + select: { Account: true, activities: true } }, Analytics: { select: { @@ -854,10 +869,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/watchlist/watchlist.controller.ts b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts index 8d9d322a8..2a8ea9875 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts @@ -39,7 +39,7 @@ export class WatchlistController { @Post() @HasPermission(permissions.createWatchlistItem) - @UseGuards(AuthGuard('jwt')) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async createWatchlistItem(@Body() data: CreateWatchlistItemDto) { return this.watchlistService.createWatchlistItem({ 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/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index c580ce149..7e373c4cc 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1249,7 +1249,7 @@ export class PortfolioService { const rules: PortfolioReportResponse['rules'] = { accountClusterRisk: - summary.ordersCount > 0 + summary.activityCount > 0 ? await this.rulesService.evaluate( [ new AccountClusterRiskCurrentInvestment( @@ -1265,7 +1265,7 @@ export class PortfolioService { ) : undefined, assetClassClusterRisk: - summary.ordersCount > 0 + summary.activityCount > 0 ? await this.rulesService.evaluate( [ new AssetClassClusterRiskEquity( @@ -1281,7 +1281,7 @@ export class PortfolioService { ) : undefined, currencyClusterRisk: - summary.ordersCount > 0 + summary.activityCount > 0 ? await this.rulesService.evaluate( [ new CurrencyClusterRiskBaseCurrencyCurrentInvestment( @@ -1297,7 +1297,7 @@ export class PortfolioService { ) : undefined, economicMarketClusterRisk: - summary.ordersCount > 0 + summary.activityCount > 0 ? await this.rulesService.evaluate( [ new EconomicMarketClusterRiskDevelopedMarkets( @@ -1338,7 +1338,7 @@ export class PortfolioService { userSettings ), regionalMarketClusterRisk: - summary.ordersCount > 0 + summary.activityCount > 0 ? await this.rulesService.evaluate( [ new RegionalMarketClusterRiskAsiaPacific( @@ -1981,6 +1981,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(), @@ -2008,9 +2011,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 cf55b8862..87c82fa0b 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -394,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/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/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts index b9be5553e..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 @@ -70,7 +73,7 @@ export class YahooFinanceService implements DataProviderInterface { try { const historicalResult = this.convertToDividendResult( - await yahooFinance.chart( + await this.yahooFinance.chart( this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( symbol ), @@ -119,7 +122,7 @@ export class YahooFinanceService implements DataProviderInterface { try { const historicalResult = this.convertToHistoricalResult( - await yahooFinance.chart( + await this.yahooFinance.chart( this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( symbol ), @@ -188,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'); @@ -244,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' && @@ -276,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; }) @@ -336,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/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/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index ab3468dcd..f10b6dc02 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 @@ -257,20 +257,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 977c8a372..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,105 +1,135 @@
    -
    +

    Data Providers

    - - - @for (dataProvider of dataProviders; track dataProvider.name) { -
    - @if (dataProvider.name === 'Ghostfolio') { -
    -
    - + + + Name + + +
    + +
    + @if (isGhostfolioDataProvider(element)) { + + Ghostfolio Premium + -
    - Early Access - Ghostfolio Premium - - @if (isGhostfolioApiKeyValid === false) { - Early Access - } - - @if (isGhostfolioApiKeyValid === true) { -
    - - Valid until - {{ - ghostfolioApiStatus?.subscription?.expiresAt - | date: defaultDateFormat - }} -
    - } -
    -
    -
    -
    + } + @if (isGhostfolioApiKeyValid === true) { -
    -
    - {{ ghostfolioApiStatus.dailyRequests }} - of - {{ ghostfolioApiStatus.dailyRequestsMax }} - daily requests -
    - - - - +
    + + Valid until + {{ + ghostfolioApiStatus?.subscription?.expiresAt + | date: defaultDateFormat + }} +
    - } @else if (isGhostfolioApiKeyValid === false) { - } -
    - } @else { -
    -
    - - {{ dataProvider.name }} -
    -
    -
    - } + } @else { + {{ element.name }} + } +
    - } - - + + + + + + Asset Profiles + + + {{ element.assetProfileCount }} + + + + + + + @if (isGhostfolioDataProvider(element)) { + @if (isGhostfolioApiKeyValid === true) { + + + {{ ghostfolioApiStatus.dailyRequests }} + of + {{ ghostfolioApiStatus.dailyRequestsMax }} + daily requests + + } + } + + + + + + + + @if (isGhostfolioDataProvider(element)) { + @if (isGhostfolioApiKeyValid === true) { + + + + + } @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 68c196962..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 @@ -22,6 +22,7 @@ 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'; @@ -36,10 +37,12 @@ import { GhostfolioPremiumApiDialogParams } from './ghostfolio-premium-api-dialo standalone: false }) export class AdminSettingsComponent implements OnDestroy, OnInit { - public dataProviders: DataProviderInfo[]; + 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; @@ -83,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: () => { @@ -125,14 +132,20 @@ export class AdminSettingsComponent implements OnDestroy, OnInit { } private initialize() { + this.isLoading = true; + + this.dataSource = new MatTableDataSource(); + this.adminService .fetchAdminData() .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ dataProviders, settings }) => { - this.dataProviders = dataProviders.filter(({ dataSource }) => { + const filteredProviders = dataProviders.filter(({ dataSource }) => { return dataSource !== 'MANUAL'; }); + this.dataSource = new MatTableDataSource(filteredProviders); + this.adminService .fetchGhostfolioDataProviderStatus( settings[PROPERTY_API_KEY_GHOSTFOLIO] as string @@ -157,6 +170,8 @@ export class AdminSettingsComponent implements OnDestroy, OnInit { this.changeDetectorRef.markForCheck(); }); + 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 79b269a62..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 @@ -6,9 +6,11 @@ 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'; @@ -21,8 +23,10 @@ import { AdminSettingsComponent } from './admin-settings.component'; GfAssetProfileIconComponent, GfPremiumIndicatorComponent, MatButtonModule, - MatCardModule, MatMenuModule, + MatProgressBarModule, + MatTableModule, + NgxSkeletonLoaderModule, RouterModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA] 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 f69579ab8..5979d2778 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,108 +1,87 @@ -
    -
    -
    - - - - - - + +
    - Name - - {{ element.name }} -
    + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - -
    + Name + + {{ element.name }} + - User - - {{ element.userId }} - + User + + {{ element.userId }} + - Activities - - {{ element.activityCount }} - + Activities + + {{ element.activityCount }} + - - - -
    - -
    -
    + + + +
    + +
    +
    -
    -
    -
    + + + 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.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html index d18dc479b..11898c44e 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 @@ -259,11 +259,11 @@
    @@ -271,11 +271,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/portfolio-summary/portfolio-summary.component.html b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html index 1a52bd646..265904b88 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/user-account-settings/user-account-settings.html b/apps/client/src/app/components/user-account-settings/user-account-settings.html index 72d5aa678..e6ab544c8 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.html +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.html @@ -86,12 +86,10 @@ >) } - @if (user?.settings?.isExperimentalFeatures) { - Chinese (Community) - } + Chinese (Community) Español (Community) 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/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 dce045a4a..5f651195a 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(); @@ -210,7 +213,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { this.activityForm.get('type').value ) ) { - this.updateSymbol(); + this.updateAssetProfile(); } this.changeDetectorRef.markForCheck(); @@ -397,7 +400,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, @@ -422,7 +425,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { this.unsubscribeSubject.complete(); } - private updateSymbol() { + private updateAssetProfile() { this.isLoading = true; this.changeDetectorRef.markForCheck(); @@ -450,6 +453,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 b0521530f..08e1b5162 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 @@ -230,8 +230,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) ) {