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
### 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

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 {
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
);
}
}
}
}

4
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]
})

84
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'));
}
});
});

43
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<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) {
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 { 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<SymbolProfile>[];
private unsubscribeSubject = new Subject<void>();
@ -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

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>
</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
mat-menu-item
[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() {
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
} from './admin-market-data.interface';
import { BenchmarkMarketDataDetails } from './benchmark-market-data-details.interface';
import { BenchmarkProperty } from './benchmark-property.interface';
import { Benchmark } from './benchmark.interface';
import { Coupon } from './coupon.interface';
import { DataProviderInfo } from './data-provider-info.interface';
@ -53,6 +54,7 @@ export {
Benchmark,
BenchmarkMarketDataDetails,
BenchmarkResponse,
BenchmarkProperty,
Coupon,
DataProviderInfo,
EnhancedSymbolProfile,

Loading…
Cancel
Save