Browse Source

Feature/add ability to add Symbol as benchmark from Market Data

- Added POST api to add benchmarks
- Added "Set as Benchmark" button to symbols in Market Data
pull/1972/head
Arghya Ghosh 2 years ago
parent
commit
7ed8a955ef
  1. 5
      CHANGELOG.md
  2. 49
      apps/api/src/app/benchmark/benchmark.controller.ts
  3. 4
      apps/api/src/app/benchmark/benchmark.module.ts
  4. 84
      apps/api/src/app/benchmark/benchmark.service.spec.ts
  5. 43
      apps/api/src/app/benchmark/benchmark.service.ts
  6. 23
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  7. 7
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  8. 4
      apps/client/src/app/services/data.service.ts
  9. 3
      libs/common/src/lib/exceptions/index.ts
  10. 6
      libs/common/src/lib/exceptions/not-found-error.ts
  11. 3
      libs/common/src/lib/interfaces/benchmark-property.interface.ts
  12. 2
      libs/common/src/lib/interfaces/index.ts

5
CHANGELOG.md

@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- POST api to add benchmarks
- Ability to add symbol as benchmark from Market Data
### Fixed ### Fixed
- Improved the _Select all_ activities checkbox state after importing activities including a duplicate - Improved the _Select all_ activities checkbox state after importing activities including a duplicate

49
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 { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { import {
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkResponse BenchmarkResponse,
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { import {
Body,
Controller, Controller,
Get, Get,
HttpException,
Inject,
Param, Param,
Post,
UseGuards, UseGuards,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { BenchmarkService } from './benchmark.service'; 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') @Controller('benchmark')
export class BenchmarkController { export class BenchmarkController {
public constructor(private readonly benchmarkService: BenchmarkService) {} public constructor(
private readonly benchmarkService: BenchmarkService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get() @Get()
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@ -45,4 +57,35 @@ export class BenchmarkController {
symbol 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
);
}
}
}
} }

4
apps/api/src/app/benchmark/benchmark.module.ts

@ -9,6 +9,7 @@ import { Module } from '@nestjs/common';
import { BenchmarkController } from './benchmark.controller'; import { BenchmarkController } from './benchmark.controller';
import { BenchmarkService } from './benchmark.service'; import { BenchmarkService } from './benchmark.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@Module({ @Module({
controllers: [BenchmarkController], controllers: [BenchmarkController],
@ -20,7 +21,8 @@ import { BenchmarkService } from './benchmark.service';
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolModule, SymbolModule,
SymbolProfileModule SymbolProfileModule,
PrismaModule
], ],
providers: [BenchmarkService] providers: [BenchmarkService]
}) })

84
apps/api/src/app/benchmark/benchmark.service.spec.ts

@ -1,10 +1,51 @@
import { BenchmarkService } from './benchmark.service'; 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', () => { describe('BenchmarkService', () => {
let benchmarkService: BenchmarkService; let benchmarkService: BenchmarkService;
let prismaService: PrismaService = new PrismaService();
let propertyService: PropertyService = new PropertyService(prismaService);
beforeAll(async () => { beforeAll(async () => {
benchmarkService = new BenchmarkService(null, null, null, null, null, null); benchmarkService = new BenchmarkService(
null,
null,
propertyService,
null,
null,
null,
prismaService
);
}); });
it('calculateChangeInPercentage', async () => { it('calculateChangeInPercentage', async () => {
@ -12,4 +53,45 @@ describe('BenchmarkService', () => {
expect(benchmarkService.calculateChangeInPercentage(2, 2)).toEqual(0); expect(benchmarkService.calculateChangeInPercentage(2, 2)).toEqual(0);
expect(benchmarkService.calculateChangeInPercentage(2, 1)).toEqual(-0.5); 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'));
}
});
}); });

43
apps/api/src/app/benchmark/benchmark.service.ts

