From 292d345ce0331ff8dfb89d7851361070da5404b1 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Dec 2022 18:22:19 +0100 Subject: [PATCH] Feature/support manual currency for fee (#1490) * Support manual currency for fee * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/app.module.ts | 2 + .../exchange-rate/exchange-rate.controller.ts | 26 ++++++++++ .../app/exchange-rate/exchange-rate.module.ts | 13 +++++ .../exchange-rate/exchange-rate.service.ts | 29 +++++++++++ .../portfolio/current-rate.service.spec.ts | 1 + apps/api/src/app/symbol/symbol.controller.ts | 11 +++- apps/api/src/app/symbol/symbol.service.ts | 9 +--- .../data-provider/data-provider.service.ts | 16 ++++-- .../src/services/exchange-rate-data.module.ts | 6 ++- .../services/exchange-rate-data.service.ts | 51 ++++++++++++++++++- apps/api/src/services/market-data.service.ts | 11 ++-- ...ate-or-update-activity-dialog.component.ts | 46 ++++++++++++++++- .../create-or-update-activity-dialog.html | 17 +++++++ apps/client/src/app/services/admin.service.ts | 2 +- apps/client/src/app/services/data.service.ts | 20 +++++--- 16 files changed, 231 insertions(+), 30 deletions(-) create mode 100644 apps/api/src/app/exchange-rate/exchange-rate.controller.ts create mode 100644 apps/api/src/app/exchange-rate/exchange-rate.module.ts create mode 100644 apps/api/src/app/exchange-rate/exchange-rate.service.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b284110b1..69f113b50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Supported a note for asset profiles +- Supported a manual currency for the activity fee ### Changed diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index e41b60b0e..47c19991c 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -22,6 +22,7 @@ import { AppController } from './app.controller'; import { AuthModule } from './auth/auth.module'; import { BenchmarkModule } from './benchmark/benchmark.module'; import { CacheModule } from './cache/cache.module'; +import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExportModule } from './export/export.module'; import { FrontendMiddleware } from './frontend.middleware'; import { ImportModule } from './import/import.module'; @@ -52,6 +53,7 @@ import { UserModule } from './user/user.module'; ConfigurationModule, DataGatheringModule, DataProviderModule, + ExchangeRateModule, ExchangeRateDataModule, ExportModule, ImportModule, diff --git a/apps/api/src/app/exchange-rate/exchange-rate.controller.ts b/apps/api/src/app/exchange-rate/exchange-rate.controller.ts new file mode 100644 index 000000000..75a6f57b4 --- /dev/null +++ b/apps/api/src/app/exchange-rate/exchange-rate.controller.ts @@ -0,0 +1,26 @@ +import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; +import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +import { ExchangeRateService } from './exchange-rate.service'; + +@Controller('exchange-rate') +export class ExchangeRateController { + public constructor( + private readonly exchangeRateService: ExchangeRateService + ) {} + + @Get(':symbol/:dateString') + @UseGuards(AuthGuard('jwt')) + public async getExchangeRate( + @Param('dateString') dateString: string, + @Param('symbol') symbol: string + ): Promise { + const date = new Date(dateString); + + return this.exchangeRateService.getExchangeRate({ + date, + symbol + }); + } +} diff --git a/apps/api/src/app/exchange-rate/exchange-rate.module.ts b/apps/api/src/app/exchange-rate/exchange-rate.module.ts new file mode 100644 index 000000000..04337d010 --- /dev/null +++ b/apps/api/src/app/exchange-rate/exchange-rate.module.ts @@ -0,0 +1,13 @@ +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; +import { Module } from '@nestjs/common'; + +import { ExchangeRateController } from './exchange-rate.controller'; +import { ExchangeRateService } from './exchange-rate.service'; + +@Module({ + controllers: [ExchangeRateController], + exports: [ExchangeRateService], + imports: [ExchangeRateDataModule], + providers: [ExchangeRateService] +}) +export class ExchangeRateModule {} diff --git a/apps/api/src/app/exchange-rate/exchange-rate.service.ts b/apps/api/src/app/exchange-rate/exchange-rate.service.ts new file mode 100644 index 000000000..be7f3d55f --- /dev/null +++ b/apps/api/src/app/exchange-rate/exchange-rate.service.ts @@ -0,0 +1,29 @@ +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; +import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ExchangeRateService { + public constructor( + private readonly exchangeRateDataService: ExchangeRateDataService + ) {} + + public async getExchangeRate({ + date, + symbol + }: { + date: Date; + symbol: string; + }): Promise { + const [currency1, currency2] = symbol.split('-'); + + const marketPrice = await this.exchangeRateDataService.toCurrencyAtDate( + 1, + currency1, + currency2, + date + ); + + return { marketPrice }; + } +} diff --git a/apps/api/src/app/portfolio/current-rate.service.spec.ts b/apps/api/src/app/portfolio/current-rate.service.spec.ts index 2ef8ad5fa..9528f980f 100644 --- a/apps/api/src/app/portfolio/current-rate.service.spec.ts +++ b/apps/api/src/app/portfolio/current-rate.service.spec.ts @@ -78,6 +78,7 @@ describe('CurrentRateService', () => { null, null, null, + null, null ); marketDataService = new MarketDataService(null); diff --git a/apps/api/src/app/symbol/symbol.controller.ts b/apps/api/src/app/symbol/symbol.controller.ts index dd50e0dee..249284ea8 100644 --- a/apps/api/src/app/symbol/symbol.controller.ts +++ b/apps/api/src/app/symbol/symbol.controller.ts @@ -91,10 +91,19 @@ export class SymbolController { ); } - return this.symbolService.getForDate({ + const result = await this.symbolService.getForDate({ dataSource, date, symbol }); + + if (!result || isEmpty(result)) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return result; } } diff --git a/apps/api/src/app/symbol/symbol.service.ts b/apps/api/src/app/symbol/symbol.service.ts index 0575cfda1..7a5f5586d 100644 --- a/apps/api/src/app/symbol/symbol.service.ts +++ b/apps/api/src/app/symbol/symbol.service.ts @@ -7,7 +7,6 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service' import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; import { Injectable, Logger } from '@nestjs/common'; -import { DataSource } from '@prisma/client'; import { format, subDays } from 'date-fns'; import { LookupItem } from './interfaces/lookup-item.interface'; @@ -65,13 +64,9 @@ export class SymbolService { public async getForDate({ dataSource, - date, + date = new Date(), symbol - }: { - dataSource: DataSource; - date: Date; - symbol: string; - }): Promise { + }: IDataGatheringItem): Promise { const historicalData = await this.dataProviderService.getHistoricalRaw( [{ dataSource, symbol }], date, 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 7f10dc3a0..7092e1112 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -114,9 +114,13 @@ export class DataProviderService { } } - const allData = await Promise.all(promises); - for (const { data, symbol } of allData) { - result[symbol] = data; + try { + const allData = await Promise.all(promises); + for (const { data, symbol } of allData) { + result[symbol] = data; + } + } catch (error) { + Logger.error(error, 'DataProviderService'); } return result; @@ -209,7 +213,9 @@ export class DataProviderService { } Logger.debug( - `Fetched ${symbolsChunk.length} quotes from ${dataSource} in ${( + `Fetched ${symbolsChunk.length} quote${ + symbolsChunk.length > 1 ? 's' : '' + } from ${dataSource} in ${( (performance.now() - startTimeDataSource) / 1000 ).toFixed(3)} seconds` @@ -223,7 +229,7 @@ export class DataProviderService { Logger.debug('------------------------------------------------'); Logger.debug( - `Fetched ${items.length} quotes in ${( + `Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${( (performance.now() - startTimeTotal) / 1000 ).toFixed(3)} seconds` diff --git a/apps/api/src/services/exchange-rate-data.module.ts b/apps/api/src/services/exchange-rate-data.module.ts index 8b8eeee28..fbbaa98a6 100644 --- a/apps/api/src/services/exchange-rate-data.module.ts +++ b/apps/api/src/services/exchange-rate-data.module.ts @@ -4,16 +4,18 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { Module } from '@nestjs/common'; +import { MarketDataModule } from './market-data.module'; import { PrismaModule } from './prisma.module'; @Module({ + exports: [ExchangeRateDataService], imports: [ ConfigurationModule, DataProviderModule, + MarketDataModule, PrismaModule, PropertyModule ], - providers: [ExchangeRateDataService], - exports: [ExchangeRateDataService] + providers: [ExchangeRateDataService] }) export class ExchangeRateDataModule {} diff --git a/apps/api/src/services/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data.service.ts index 60a7e0e56..cbaa38af0 100644 --- a/apps/api/src/services/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data.service.ts @@ -1,12 +1,13 @@ import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { Injectable, Logger } from '@nestjs/common'; -import { format } from 'date-fns'; +import { format, isToday } from 'date-fns'; import { isNumber, uniq } from 'lodash'; import { ConfigurationService } from './configuration.service'; import { DataProviderService } from './data-provider/data-provider.service'; import { IDataGatheringItem } from './interfaces/interfaces'; +import { MarketDataService } from './market-data.service'; import { PrismaService } from './prisma.service'; import { PropertyService } from './property/property.service'; @@ -20,6 +21,7 @@ export class ExchangeRateDataService { public constructor( private readonly configurationService: ConfigurationService, private readonly dataProviderService: DataProviderService, + private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService ) {} @@ -152,6 +154,53 @@ export class ExchangeRateDataService { return aValue; } + public async toCurrencyAtDate( + aValue: number, + aFromCurrency: string, + aToCurrency: string, + aDate: Date + ) { + if (aValue === 0) { + return 0; + } + + if (isToday(aDate)) { + return this.toCurrency(aValue, aFromCurrency, aToCurrency); + } + + let factor = 1; + + if (aFromCurrency !== aToCurrency) { + const dataSource = this.dataProviderService.getPrimaryDataSource(); + const symbol = `${aFromCurrency}${aToCurrency}`; + + const marketData = await this.marketDataService.get({ + dataSource, + symbol, + date: aDate + }); + + if (marketData?.marketPrice) { + factor = marketData?.marketPrice; + } else { + // TODO: Get from data provider service or calculate indirectly via base currency + // and market data + return this.toCurrency(aValue, aFromCurrency, aToCurrency); + } + } + + if (isNumber(factor) && !isNaN(factor)) { + return factor * aValue; + } + + // Fallback with error, if currencies are not available + Logger.error( + `No exchange rate has been found for ${aFromCurrency}${aToCurrency}`, + 'ExchangeRateDataService' + ); + return aValue; + } + private async prepareCurrencies(): Promise { let currencies: string[] = []; diff --git a/apps/api/src/services/market-data.service.ts b/apps/api/src/services/market-data.service.ts index 9dd3e4773..fbea8e104 100644 --- a/apps/api/src/services/market-data.service.ts +++ b/apps/api/src/services/market-data.service.ts @@ -6,6 +6,8 @@ import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; import { DataSource, MarketData, Prisma } from '@prisma/client'; +import { IDataGatheringItem } from './interfaces/interfaces'; + @Injectable() export class MarketDataService { public constructor(private readonly prismaService: PrismaService) {} @@ -20,14 +22,13 @@ export class MarketDataService { } public async get({ - date, + dataSource, + date = new Date(), symbol - }: { - date: Date; - symbol: string; - }): Promise { + }: IDataGatheringItem): Promise { return await this.prismaService.marketData.findFirst({ where: { + dataSource, symbol, date: resetHours(date) } 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 30d7e64c8..369f3121d 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 @@ -18,7 +18,7 @@ import { translate } from '@ghostfolio/ui/i18n'; import { AssetClass, AssetSubClass, Type } from '@prisma/client'; import { isUUID } from 'class-validator'; import { isString } from 'lodash'; -import { EMPTY, Observable, Subject } from 'rxjs'; +import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs'; import { catchError, debounceTime, @@ -86,12 +86,17 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { this.data.activity?.SymbolProfile?.currency, Validators.required ], + currencyOfFee: [ + this.data.activity?.SymbolProfile?.currency, + Validators.required + ], dataSource: [ this.data.activity?.SymbolProfile?.dataSource, Validators.required ], date: [this.data.activity?.date, Validators.required], fee: [this.data.activity?.fee, Validators.required], + feeInCustomCurrency: [this.data.activity?.fee, Validators.required], name: [this.data.activity?.SymbolProfile?.name, Validators.required], quantity: [this.data.activity?.quantity, Validators.required], searchSymbol: [ @@ -108,7 +113,36 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { this.activityForm.valueChanges .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { + .subscribe(async () => { + let exchangeRate = 1; + + const currency = this.activityForm.controls['currency'].value; + const currencyOfFee = this.activityForm.controls['currencyOfFee'].value; + const date = this.activityForm.controls['date'].value; + + if (currency && currencyOfFee && currency !== currencyOfFee && date) { + try { + const { marketPrice } = await lastValueFrom( + this.dataService + .fetchExchangeRateForDate({ + date, + symbol: `${currencyOfFee}-${currency}` + }) + .pipe(takeUntil(this.unsubscribeSubject)) + ); + + exchangeRate = marketPrice; + } catch {} + } + + const feeInCustomCurrency = + this.activityForm.controls['feeInCustomCurrency'].value * + exchangeRate; + + this.activityForm.controls['fee'].setValue(feeInCustomCurrency, { + emitEvent: false + }); + if ( this.activityForm.controls['type'].value === 'BUY' || this.activityForm.controls['type'].value === 'ITEM' @@ -123,6 +157,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { this.activityForm.controls['unitPrice'].value - this.activityForm.controls['fee'].value ?? 0; } + + this.changeDetectorRef.markForCheck(); }); this.filteredLookupItemsObservable = this.activityForm.controls[ @@ -160,6 +196,9 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { this.activityForm.controls['currency'].setValue( this.data.user.settings.baseCurrency ); + this.activityForm.controls['currencyOfFee'].setValue( + this.data.user.settings.baseCurrency + ); this.activityForm.controls['dataSource'].removeValidators( Validators.required ); @@ -189,6 +228,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { ); this.activityForm.controls['searchSymbol'].updateValueAndValidity(); } + + this.changeDetectorRef.markForCheck(); }); this.activityForm.controls['type'].setValue(this.data.activity?.type); @@ -313,6 +354,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { ) .subscribe(({ currency, dataSource, marketPrice }) => { this.activityForm.controls['currency'].setValue(currency); + this.activityForm.controls['currencyOfFee'].setValue(currency); this.activityForm.controls['dataSource'].setValue(dataSource); this.currentMarketPrice = marketPrice; 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 45d34a47c..7ecb35b44 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 @@ -127,6 +127,23 @@
+ + Fee + +
+ + + {{ currency }} + + +
+
+
+
Fee diff --git a/apps/client/src/app/services/admin.service.ts b/apps/client/src/app/services/admin.service.ts index 07ed3a75c..50e66fd39 100644 --- a/apps/client/src/app/services/admin.service.ts +++ b/apps/client/src/app/services/admin.service.ts @@ -1,7 +1,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto'; import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto'; +import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 6bb4b0dba..fc98da0fa 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -12,6 +12,7 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface'; import { UserItem } from '@ghostfolio/api/app/user/interfaces/user-item.interface'; import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto'; +import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { @@ -36,12 +37,7 @@ import { import { filterGlobalPermissions } from '@ghostfolio/common/permissions'; import { AccountWithValue, DateRange } from '@ghostfolio/common/types'; import { translate } from '@ghostfolio/ui/i18n'; -import { - AssetClass, - AssetSubClass, - DataSource, - Order as OrderModel -} from '@prisma/client'; +import { DataSource, Order as OrderModel } from '@prisma/client'; import { format, parseISO } from 'date-fns'; import { cloneDeep, groupBy } from 'lodash'; import { Observable } from 'rxjs'; @@ -104,6 +100,18 @@ export class DataService { }); } + public fetchExchangeRateForDate({ + date, + symbol + }: { + date: Date; + symbol: string; + }) { + return this.http.get( + `/api/v1/exchange-rate/${symbol}/${format(date, DATE_FORMAT)}` + ); + } + public deleteAccess(aId: string) { return this.http.delete(`/api/v1/access/${aId}`); }