mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
85 changed files with 1307 additions and 360 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,25 @@ |
|||
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], |
|||
exports: [BenchmarkService], |
|||
imports: [ |
|||
ConfigurationModule, |
|||
DataProviderModule, |
|||
MarketDataModule, |
|||
PropertyModule, |
|||
RedisCacheModule, |
|||
SymbolProfileModule |
|||
], |
|||
providers: [BenchmarkService] |
|||
}) |
|||
export class BenchmarkModule {} |
@ -0,0 +1,84 @@ |
|||
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 { |
|||
marketCondition: this.getMarketCondition( |
|||
performancePercentFromAllTimeHigh |
|||
), |
|||
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; |
|||
} |
|||
|
|||
private getMarketCondition(aPerformanceInPercent: Big) { |
|||
return aPerformanceInPercent.lte(-0.2) ? 'BEAR_MARKET' : 'NEUTRAL_MARKET'; |
|||
} |
|||
} |
@ -1,9 +1,7 @@ |
|||
import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; |
|||
import { DataSource } from '@prisma/client'; |
|||
import { HistoricalDataItem, UniqueAsset } from '@ghostfolio/common/interfaces'; |
|||
|
|||
export interface SymbolItem { |
|||
export interface SymbolItem extends UniqueAsset { |
|||
currency: string; |
|||
dataSource: DataSource; |
|||
historicalData: HistoricalDataItem[]; |
|||
marketPrice: number; |
|||
} |
|||
|
@ -0,0 +1,50 @@ |
|||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
|||
import { |
|||
CallHandler, |
|||
ExecutionContext, |
|||
Injectable, |
|||
NestInterceptor |
|||
} from '@nestjs/common'; |
|||
import { Observable } from 'rxjs'; |
|||
import { map } from 'rxjs/operators'; |
|||
|
|||
@Injectable() |
|||
export class RedactValuesInResponseInterceptor<T> |
|||
implements NestInterceptor<T, any> |
|||
{ |
|||
public constructor() {} |
|||
|
|||
public intercept( |
|||
context: ExecutionContext, |
|||
next: CallHandler<T> |
|||
): Observable<any> { |
|||
return next.handle().pipe( |
|||
map((data: any) => { |
|||
const request = context.switchToHttp().getRequest(); |
|||
const hasImpersonationId = !!request.headers?.['impersonation-id']; |
|||
|
|||
if (hasImpersonationId) { |
|||
if (data.accounts) { |
|||
for (const accountId of Object.keys(data.accounts)) { |
|||
if (data.accounts[accountId]?.balance !== undefined) { |
|||
data.accounts[accountId].balance = null; |
|||
} |
|||
} |
|||
} |
|||
|
|||
if (data.activities) { |
|||
data.activities = data.activities.map((activity: Activity) => { |
|||
if (activity.Account?.balance !== undefined) { |
|||
activity.Account.balance = null; |
|||
} |
|||
|
|||
return activity; |
|||
}); |
|||
} |
|||
} |
|||
|
|||
return data; |
|||
}) |
|||
); |
|||
} |
|||
} |
@ -0,0 +1,138 @@ |
|||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; |
|||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; |
|||
import { |
|||
IDataProviderHistoricalResponse, |
|||
IDataProviderResponse |
|||
} from '@ghostfolio/api/services/interfaces/interfaces'; |
|||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; |
|||
import { DATE_FORMAT } from '@ghostfolio/common/helper'; |
|||
import { Granularity } from '@ghostfolio/common/types'; |
|||
import { Injectable, Logger } from '@nestjs/common'; |
|||
import { DataSource, SymbolProfile } from '@prisma/client'; |
|||
import bent from 'bent'; |
|||
import { format } from 'date-fns'; |
|||
|
|||
@Injectable() |
|||
export class EodHistoricalDataService implements DataProviderInterface { |
|||
private apiKey: string; |
|||
private readonly URL = 'https://eodhistoricaldata.com/api'; |
|||
|
|||
public constructor( |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly symbolProfileService: SymbolProfileService |
|||
) { |
|||
this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY'); |
|||
} |
|||
|
|||
public canHandle(symbol: string) { |
|||
return true; |
|||
} |
|||
|
|||
public async getAssetProfile( |
|||
aSymbol: string |
|||
): Promise<Partial<SymbolProfile>> { |
|||
return { |
|||
dataSource: this.getName() |
|||
}; |
|||
} |
|||
|
|||
public async getHistorical( |
|||
aSymbol: string, |
|||
aGranularity: Granularity = 'day', |
|||
from: Date, |
|||
to: Date |
|||
): Promise<{ |
|||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; |
|||
}> { |
|||
try { |
|||
const get = bent( |
|||
`${this.URL}/eod/${aSymbol}?api_token=${ |
|||
this.apiKey |
|||
}&fmt=json&from=${format(from, DATE_FORMAT)}&to=${format( |
|||
to, |
|||
DATE_FORMAT |
|||
)}&period={aGranularity}`,
|
|||
'GET', |
|||
'json', |
|||
200 |
|||
); |
|||
|
|||
const response = await get(); |
|||
|
|||
return response.reduce( |
|||
(result, historicalItem, index, array) => { |
|||
result[aSymbol][historicalItem.date] = { |
|||
marketPrice: historicalItem.close, |
|||
performance: historicalItem.open - historicalItem.close |
|||
}; |
|||
|
|||
return result; |
|||
}, |
|||
{ [aSymbol]: {} } |
|||
); |
|||
} catch (error) { |
|||
Logger.error(error, 'EodHistoricalDataService'); |
|||
} |
|||
|
|||
return {}; |
|||
} |
|||
|
|||
public getName(): DataSource { |
|||
return DataSource.EOD_HISTORICAL_DATA; |
|||
} |
|||
|
|||
public async getQuotes( |
|||
aSymbols: string[] |
|||
): Promise<{ [symbol: string]: IDataProviderResponse }> { |
|||
if (aSymbols.length <= 0) { |
|||
return {}; |
|||
} |
|||
|
|||
try { |
|||
const get = bent( |
|||
`${this.URL}/real-time/${aSymbols[0]}?api_token=${ |
|||
this.apiKey |
|||
}&fmt=json&s=${aSymbols.join(',')}`,
|
|||
'GET', |
|||
'json', |
|||
200 |
|||
); |
|||
|
|||
const [response, symbolProfiles] = await Promise.all([ |
|||
get(), |
|||
this.symbolProfileService.getSymbolProfiles( |
|||
aSymbols.map((symbol) => { |
|||
return { |
|||
symbol, |
|||
dataSource: DataSource.EOD_HISTORICAL_DATA |
|||
}; |
|||
}) |
|||
) |
|||
]); |
|||
|
|||
const quotes = aSymbols.length === 1 ? [response] : response; |
|||
|
|||
return quotes.reduce((result, item, index, array) => { |
|||
result[item.code] = { |
|||
currency: symbolProfiles.find((symbolProfile) => { |
|||
return symbolProfile.symbol === item.code; |
|||
})?.currency, |
|||
dataSource: DataSource.EOD_HISTORICAL_DATA, |
|||
marketPrice: item.close, |
|||
marketState: 'delayed' |
|||
}; |
|||
|
|||
return result; |
|||
}, {}); |
|||
} catch (error) { |
|||
Logger.error(error, 'EodHistoricalDataService'); |
|||
} |
|||
|
|||
return {}; |
|||
} |
|||
|
|||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { |
|||
return { items: [] }; |
|||
} |
|||
} |
@ -1,11 +1,13 @@ |
|||
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module'; |
|||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; |
|||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; |
|||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; |
|||
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service'; |
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
@Module({ |
|||
exports: [TwitterBotService], |
|||
imports: [ConfigurationModule, SymbolModule], |
|||
imports: [BenchmarkModule, ConfigurationModule, PropertyModule, SymbolModule], |
|||
providers: [TwitterBotService] |
|||
}) |
|||
export class TwitterBotModule {} |
|||
|
@ -1,68 +1,68 @@ |
|||
<div class="container"> |
|||
<div class="row"> |
|||
<div class="row mb-5"> |
|||
<div class="col-lg"> |
|||
<h3 class="d-flex justify-content-center mb-3" i18n>FIRE</h3> |
|||
<div class="mb-5"> |
|||
<h4 i18n>4% Rule</h4> |
|||
<div *ngIf="isLoading"> |
|||
<ngx-skeleton-loader |
|||
animation="pulse" |
|||
class="my-1" |
|||
[theme]="{ |
|||
height: '1rem', |
|||
width: '100%' |
|||
}" |
|||
></ngx-skeleton-loader> |
|||
<ngx-skeleton-loader |
|||
animation="pulse" |
|||
[theme]="{ |
|||
height: '1rem', |
|||
width: '10rem' |
|||
}" |
|||
></ngx-skeleton-loader> |
|||
</div> |
|||
<div *ngIf="!isLoading"> |
|||
If you retire today, you would be able to withdraw |
|||
<span class="font-weight-bold" |
|||
><gf-value |
|||
class="d-inline-block" |
|||
[currency]="user?.settings?.baseCurrency" |
|||
[locale]="user?.settings?.locale" |
|||
[value]="withdrawalRatePerYear?.toNumber()" |
|||
></gf-value> |
|||
per year</span |
|||
> |
|||
or |
|||
<span class="font-weight-bold" |
|||
><gf-value |
|||
class="d-inline-block" |
|||
[currency]="user?.settings?.baseCurrency" |
|||
[locale]="user?.settings?.locale" |
|||
[value]="withdrawalRatePerMonth?.toNumber()" |
|||
></gf-value> |
|||
per month</span |
|||
>, based on your total assets of |
|||
<gf-value |
|||
class="d-inline-block" |
|||
[currency]="user?.settings?.baseCurrency" |
|||
[locale]="user?.settings?.locale" |
|||
[value]="fireWealth?.toNumber()" |
|||
></gf-value> |
|||
and a withdrawal rate of 4%. |
|||
</div> |
|||
<div> |
|||
<h4 class="mb-3" i18n>Calculator</h4> |
|||
<gf-fire-calculator |
|||
[currency]="user?.settings?.baseCurrency" |
|||
[deviceType]="deviceType" |
|||
[fireWealth]="fireWealth?.toNumber()" |
|||
[hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings" |
|||
[locale]="user?.settings?.locale" |
|||
[savingsRate]="user?.settings?.savingsRate" |
|||
(savingsRateChanged)="onSavingsRateChange($event)" |
|||
></gf-fire-calculator> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div> |
|||
<h4 class="mb-3" i18n>Calculator</h4> |
|||
<gf-fire-calculator |
|||
[currency]="user?.settings?.baseCurrency" |
|||
[deviceType]="deviceType" |
|||
[fireWealth]="fireWealth?.toNumber()" |
|||
[hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings" |
|||
[locale]="user?.settings?.locale" |
|||
[savingsRate]="user?.settings?.savingsRate" |
|||
(savingsRateChanged)="onSavingsRateChange($event)" |
|||
></gf-fire-calculator> |
|||
<h4 i18n>4% Rule</h4> |
|||
<div *ngIf="isLoading"> |
|||
<ngx-skeleton-loader |
|||
animation="pulse" |
|||
class="my-1" |
|||
[theme]="{ |
|||
height: '1rem', |
|||
width: '100%' |
|||
}" |
|||
></ngx-skeleton-loader> |
|||
<ngx-skeleton-loader |
|||
animation="pulse" |
|||
[theme]="{ |
|||
height: '1rem', |
|||
width: '10rem' |
|||
}" |
|||
></ngx-skeleton-loader> |
|||
</div> |
|||
<div *ngIf="!isLoading"> |
|||
If you retire today, you would be able to withdraw |
|||
<span class="font-weight-bold" |
|||
><gf-value |
|||
class="d-inline-block" |
|||
[currency]="user?.settings?.baseCurrency" |
|||
[locale]="user?.settings?.locale" |
|||
[value]="withdrawalRatePerYear?.toNumber()" |
|||
></gf-value> |
|||
per year</span |
|||
> |
|||
or |
|||
<span class="font-weight-bold" |
|||
><gf-value |
|||
class="d-inline-block" |
|||
[currency]="user?.settings?.baseCurrency" |
|||
[locale]="user?.settings?.locale" |
|||
[value]="withdrawalRatePerMonth?.toNumber()" |
|||
></gf-value> |
|||
per month</span |
|||
>, based on your total assets of |
|||
<gf-value |
|||
class="d-inline-block" |
|||
[currency]="user?.settings?.baseCurrency" |
|||
[locale]="user?.settings?.locale" |
|||
[value]="fireWealth?.toNumber()" |
|||
></gf-value> |
|||
and a withdrawal rate of 4%. |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
After Width: | Height: | Size: 63 KiB |
@ -0,0 +1,83 @@ |
|||
import { Chart, TooltipPosition } from 'chart.js'; |
|||
|
|||
import { getBackgroundColor, getTextColor } from './helper'; |
|||
|
|||
export function getTooltipOptions(currency = '', locale = '') { |
|||
return { |
|||
backgroundColor: getBackgroundColor(), |
|||
bodyColor: `rgb(${getTextColor()})`, |
|||
borderWidth: 1, |
|||
borderColor: `rgba(${getTextColor()}, 0.1)`, |
|||
callbacks: { |
|||
label: (context) => { |
|||
let label = context.dataset.label || ''; |
|||
if (label) { |
|||
label += ': '; |
|||
} |
|||
if (context.parsed.y !== null) { |
|||
if (currency) { |
|||
label += `${context.parsed.y.toLocaleString(locale, { |
|||
maximumFractionDigits: 2, |
|||
minimumFractionDigits: 2 |
|||
})} ${currency}`;
|
|||
} else { |
|||
label += context.parsed.y.toFixed(2); |
|||
} |
|||
} |
|||
return label; |
|||
} |
|||
}, |
|||
caretSize: 0, |
|||
cornerRadius: 2, |
|||
footerColor: `rgb(${getTextColor()})`, |
|||
itemSort: (a, b) => { |
|||
// Reverse order
|
|||
return b.datasetIndex - a.datasetIndex; |
|||
}, |
|||
titleColor: `rgb(${getTextColor()})`, |
|||
usePointStyle: true |
|||
}; |
|||
} |
|||
|
|||
export function getTooltipPositionerMapTop( |
|||
chart: Chart, |
|||
position: TooltipPosition |
|||
) { |
|||
if (!position) { |
|||
return false; |
|||
} |
|||
return { |
|||
x: position.x, |
|||
y: chart.chartArea.top |
|||
}; |
|||
} |
|||
|
|||
export function getVerticalHoverLinePlugin(chartCanvas) { |
|||
return { |
|||
afterDatasetsDraw: (chart, x, options) => { |
|||
const active = chart.getActiveElements(); |
|||
|
|||
if (!active || active.length === 0) { |
|||
return; |
|||
} |
|||
|
|||
const color = options.color || `rgb(${getTextColor()})`; |
|||
const width = options.width || 1; |
|||
|
|||
const { |
|||
chartArea: { bottom, top } |
|||
} = chart; |
|||
const xValue = active[0].element.x; |
|||
|
|||
const context = chartCanvas.nativeElement.getContext('2d'); |
|||
context.lineWidth = width; |
|||
context.strokeStyle = color; |
|||
|
|||
context.beginPath(); |
|||
context.moveTo(xValue, top); |
|||
context.lineTo(xValue, bottom); |
|||
context.stroke(); |
|||
}, |
|||
id: 'verticalHoverLine' |
|||
}; |
|||
} |
@ -0,0 +1,11 @@ |
|||
import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface'; |
|||
|
|||
export interface Benchmark { |
|||
marketCondition: 'BEAR_MARKET' | 'BULL_MARKET' | 'NEUTRAL_MARKET'; |
|||
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 { Country } from './country.interface'; |
|||
import { ScraperConfiguration } from './scraper-configuration.interface'; |
|||
import { Sector } from './sector.interface'; |
|||
|
|||
export interface EnhancedSymbolProfile { |
|||
assetClass: AssetClass; |
|||
assetSubClass: AssetSubClass; |
@ -0,0 +1,5 @@ |
|||
import { Benchmark } from '../benchmark.interface'; |
|||
|
|||
export interface BenchmarkResponse { |
|||
benchmarks: Benchmark[]; |
|||
} |
@ -0,0 +1,49 @@ |
|||
<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: '67%' |
|||
}" |
|||
></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 class="ml-2"> |
|||
<div |
|||
*ngIf="benchmark?.marketCondition" |
|||
[title]="benchmark?.marketCondition" |
|||
> |
|||
{{ resolveMarketCondition(benchmark.marketCondition).emoji }} |
|||
</div> |
|||
<ngx-skeleton-loader |
|||
*ngIf="!benchmark?.marketCondition" |
|||
animation="pulse" |
|||
appearance="circle" |
|||
[theme]="{ |
|||
height: '1rem', |
|||
width: '1rem' |
|||
}" |
|||
></ngx-skeleton-loader> |
|||
</div> |
|||
</div> |
@ -0,0 +1,3 @@ |
|||
:host { |
|||
display: block; |
|||
} |
@ -0,0 +1,18 @@ |
|||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; |
|||
import { resolveMarketCondition } from '@ghostfolio/common/helper'; |
|||
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 resolveMarketCondition = resolveMarketCondition; |
|||
|
|||
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'; |
@ -0,0 +1,2 @@ |
|||
-- AlterEnum |
|||
ALTER TYPE "DataSource" ADD VALUE 'EOD_HISTORICAL_DATA'; |
Loading…
Reference in new issue