diff --git a/CHANGELOG.md b/CHANGELOG.md index 813510c8e..bf5f8ddf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- POST api to add benchmarks +- Ability to add symbol as benchmark from Market Data + ### Fixed - Improved the _Select all_ activities checkbox state after importing activities including a duplicate diff --git a/apps/api/src/app/benchmark/benchmark.controller.ts b/apps/api/src/app/benchmark/benchmark.controller.ts index 4daa14009..b362f6717 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,35 @@ 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 (e) { + if (e 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..886039e6e 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], @@ -20,7 +21,8 @@ import { BenchmarkService } from './benchmark.service'; PropertyModule, RedisCacheModule, SymbolModule, - SymbolProfileModule + SymbolProfileModule, + PrismaModule ], providers: [BenchmarkService] }) diff --git a/apps/api/src/app/benchmark/benchmark.service.spec.ts b/apps/api/src/app/benchmark/benchmark.service.spec.ts index 833dbcdfc..b38624354 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, + propertyService, + null, + null, + null, + prismaService + ); }); 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..bac921db5 100644 --- a/apps/api/src/app/benchmark/benchmark.service.ts +++ b/apps/api/src/app/benchmark/benchmark.service.ts @@ -12,13 +12,17 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { BenchmarkMarketDataDetails, BenchmarkResponse, - UniqueAsset + UniqueAsset, + BenchmarkProperty } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; 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 { @@ -30,7 +34,8 @@ export class BenchmarkService { private readonly propertyService: PropertyService, private readonly redisCacheService: RedisCacheService, private readonly symbolProfileService: SymbolProfileService, - private readonly symbolService: SymbolService + private readonly symbolService: SymbolService, + private readonly prismaService: PrismaService ) {} public calculateChangeInPercentage(baseValue: number, currentValue: number) { @@ -204,6 +209,40 @@ export class BenchmarkService { return response; } + public async addBenchmark({ + dataSource, + symbol + }: UniqueAsset): Promise> { + const symbolProfile = await this.prismaService.symbolProfile.findFirst({ + where: { + AND: [{ dataSource: dataSource }, { symbol: 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, + id: symbolProfile.id, + name: symbolProfile.name, + symbol + }; + } + private getMarketCondition(aPerformanceInPercent: number) { return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET'; } 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..38012d738 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 @@ -17,7 +17,7 @@ import { getDateFormatString } from '@ghostfolio/common/helper'; import { Filter, 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'; @@ -74,6 +74,7 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit { public isLoading = false; public placeholder = ''; public user: User; + public benchmarks: Partial[]; private unsubscribeSubject = new Subject(); @@ -117,6 +118,7 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit { public ngOnInit() { this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.benchmarks = this.dataService.fetchInfo().benchmarks; this.filters$ .pipe( @@ -186,6 +188,18 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit { .subscribe(() => {}); } + public onSetBenchmark(benchmark: UniqueAsset) { + this.dataService + .postBenchmark(benchmark) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((benchmarkProfile) => { + this.benchmarks.push(benchmarkProfile); + (window as any).info.benchmarks = this.benchmarks.sort((a, b) => + a.name.localeCompare(b.name) + ); + }); + } + public onGatherSymbol({ dataSource, symbol }: UniqueAsset) { this.adminService .gatherSymbol({ dataSource, symbol }) @@ -208,6 +222,13 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit { this.unsubscribeSubject.complete(); } + private isBenchmark({ dataSource, symbol }: UniqueAsset) { + return this.benchmarks.some( + (benchmark) => + benchmark.dataSource === dataSource && benchmark.symbol === symbol + ); + } + private openAssetProfileDialog({ 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 +