mirror of https://github.com/ghostfolio/ghostfolio
Thomas Kaul
3 years ago
committed by
GitHub
32 changed files with 351 additions and 45 deletions
@ -0,0 +1,32 @@ |
|||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; |
||||
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; |
||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; |
||||
|
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config'; |
||||
|
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces'; |
||||
|
import { Controller, Get, UseGuards, UseInterceptors } from '@nestjs/common'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
|
||||
|
import { BenchmarkService } from './benchmark.service'; |
||||
|
|
||||
|
@Controller('benchmark') |
||||
|
export class BenchmarkController { |
||||
|
public constructor( |
||||
|
private readonly benchmarkService: BenchmarkService, |
||||
|
private readonly propertyService: PropertyService |
||||
|
) {} |
||||
|
|
||||
|
@Get() |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor) |
||||
|
public async getBenchmark(): Promise<BenchmarkResponse> { |
||||
|
const benchmarkAssets: UniqueAsset[] = |
||||
|
((await this.propertyService.getByKey( |
||||
|
PROPERTY_BENCHMARKS |
||||
|
)) as UniqueAsset[]) ?? []; |
||||
|
|
||||
|
return { |
||||
|
benchmarks: await this.benchmarkService.getBenchmarks(benchmarkAssets) |
||||
|
}; |
||||
|
} |
||||
|
} |
@ -0,0 +1,24 @@ |
|||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; |
||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; |
||||
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module'; |
||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; |
||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; |
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { BenchmarkController } from './benchmark.controller'; |
||||
|
import { BenchmarkService } from './benchmark.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [BenchmarkController], |
||||
|
imports: [ |
||||
|
ConfigurationModule, |
||||
|
DataProviderModule, |
||||
|
MarketDataModule, |
||||
|
PropertyModule, |
||||
|
RedisCacheModule, |
||||
|
SymbolProfileModule |
||||
|
], |
||||
|
providers: [BenchmarkService] |
||||
|
}) |
||||
|
export class BenchmarkModule {} |
@ -0,0 +1,77 @@ |
|||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; |
||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; |
||||
|
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces'; |
||||
|
import { Injectable } from '@nestjs/common'; |
||||
|
import Big from 'big.js'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class BenchmarkService { |
||||
|
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS'; |
||||
|
|
||||
|
public constructor( |
||||
|
private readonly dataProviderService: DataProviderService, |
||||
|
private readonly marketDataService: MarketDataService, |
||||
|
private readonly redisCacheService: RedisCacheService, |
||||
|
private readonly symbolProfileService: SymbolProfileService |
||||
|
) {} |
||||
|
|
||||
|
public async getBenchmarks( |
||||
|
benchmarkAssets: UniqueAsset[] |
||||
|
): Promise<BenchmarkResponse['benchmarks']> { |
||||
|
let benchmarks: BenchmarkResponse['benchmarks']; |
||||
|
|
||||
|
try { |
||||
|
benchmarks = JSON.parse( |
||||
|
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS) |
||||
|
); |
||||
|
|
||||
|
if (benchmarks) { |
||||
|
return benchmarks; |
||||
|
} |
||||
|
} catch {} |
||||
|
|
||||
|
const promises: Promise<number>[] = []; |
||||
|
|
||||
|
const [quotes, assetProfiles] = await Promise.all([ |
||||
|
this.dataProviderService.getQuotes(benchmarkAssets), |
||||
|
this.symbolProfileService.getSymbolProfiles(benchmarkAssets) |
||||
|
]); |
||||
|
|
||||
|
for (const benchmarkAsset of benchmarkAssets) { |
||||
|
promises.push(this.marketDataService.getMax(benchmarkAsset)); |
||||
|
} |
||||
|
|
||||
|
const allTimeHighs = await Promise.all(promises); |
||||
|
|
||||
|
benchmarks = allTimeHighs.map((allTimeHigh, index) => { |
||||
|
const { marketPrice } = quotes[benchmarkAssets[index].symbol]; |
||||
|
|
||||
|
const performancePercentFromAllTimeHigh = new Big(marketPrice) |
||||
|
.div(allTimeHigh) |
||||
|
.minus(1); |
||||
|
|
||||
|
return { |
||||
|
name: assetProfiles.find(({ dataSource, symbol }) => { |
||||
|
return ( |
||||
|
dataSource === benchmarkAssets[index].dataSource && |
||||
|
symbol === benchmarkAssets[index].symbol |
||||
|
); |
||||
|
})?.name, |
||||
|
performances: { |
||||
|
allTimeHigh: { |
||||
|
performancePercent: performancePercentFromAllTimeHigh.toNumber() |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
await this.redisCacheService.set( |
||||
|
this.CACHE_KEY_BENCHMARKS, |
||||
|
JSON.stringify(benchmarks) |
||||
|
); |
||||
|
|
||||
|
return benchmarks; |
||||
|
} |
||||
|
} |
@ -1,9 +1,7 @@ |
|||||
import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; |
import { HistoricalDataItem, UniqueAsset } from '@ghostfolio/common/interfaces'; |
||||
import { DataSource } from '@prisma/client'; |
|
||||
|
|
||||
export interface SymbolItem { |
export interface SymbolItem extends UniqueAsset { |
||||
currency: string; |
currency: string; |
||||
dataSource: DataSource; |
|
||||
historicalData: HistoricalDataItem[]; |
historicalData: HistoricalDataItem[]; |
||||
marketPrice: number; |
marketPrice: number; |
||||
} |
} |
||||
|
@ -0,0 +1,10 @@ |
|||||
|
import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface'; |
||||
|
|
||||
|
export interface Benchmark { |
||||
|
name: EnhancedSymbolProfile['name']; |
||||
|
performances: { |
||||
|
allTimeHigh: { |
||||
|
performancePercent: number; |
||||
|
}; |
||||
|
}; |
||||
|
} |
@ -1,8 +1,9 @@ |
|||||
import { ScraperConfiguration } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface'; |
|
||||
import { Country } from '@ghostfolio/common/interfaces/country.interface'; |
|
||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; |
|
||||
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; |
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; |
||||
|
|
||||
|
import { Country } from './country.interface'; |
||||
|
import { ScraperConfiguration } from './scraper-configuration.interface'; |
||||
|
import { Sector } from './sector.interface'; |
||||
|
|
||||
export interface EnhancedSymbolProfile { |
export interface EnhancedSymbolProfile { |
||||
assetClass: AssetClass; |
assetClass: AssetClass; |
||||
assetSubClass: AssetSubClass; |
assetSubClass: AssetSubClass; |
@ -0,0 +1,5 @@ |
|||||
|
import { Benchmark } from '../benchmark.interface'; |
||||
|
|
||||
|
export interface BenchmarkResponse { |
||||
|
benchmarks: Benchmark[]; |
||||
|
} |
@ -0,0 +1,32 @@ |
|||||
|
<div class="align-items-center d-flex"> |
||||
|
<div *ngIf="benchmark?.name" class="flex-grow-1 text-truncate"> |
||||
|
{{ benchmark.name }} |
||||
|
</div> |
||||
|
<div *ngIf="!benchmark?.name" class="flex-grow-1"> |
||||
|
<ngx-skeleton-loader |
||||
|
animation="pulse" |
||||
|
[theme]="{ |
||||
|
width: '15rem' |
||||
|
}" |
||||
|
></ngx-skeleton-loader> |
||||
|
</div> |
||||
|
<gf-value |
||||
|
class="mx-2" |
||||
|
size="medium" |
||||
|
[isPercent]="true" |
||||
|
[locale]="locale" |
||||
|
[ngClass]="{ |
||||
|
'text-danger': |
||||
|
benchmark?.performances?.allTimeHigh?.performancePercent < 0, |
||||
|
'text-success': |
||||
|
benchmark?.performances?.allTimeHigh?.performancePercent > 0 |
||||
|
}" |
||||
|
[value]=" |
||||
|
benchmark?.performances?.allTimeHigh?.performancePercent ?? undefined |
||||
|
" |
||||
|
></gf-value> |
||||
|
<div class="text-muted"> |
||||
|
<small class="d-none d-sm-block text-nowrap" i18n>from All Time High</small |
||||
|
><small class="d-block d-sm-none text-nowrap" i18n>from ATH</small> |
||||
|
</div> |
||||
|
</div> |
@ -0,0 +1,3 @@ |
|||||
|
:host { |
||||
|
display: block; |
||||
|
} |
@ -0,0 +1,15 @@ |
|||||
|
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; |
||||
|
import { Benchmark } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'gf-benchmark', |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
templateUrl: './benchmark.component.html', |
||||
|
styleUrls: ['./benchmark.component.scss'] |
||||
|
}) |
||||
|
export class BenchmarkComponent { |
||||
|
@Input() benchmark: Benchmark; |
||||
|
@Input() locale: string; |
||||
|
|
||||
|
public constructor() {} |
||||
|
} |
@ -0,0 +1,14 @@ |
|||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; |
||||
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; |
||||
|
|
||||
|
import { GfValueModule } from '../value'; |
||||
|
import { BenchmarkComponent } from './benchmark.component'; |
||||
|
|
||||
|
@NgModule({ |
||||
|
declarations: [BenchmarkComponent], |
||||
|
exports: [BenchmarkComponent], |
||||
|
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule], |
||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA] |
||||
|
}) |
||||
|
export class GfBenchmarkModule {} |
@ -0,0 +1 @@ |
|||||
|
export * from './benchmark.module'; |
Loading…
Reference in new issue