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 { 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,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 { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; |
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.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 { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service'; |
||||
import { Module } from '@nestjs/common'; |
import { Module } from '@nestjs/common'; |
||||
|
|
||||
@Module({ |
@Module({ |
||||
exports: [TwitterBotService], |
exports: [TwitterBotService], |
||||
imports: [ConfigurationModule, SymbolModule], |
imports: [BenchmarkModule, ConfigurationModule, PropertyModule, SymbolModule], |
||||
providers: [TwitterBotService] |
providers: [TwitterBotService] |
||||
}) |
}) |
||||
export class TwitterBotModule {} |
export class TwitterBotModule {} |
||||
|
@ -1,68 +1,68 @@ |
|||||
<div class="container"> |
<div class="container"> |
||||
<div class="row"> |
<div class="row mb-5"> |
||||
<div class="col-lg"> |
<div class="col-lg"> |
||||
<h3 class="d-flex justify-content-center mb-3" i18n>FIRE</h3> |
<h3 class="d-flex justify-content-center mb-3" i18n>FIRE</h3> |
||||
<div class="mb-5"> |
<div> |
||||
<h4 i18n>4% Rule</h4> |
<h4 class="mb-3" i18n>Calculator</h4> |
||||
<div *ngIf="isLoading"> |
<gf-fire-calculator |
||||
<ngx-skeleton-loader |
[currency]="user?.settings?.baseCurrency" |
||||
animation="pulse" |
[deviceType]="deviceType" |
||||
class="my-1" |
[fireWealth]="fireWealth?.toNumber()" |
||||
[theme]="{ |
[hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings" |
||||
height: '1rem', |
[locale]="user?.settings?.locale" |
||||
width: '100%' |
[savingsRate]="user?.settings?.savingsRate" |
||||
}" |
(savingsRateChanged)="onSavingsRateChange($event)" |
||||
></ngx-skeleton-loader> |
></gf-fire-calculator> |
||||
<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> |
||||
</div> |
</div> |
||||
</div> |
</div> |
||||
<div> |
<div> |
||||
<h4 class="mb-3" i18n>Calculator</h4> |
<h4 i18n>4% Rule</h4> |
||||
<gf-fire-calculator |
<div *ngIf="isLoading"> |
||||
[currency]="user?.settings?.baseCurrency" |
<ngx-skeleton-loader |
||||
[deviceType]="deviceType" |
animation="pulse" |
||||
[fireWealth]="fireWealth?.toNumber()" |
class="my-1" |
||||
[hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings" |
[theme]="{ |
||||
[locale]="user?.settings?.locale" |
height: '1rem', |
||||
[savingsRate]="user?.settings?.savingsRate" |
width: '100%' |
||||
(savingsRateChanged)="onSavingsRateChange($event)" |
}" |
||||
></gf-fire-calculator> |
></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> |
||||
</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 { 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,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