diff --git a/CHANGELOG.md b/CHANGELOG.md index ef017ba04..48fe8d5f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added an error message for duplicates to the preview step of the activities import - Added a connection timeout to the environment variable `DATABASE_URL` - Introduced the _Open Startup_ (`/open`) page with aggregated key metrics including uptime +- POST api to add benchmarks +- Ability to add symbol as benchmark from Market Data ### Changed diff --git a/apps/api/src/app/benchmark/benchmark.controller.ts b/apps/api/src/app/benchmark/benchmark.controller.ts index 4daa14009..473d82b74 100644 --- a/apps/api/src/app/benchmark/benchmark.controller.ts +++ b/apps/api/src/app/benchmark/benchmark.controller.ts @@ -2,23 +2,35 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { BenchmarkMarketDataDetails, - BenchmarkResponse + BenchmarkResponse, + UniqueAsset } from '@ghostfolio/common/interfaces'; import { + Body, Controller, Get, + HttpException, + Inject, Param, + Post, UseGuards, UseInterceptors } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { DataSource } from '@prisma/client'; - +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { BenchmarkService } from './benchmark.service'; +import { REQUEST } from '@nestjs/core'; +import { RequestWithUser } from '@ghostfolio/common/types'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { NotFoundError } from '@ghostfolio/common/exceptions'; @Controller('benchmark') export class BenchmarkController { - public constructor(private readonly benchmarkService: BenchmarkService) {} + public constructor( + private readonly benchmarkService: BenchmarkService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} @Get() @UseInterceptors(TransformDataSourceInRequestInterceptor) @@ -45,4 +57,36 @@ export class BenchmarkController { symbol }); } + + @Post() + @UseGuards(AuthGuard('jwt')) + public async addBenchmark(@Body() benchmark: UniqueAsset) { + if ( + !hasPermission( + this.request.user.permissions, + permissions.accessAdminControl + ) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + try { + return await this.benchmarkService.addBenchmark(benchmark); + } catch (error) { + if (error instanceof NotFoundError) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } else { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } + } } diff --git a/apps/api/src/app/benchmark/benchmark.module.ts b/apps/api/src/app/benchmark/benchmark.module.ts index 71b08c191..eb51588aa 100644 --- a/apps/api/src/app/benchmark/benchmark.module.ts +++ b/apps/api/src/app/benchmark/benchmark.module.ts @@ -9,6 +9,7 @@ import { Module } from '@nestjs/common'; import { BenchmarkController } from './benchmark.controller'; import { BenchmarkService } from './benchmark.service'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; @Module({ controllers: [BenchmarkController], @@ -17,6 +18,7 @@ import { BenchmarkService } from './benchmark.service'; ConfigurationModule, DataProviderModule, MarketDataModule, + PrismaModule, PropertyModule, RedisCacheModule, SymbolModule, diff --git a/apps/api/src/app/benchmark/benchmark.service.spec.ts b/apps/api/src/app/benchmark/benchmark.service.spec.ts index 833dbcdfc..faf77df6b 100644 --- a/apps/api/src/app/benchmark/benchmark.service.spec.ts +++ b/apps/api/src/app/benchmark/benchmark.service.spec.ts @@ -1,10 +1,51 @@ import { BenchmarkService } from './benchmark.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { NotFoundError } from '@ghostfolio/common/exceptions'; + +jest.mock('@ghostfolio/api/services/property/property.service', () => { + return { + PropertyService: jest.fn().mockImplementation(() => { + return { + getByKey: jest.fn().mockImplementation((key: string) => { + return [{ symbolProfileId: 'profile-id-1' }]; + }), + put: jest.fn().mockImplementation(({ key, value }) => { + return Promise.resolve(); + }) + }; + }) + }; +}); + +jest.mock('@ghostfolio/api/services/prisma/prisma.service', () => { + return { + PrismaService: jest.fn().mockImplementation(() => { + return { + symbolProfile: { + findFirst: jest.fn() + } + }; + }) + }; +}); describe('BenchmarkService', () => { let benchmarkService: BenchmarkService; + let prismaService: PrismaService = new PrismaService(); + let propertyService: PropertyService = new PropertyService(prismaService); beforeAll(async () => { - benchmarkService = new BenchmarkService(null, null, null, null, null, null); + benchmarkService = new BenchmarkService( + null, + null, + prismaService, + propertyService, + null, + null, + null + ); }); it('calculateChangeInPercentage', async () => { @@ -12,4 +53,45 @@ describe('BenchmarkService', () => { expect(benchmarkService.calculateChangeInPercentage(2, 2)).toEqual(0); expect(benchmarkService.calculateChangeInPercentage(2, 1)).toEqual(-0.5); }); + + it('should add new benchmark', async () => { + prismaService.symbolProfile.findFirst = jest + .fn() + .mockResolvedValueOnce( + Promise.resolve({ id: 'profile-id-2', name: 'Test Profile' }) + ); + + const result = await benchmarkService.addBenchmark({ + dataSource: 'YAHOO', + symbol: 'symbol-2' + }); + + expect(propertyService.put).toHaveBeenCalledWith({ + key: PROPERTY_BENCHMARKS, + value: JSON.stringify([ + { symbolProfileId: 'profile-id-1' }, + { symbolProfileId: 'profile-id-2' } + ]) + }); + expect(result).toEqual({ + dataSource: 'YAHOO', + id: 'profile-id-2', + name: 'Test Profile', + symbol: 'symbol-2' + }); + }); + + it('should throw error if symbol profile not found', async () => { + prismaService.symbolProfile.findFirst = jest + .fn() + .mockResolvedValueOnce(Promise.resolve(null)); + try { + await benchmarkService.addBenchmark({ + dataSource: 'YAHOO', + symbol: 'symbol-2' + }); + } catch (e) { + expect(e).toEqual(new NotFoundError('Symbol profile not found')); + } + }); }); diff --git a/apps/api/src/app/benchmark/benchmark.service.ts b/apps/api/src/app/benchmark/benchmark.service.ts index 4e87b26f9..1065e3fcb 100644 --- a/apps/api/src/app/benchmark/benchmark.service.ts +++ b/apps/api/src/app/benchmark/benchmark.service.ts @@ -11,6 +11,7 @@ import { import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { BenchmarkMarketDataDetails, + BenchmarkProperty, BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces'; @@ -19,6 +20,9 @@ import { SymbolProfile } from '@prisma/client'; import Big from 'big.js'; import { format } from 'date-fns'; import ms from 'ms'; +import { uniqBy } from 'lodash'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { NotFoundError } from '@ghostfolio/common/exceptions'; @Injectable() export class BenchmarkService { @@ -27,6 +31,7 @@ export class BenchmarkService { public constructor( private readonly dataProviderService: DataProviderService, private readonly marketDataService: MarketDataService, + private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, private readonly redisCacheService: RedisCacheService, private readonly symbolProfileService: SymbolProfileService, @@ -204,6 +209,43 @@ export class BenchmarkService { return response; } + public async addBenchmark({ + dataSource, + symbol + }: UniqueAsset): Promise> { + const symbolProfile = await this.prismaService.symbolProfile.findFirst({ + where: { + dataSource, + symbol + } + }); + + if (!symbolProfile) { + throw new NotFoundError('Symbol profile not found'); + } + + const benchmarks = + ((await this.propertyService.getByKey( + PROPERTY_BENCHMARKS + )) as BenchmarkProperty[]) ?? []; + + benchmarks.push({ symbolProfileId: symbolProfile.id } as BenchmarkProperty); + + const newBenchmarks = uniqBy(benchmarks, 'symbolProfileId'); + + await this.propertyService.put({ + key: PROPERTY_BENCHMARKS, + value: JSON.stringify(newBenchmarks) + }); + + return { + dataSource, + symbol, + id: symbolProfile.id, + name: symbolProfile.name + }; + } + private getMarketCondition(aPerformanceInPercent: number) { return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET'; } diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index 2fc84b6aa..9a419dc4a 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -64,7 +64,7 @@ export class AppComponent implements OnDestroy, OnInit { const urlSegments = urlSegmentGroup.segments; this.currentRoute = urlSegments[0].path; - this.info = this.dataService.fetchInfo(); + this.info = this.dataService.getInfo(); if (this.deviceType === 'mobile') { setTimeout(() => { 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 a45703562..ec16960e2 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 @@ -14,13 +14,23 @@ import { AdminService } from '@ghostfolio/client/services/admin.service'; import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { getDateFormatString } from '@ghostfolio/common/helper'; -import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces'; +import { + Filter, + InfoItem, + UniqueAsset, + User +} from '@ghostfolio/common/interfaces'; import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface'; import { translate } from '@ghostfolio/ui/i18n'; -import { AssetSubClass, DataSource } from '@prisma/client'; +import { AssetSubClass, DataSource, SymbolProfile } from '@prisma/client'; import { DeviceDetectorService } from 'ngx-device-detector'; import { Subject } from 'rxjs'; -import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators'; +import { + distinctUntilChanged, + switchMap, + take, + takeUntil +} from 'rxjs/operators'; import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component'; import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces'; @@ -51,6 +61,7 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit { type: 'ASSET_SUB_CLASS' }; }); + public benchmarks: Partial[]; public currentDataSource: DataSource; public currentSymbol: string; public dataSource: MatTableDataSource = @@ -116,6 +127,7 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit { } public ngOnInit() { + this.benchmarks = this.dataService.getInfo().benchmarks; this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.filters$ @@ -143,6 +155,13 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit { }); } + public isBenchmark({ dataSource, symbol }: UniqueAsset) { + return this.benchmarks.some( + (benchmark) => + benchmark.dataSource === dataSource && benchmark.symbol === symbol + ); + } + public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) { this.adminService .deleteProfileData({ dataSource, symbol }) @@ -186,6 +205,21 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit { .subscribe(() => {}); } + public onSetBenchmark(benchmark: UniqueAsset) { + this.dataService + .postBenchmark(benchmark) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((_) => { + this.dataService + .fetchInfo() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((info: InfoItem) => { + this.benchmarks = info.benchmarks; + (window as any) = info; + }); + }); + } + public onGatherSymbol({ dataSource, symbol }: UniqueAsset) { this.adminService .gatherSymbol({ dataSource, symbol }) 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 36beae6dd..7b3e80df4 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 @@ -155,6 +155,13 @@ > Gather Profile Data +