@ -12,13 +12,17 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkResponse, BenchmarkResponse,
UniqueAsset UniqueAsset,
BenchmarkProperty
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { format } from 'date-fns'; import { format } from 'date-fns';
import ms from 'ms'; import ms from 'ms';
import { uniqBy } from 'lodash';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { NotFoundError } from '@ghostfolio/common/exceptions';
@Injectable() @Injectable()
export class BenchmarkService { export class BenchmarkService {
@ -30,7 +34,8 @@ export class BenchmarkService {
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService,
private readonly symbolService: SymbolService private readonly symbolService: SymbolService,
private readonly prismaService: PrismaService
) {} ) {}
public calculateChangeInPercentage(baseValue: number, currentValue: number) { public calculateChangeInPercentage(baseValue: number, currentValue: number) {
@ -204,6 +209,40 @@ export class BenchmarkService {
return response; return response;
} }
public async addBenchmark({
dataSource,
symbol
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
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) { private getMarketCondition(aPerformanceInPercent: number) {
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET'; return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
} }

23
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 { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface'; import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { translate } from '@ghostfolio/ui/i18n'; 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 { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators'; import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
@ -74,6 +74,7 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
public isLoading = false; public isLoading = false;
public placeholder = ''; public placeholder = '';
public user: User; public user: User;
public benchmarks: Partial<SymbolProfile>[];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -117,6 +118,7 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.benchmarks = this.dataService.fetchInfo().benchmarks;
this.filters$ this.filters$
.pipe( .pipe(
@ -186,6 +188,18 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
.subscribe(() => {}); .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) { public onGatherSymbol({ dataSource, symbol }: UniqueAsset) {
this.adminService this.adminService
.gatherSymbol({ dataSource, symbol }) .gatherSymbol({ dataSource, symbol })
@ -208,6 +222,13 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private isBenchmark({ dataSource, symbol }: UniqueAsset) {
return this.benchmarks.some(
(benchmark) =>
benchmark.dataSource === dataSource && benchmark.symbol === symbol
);
}
private openAssetProfileDialog({ private openAssetProfileDialog({
dataSource, dataSource,
symbol symbol

7
apps/client/src/app/components/admin-market-data/admin-market-data.html

@ -155,6 +155,13 @@
> >
<ng-container i18n>Gather Profile Data</ng-container> <ng-container i18n>Gather Profile Data</ng-container>
</button> </button>
<button
mat-menu-item
[disabled]="isBenchmark({dataSource: element.dataSource, symbol: element.symbol})"
(click)="onSetBenchmark({dataSource: element.dataSource, symbol: element.symbol})"
>
<ng-container i18n>Set as Benchmark</ng-container>
</button>
<button <button
mat-menu-item mat-menu-item
[disabled]="element.activitiesCount !== 0" [disabled]="element.activitiesCount !== 0"

4
apps/client/src/app/services/data.service.ts

@ -177,6 +177,10 @@ export class DataService {
); );
} }
public postBenchmark(benchmark: UniqueAsset) {
return this.http.post(`/api/v1/benchmark`, benchmark);
}
public fetchBenchmarks() { public fetchBenchmarks() {
return this.http.get<BenchmarkResponse>('/api/v1/benchmark'); return this.http.get<BenchmarkResponse>('/api/v1/benchmark');
} }

3
libs/common/src/lib/exceptions/index.ts

@ -0,0 +1,3 @@
import { NotFoundError } from './not-found-error';
export { NotFoundError };

6
libs/common/src/lib/exceptions/not-found-error.ts

@ -0,0 +1,6 @@
export class NotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'NotFoundError';
}
}

3
libs/common/src/lib/interfaces/benchmark-property.interface.ts

@ -0,0 +1,3 @@
export interface BenchmarkProperty {
symbolProfileId: string;
}

2
libs/common/src/lib/interfaces/index.ts

@ -8,6 +8,7 @@ import {
AdminMarketDataItem AdminMarketDataItem
} from './admin-market-data.interface'; } from './admin-market-data.interface';
import { BenchmarkMarketDataDetails } from './benchmark-market-data-details.interface'; import { BenchmarkMarketDataDetails } from './benchmark-market-data-details.interface';
import { BenchmarkProperty } from './benchmark-property.interface';
import { Benchmark } from './benchmark.interface'; import { Benchmark } from './benchmark.interface';
import { Coupon } from './coupon.interface'; import { Coupon } from './coupon.interface';
import { DataProviderInfo } from './data-provider-info.interface'; import { DataProviderInfo } from './data-provider-info.interface';
@ -53,6 +54,7 @@ export {
Benchmark, Benchmark,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkResponse, BenchmarkResponse,
BenchmarkProperty,
Coupon, Coupon,
DataProviderInfo, DataProviderInfo,
EnhancedSymbolProfile, EnhancedSymbolProfile,

Loading…
Cancel
Save