Browse Source

Feature/support manual currency for fee (#1490)

* Support manual currency for fee

* Update changelog
pull/1491/head
Thomas Kaul 2 years ago
committed by GitHub
parent
commit
292d345ce0
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      CHANGELOG.md
  2. 2
      apps/api/src/app/app.module.ts
  3. 26
      apps/api/src/app/exchange-rate/exchange-rate.controller.ts
  4. 13
      apps/api/src/app/exchange-rate/exchange-rate.module.ts
  5. 29
      apps/api/src/app/exchange-rate/exchange-rate.service.ts
  6. 1
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  7. 11
      apps/api/src/app/symbol/symbol.controller.ts
  8. 9
      apps/api/src/app/symbol/symbol.service.ts
  9. 16
      apps/api/src/services/data-provider/data-provider.service.ts
  10. 6
      apps/api/src/services/exchange-rate-data.module.ts
  11. 51
      apps/api/src/services/exchange-rate-data.service.ts
  12. 11
      apps/api/src/services/market-data.service.ts
  13. 46
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  14. 17
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  15. 2
      apps/client/src/app/services/admin.service.ts
  16. 20
      apps/client/src/app/services/data.service.ts

1
CHANGELOG.md

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Supported a note for asset profiles - Supported a note for asset profiles
- Supported a manual currency for the activity fee
### Changed ### Changed

2
apps/api/src/app/app.module.ts

@ -22,6 +22,7 @@ import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module'; import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { FrontendMiddleware } from './frontend.middleware'; import { FrontendMiddleware } from './frontend.middleware';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
@ -52,6 +53,7 @@ import { UserModule } from './user/user.module';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ExportModule, ExportModule,
ImportModule, ImportModule,

26
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<IDataProviderHistoricalResponse> {
const date = new Date(dateString);
return this.exchangeRateService.getExchangeRate({
date,
symbol
});
}
}

13
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 {}

29
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<IDataProviderHistoricalResponse> {
const [currency1, currency2] = symbol.split('-');
const marketPrice = await this.exchangeRateDataService.toCurrencyAtDate(
1,
currency1,
currency2,
date
);
return { marketPrice };
}
}

1
apps/api/src/app/portfolio/current-rate.service.spec.ts

@ -78,6 +78,7 @@ describe('CurrentRateService', () => {
null, null,
null, null,
null, null,
null,
null null
); );
marketDataService = new MarketDataService(null); marketDataService = new MarketDataService(null);

11
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, dataSource,
date, date,
symbol symbol
}); });
if (!result || isEmpty(result)) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return result;
} }
} }

