diff --git a/CHANGELOG.md b/CHANGELOG.md index bb5418b82..596c07ee6 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 - Added a line chart to the historical data view in the admin control panel +- Supported the update of historical data in the admin control panel ## 1.91.0 - 18.12.2021 diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 2c2130da3..52ec4998c 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -1,6 +1,6 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; -import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { AdminData, AdminMarketData, @@ -22,16 +22,18 @@ import { import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { DataSource, MarketData } from '@prisma/client'; -import { isDate, isValid } from 'date-fns'; +import { isDate } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AdminService } from './admin.service'; +import { UpdateMarketDataDto } from './update-market-data.dto'; @Controller('admin') export class AdminController { public constructor( private readonly adminService: AdminService, private readonly dataGatheringService: DataGatheringService, + private readonly marketDataService: MarketDataService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} @@ -173,7 +175,7 @@ export class AdminController { @Get('market-data/:symbol') @UseGuards(AuthGuard('jwt')) public async getMarketDataBySymbol( - @Param('symbol') symbol + @Param('symbol') symbol: string ): Promise { if ( !hasPermission( @@ -190,6 +192,39 @@ export class AdminController { return this.adminService.getMarketDataBySymbol(symbol); } + @Put('market-data/:dataSource/:symbol/:dateString') + @UseGuards(AuthGuard('jwt')) + public async update( + @Param('dataSource') dataSource: DataSource, + @Param('dateString') dateString: string, + @Param('symbol') symbol: string, + @Body() data: UpdateMarketDataDto + ) { + if ( + !hasPermission( + this.request.user.permissions, + permissions.accessAdminControl + ) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + const date = new Date(dateString); + + return this.marketDataService.updateMarketData({ + data, + where: { + date_symbol: { + date, + symbol + } + } + }); + } + @Put('settings/:key') @UseGuards(AuthGuard('jwt')) public async updateProperty( diff --git a/apps/api/src/app/admin/update-market-data.dto.ts b/apps/api/src/app/admin/update-market-data.dto.ts new file mode 100644 index 000000000..79779a318 --- /dev/null +++ b/apps/api/src/app/admin/update-market-data.dto.ts @@ -0,0 +1,6 @@ +import { IsNumber } from 'class-validator'; + +export class UpdateMarketDataDto { + @IsNumber() + marketPrice: number; +} diff --git a/apps/api/src/app/symbol/symbol.controller.ts b/apps/api/src/app/symbol/symbol.controller.ts index a364de6bc..a5c31fe6a 100644 --- a/apps/api/src/app/symbol/symbol.controller.ts +++ b/apps/api/src/app/symbol/symbol.controller.ts @@ -1,3 +1,4 @@ +import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { Controller, @@ -12,9 +13,9 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import { DataSource } from '@prisma/client'; +import { DataSource, MarketData } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; -import { isEmpty } from 'lodash'; +import { isDate, isEmpty } from 'lodash'; import { LookupItem } from './interfaces/lookup-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface'; @@ -78,4 +79,27 @@ export class SymbolController { return result; } + + @Get(':dataSource/:symbol/:dateString') + @UseGuards(AuthGuard('jwt')) + public async gatherSymbolForDate( + @Param('dataSource') dataSource: DataSource, + @Param('dateString') dateString: string, + @Param('symbol') symbol: string + ): Promise { + const date = new Date(dateString); + + if (!isDate(date)) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + + return this.symbolService.getForDate({ + dataSource, + date, + symbol + }); + } } diff --git a/apps/api/src/app/symbol/symbol.service.ts b/apps/api/src/app/symbol/symbol.service.ts index cdd13da8d..2649ef5d0 100644 --- a/apps/api/src/app/symbol/symbol.service.ts +++ b/apps/api/src/app/symbol/symbol.service.ts @@ -1,11 +1,15 @@ import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; -import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { + IDataGatheringItem, + IDataProviderHistoricalResponse +} from '@ghostfolio/api/services/interfaces/interfaces'; import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { Injectable, Logger } from '@nestjs/common'; -import { DataSource } from '@prisma/client'; -import { subDays } from 'date-fns'; +import { DataSource, MarketData } from '@prisma/client'; +import { format, subDays } from 'date-fns'; import { LookupItem } from './interfaces/lookup-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface'; @@ -58,6 +62,27 @@ export class SymbolService { return undefined; } + public async getForDate({ + dataSource, + date, + symbol + }: { + dataSource: DataSource; + date: Date; + symbol: string; + }): Promise { + const historicalData = await this.dataProviderService.getHistoricalRaw( + [{ dataSource, symbol }], + date, + date + ); + + return { + marketPrice: + historicalData?.[symbol]?.[format(date, DATE_FORMAT)]?.marketPrice + }; + } + public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> { const results: { items: LookupItem[] } = { items: [] }; diff --git a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.spec.ts b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.spec.ts index 414a83dd6..c07be06bf 100644 --- a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.spec.ts +++ b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.spec.ts @@ -1,4 +1,5 @@ import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; + import { YahooFinanceService } from './yahoo-finance.service'; jest.mock( 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 c9d5081d8..8d375a10e 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 @@ -1,6 +1,6 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; -import { baseCurrency, UNKNOWN_KEY } from '@ghostfolio/common/config'; +import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config'; import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { Granularity } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; diff --git a/apps/api/src/services/market-data.service.ts b/apps/api/src/services/market-data.service.ts index f9c7fc003..66a1fe50a 100644 --- a/apps/api/src/services/market-data.service.ts +++ b/apps/api/src/services/market-data.service.ts @@ -65,4 +65,16 @@ export class MarketDataService { where }); } + + public async updateMarketData(params: { + data: Prisma.MarketDataUpdateInput; + where: Prisma.MarketDataWhereUniqueInput; + }): Promise { + const { data, where } = params; + + return this.prismaService.marketData.update({ + data, + where + }); + } } diff --git a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts index 4ee2b9aea..fa24f5941 100644 --- a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts +++ b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts @@ -1,9 +1,11 @@ import { ChangeDetectionStrategy, Component, + EventEmitter, Input, OnChanges, - OnInit + OnInit, + Output } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; @@ -27,6 +29,8 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit { @Input() marketData: MarketData[]; @Input() symbol: string; + @Output() marketDataChanged = new EventEmitter(); + public days = Array(31); public defaultDateFormat = DEFAULT_DATE_FORMAT; public deviceType: string; @@ -101,7 +105,9 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit { dialogRef .afterClosed() .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => {}); + .subscribe(({ withRefresh }) => { + this.marketDataChanged.next(withRefresh); + }); } public ngOnDestroy() { diff --git a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts index 0ce14aba6..01b1f4718 100644 --- a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts @@ -7,7 +7,6 @@ import { } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { AdminService } from '@ghostfolio/client/services/admin.service'; -import { MarketData } from '@prisma/client'; import { Subject, takeUntil } from 'rxjs'; import { MarketDataDetailDialogParams } from './interfaces/interfaces'; @@ -32,24 +31,38 @@ export class MarketDataDetailDialog implements OnDestroy { public ngOnInit() {} public onCancel(): void { - this.dialogRef.close(); + this.dialogRef.close({ withRefresh: false }); } - public onGatherData() { + public onFetchSymbolForDate() { this.adminService - .gatherSymbol({ + .fetchSymbolForDate({ dataSource: this.data.dataSource, date: this.data.date, symbol: this.data.symbol }) .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((marketData: MarketData) => { - this.data.marketPrice = marketData.marketPrice; + .subscribe(({ marketPrice }) => { + this.data.marketPrice = marketPrice; this.changeDetectorRef.markForCheck(); }); } + public onUpdate() { + this.adminService + .putMarketData({ + dataSource: this.data.dataSource, + date: this.data.date, + marketData: { marketPrice: this.data.marketPrice }, + symbol: this.data.symbol + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.dialogRef.close({ withRefresh: true }); + }); + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); diff --git a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html index 48bb2a7b0..3642a9e1d 100644 --- a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html +++ b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html @@ -21,22 +21,30 @@ -
- +
+ Market Price + -
+
diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts index 256a86f56..87ebfea28 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts @@ -68,6 +68,13 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit { } } + public onMarketDataChanged(withRefresh: boolean = false) { + if (withRefresh) { + this.fetchAdminMarketData(); + this.fetchAdminMarketDataBySymbol(this.currentSymbol); + } + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); 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 5e8083e98..b0df54364 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 @@ -47,6 +47,7 @@ [dataSource]="item.dataSource" [marketData]="marketDataDetails" [symbol]="item.symbol" + (marketDataChanged)="onMarketDataChanged($event)" > diff --git a/apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/create-or-update-transaction-dialog.html b/apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/create-or-update-transaction-dialog.html index 174b2e0be..2d5199ff3 100644 --- a/apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/create-or-update-transaction-dialog.html +++ b/apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/create-or-update-transaction-dialog.html @@ -131,7 +131,7 @@
- + Unit Price (url, {}); } + + public fetchSymbolForDate({ + dataSource, + date, + symbol + }: { + dataSource: DataSource; + date: Date; + symbol: string; + }) { + const url = `/api/symbol/${dataSource}/${symbol}/${format( + date, + DATE_FORMAT + )}`; + + return this.http.get(url); + } + + public putMarketData({ + dataSource, + date, + marketData, + symbol + }: { + dataSource: DataSource; + date: Date; + marketData: UpdateMarketDataDto; + symbol: string; + }) { + const url = `/api/admin/market-data/${dataSource}/${symbol}/${format( + date, + DATE_FORMAT + )}`; + + return this.http.put(url, marketData); + } }