9
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 { DATE_FORMAT } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
@ -65,13 +64,9 @@ export class SymbolService {
public async getForDate({ public async getForDate({
dataSource, dataSource,
date, date = new Date(),
symbol symbol
}: { }: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> {
dataSource: DataSource;
date: Date;
symbol: string;
}): Promise<IDataProviderHistoricalResponse> {
const historicalData = await this.dataProviderService.getHistoricalRaw( const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }], [{ dataSource, symbol }],
date, date,

16
apps/api/src/services/data-provider/data-provider.service.ts

@ -114,9 +114,13 @@ export class DataProviderService {
} }
} }
const allData = await Promise.all(promises); try {
for (const { data, symbol } of allData) { const allData = await Promise.all(promises);
result[symbol] = data; for (const { data, symbol } of allData) {
result[symbol] = data;
}
} catch (error) {
Logger.error(error, 'DataProviderService');
} }
return result; return result;
@ -209,7 +213,9 @@ export class DataProviderService {
} }
Logger.debug( Logger.debug(
`Fetched ${symbolsChunk.length} quotes from ${dataSource} in ${( `Fetched ${symbolsChunk.length} quote${
symbolsChunk.length > 1 ? 's' : ''
} from ${dataSource} in ${(
(performance.now() - startTimeDataSource) / (performance.now() - startTimeDataSource) /
1000 1000
).toFixed(3)} seconds` ).toFixed(3)} seconds`
@ -223,7 +229,7 @@ export class DataProviderService {
Logger.debug('------------------------------------------------'); Logger.debug('------------------------------------------------');
Logger.debug( Logger.debug(
`Fetched ${items.length} quotes in ${( `Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${(
(performance.now() - startTimeTotal) / (performance.now() - startTimeTotal) /
1000 1000
).toFixed(3)} seconds` ).toFixed(3)} seconds`

6
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 { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MarketDataModule } from './market-data.module';
import { PrismaModule } from './prisma.module'; import { PrismaModule } from './prisma.module';
@Module({ @Module({
exports: [ExchangeRateDataService],
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule PropertyModule
], ],
providers: [ExchangeRateDataService], providers: [ExchangeRateDataService]
exports: [ExchangeRateDataService]
}) })
export class ExchangeRateDataModule {} export class ExchangeRateDataModule {}

51
apps/api/src/services/exchange-rate-data.service.ts

@ -1,12 +1,13 @@
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format } from 'date-fns'; import { format, isToday } from 'date-fns';
import { isNumber, uniq } from 'lodash'; import { isNumber, uniq } from 'lodash';
import { ConfigurationService } from './configuration.service'; import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces'; import { IDataGatheringItem } from './interfaces/interfaces';
import { MarketDataService } from './market-data.service';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
import { PropertyService } from './property/property.service'; import { PropertyService } from './property/property.service';
@ -20,6 +21,7 @@ export class ExchangeRateDataService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
@ -152,6 +154,53 @@ export class ExchangeRateDataService {
return aValue; 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<string[]> { private async prepareCurrencies(): Promise<string[]> {
let currencies: string[] = []; let currencies: string[] = [];

11
apps/api/src/services/market-data.service.ts

@ -6,6 +6,8 @@ import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, MarketData, Prisma } from '@prisma/client'; import { DataSource, MarketData, Prisma } from '@prisma/client';
import { IDataGatheringItem } from './interfaces/interfaces';
@Injectable() @Injectable()
export class MarketDataService { export class MarketDataService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
@ -20,14 +22,13 @@ export class MarketDataService {
} }
public async get({ public async get({
date, dataSource,
date = new Date(),
symbol symbol
}: { }: IDataGatheringItem): Promise<MarketData> {
date: Date;
symbol: string;
}): Promise<MarketData> {
return await this.prismaService.marketData.findFirst({ return await this.prismaService.marketData.findFirst({
where: { where: {
dataSource,
symbol, symbol,
date: resetHours(date) date: resetHours(date)
} }

46
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 { AssetClass, AssetSubClass, Type } from '@prisma/client';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
import { EMPTY, Observable, Subject } from 'rxjs'; import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs';
import { import {
catchError, catchError,
debounceTime, debounceTime,
@ -86,12 +86,17 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.data.activity?.SymbolProfile?.currency, this.data.activity?.SymbolProfile?.currency,
Validators.required Validators.required
], ],
currencyOfFee: [
this.data.activity?.SymbolProfile?.currency,
Validators.required
],
dataSource: [ dataSource: [
this.data.activity?.SymbolProfile?.dataSource, this.data.activity?.SymbolProfile?.dataSource,
Validators.required Validators.required
], ],
date: [this.data.activity?.date, Validators.required], date: [this.data.activity?.date, Validators.required],
fee: [this.data.activity?.fee, Validators.required], fee: [this.data.activity?.fee, Validators.required],
feeInCustomCurrency: [this.data.activity?.fee, Validators.required],
name: [this.data.activity?.SymbolProfile?.name, Validators.required], name: [this.data.activity?.SymbolProfile?.name, Validators.required],
quantity: [this.data.activity?.quantity, Validators.required], quantity: [this.data.activity?.quantity, Validators.required],
searchSymbol: [ searchSymbol: [
@ -108,7 +113,36 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.valueChanges this.activityForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject)) .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 ( if (
this.activityForm.controls['type'].value === 'BUY' || this.activityForm.controls['type'].value === 'BUY' ||
this.activityForm.controls['type'].value === 'ITEM' this.activityForm.controls['type'].value === 'ITEM'
@ -123,6 +157,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.controls['unitPrice'].value - this.activityForm.controls['unitPrice'].value -
this.activityForm.controls['fee'].value ?? 0; this.activityForm.controls['fee'].value ?? 0;
} }
this.changeDetectorRef.markForCheck();
}); });
this.filteredLookupItemsObservable = this.activityForm.controls[ this.filteredLookupItemsObservable = this.activityForm.controls[
@ -160,6 +196,9 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.controls['currency'].setValue( this.activityForm.controls['currency'].setValue(
this.data.user.settings.baseCurrency this.data.user.settings.baseCurrency
); );
this.activityForm.controls['currencyOfFee'].setValue(
this.data.user.settings.baseCurrency
);
this.activityForm.controls['dataSource'].removeValidators( this.activityForm.controls['dataSource'].removeValidators(
Validators.required Validators.required
); );
@ -189,6 +228,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
); );
this.activityForm.controls['searchSymbol'].updateValueAndValidity(); this.activityForm.controls['searchSymbol'].updateValueAndValidity();
} }
this.changeDetectorRef.markForCheck();
}); });
this.activityForm.controls['type'].setValue(this.data.activity?.type); this.activityForm.controls['type'].setValue(this.data.activity?.type);
@ -313,6 +354,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
) )
.subscribe(({ currency, dataSource, marketPrice }) => { .subscribe(({ currency, dataSource, marketPrice }) => {
this.activityForm.controls['currency'].setValue(currency); this.activityForm.controls['currency'].setValue(currency);
this.activityForm.controls['currencyOfFee'].setValue(currency);
this.activityForm.controls['dataSource'].setValue(dataSource); this.activityForm.controls['dataSource'].setValue(dataSource);
this.currentMarketPrice = marketPrice; this.currentMarketPrice = marketPrice;

17
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -127,6 +127,23 @@
</mat-form-field> </mat-form-field>
</div> </div>
<div> <div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label>
<input formControlName="feeInCustomCurrency" matInput type="number" />
<div
class="ml-2"
matSuffix
[ngClass]="{ 'd-none': !activityForm.controls['currency']?.value }"
>
<mat-select formControlName="currencyOfFee">
<mat-option *ngFor="let currency of currencies" [value]="currency">
{{ currency }}
</mat-option>
</mat-select>
</div>
</mat-form-field>
</div>
<div class="d-none">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label> <mat-label i18n>Fee</mat-label>
<input formControlName="fee" matInput type="number" /> <input formControlName="fee" matInput type="number" />

2
apps/client/src/app/services/admin.service.ts

@ -1,7 +1,7 @@
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core'; 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 { 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 { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {

20
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 { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface';
import { UserItem } from '@ghostfolio/api/app/user/interfaces/user-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 { 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 { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
@ -36,12 +37,7 @@ import {
import { filterGlobalPermissions } from '@ghostfolio/common/permissions'; import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
import { AccountWithValue, DateRange } from '@ghostfolio/common/types'; import { AccountWithValue, DateRange } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { import { DataSource, Order as OrderModel } from '@prisma/client';
AssetClass,
AssetSubClass,
DataSource,
Order as OrderModel
} from '@prisma/client';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { cloneDeep, groupBy } from 'lodash'; import { cloneDeep, groupBy } from 'lodash';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@ -104,6 +100,18 @@ export class DataService {
}); });
} }
public fetchExchangeRateForDate({
date,
symbol
}: {
date: Date;
symbol: string;
}) {
return this.http.get<IDataProviderHistoricalResponse>(
`/api/v1/exchange-rate/${symbol}/${format(date, DATE_FORMAT)}`
);
}
public deleteAccess(aId: string) { public deleteAccess(aId: string) {
return this.http.delete<any>(`/api/v1/access/${aId}`); return this.http.delete<any>(`/api/v1/access/${aId}`);
} }

Loading…
Cancel
Save