Setup benchmark comparator

pull/1230/head
Thomas 2 years ago
parent 0de28d733e
commit 75d61bff6d

@ -56,11 +56,11 @@ export class BenchmarkService {
const allTimeHighs = await Promise.all(promises);
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } = quotes[benchmarkAssets[index].symbol];
const { marketPrice } = quotes[benchmarkAssets[index].symbol] ?? {};
let performancePercentFromAllTimeHigh = new Big(0);
if (allTimeHigh) {
if (allTimeHigh && marketPrice) {
performancePercentFromAllTimeHigh = new Big(marketPrice)
.div(allTimeHigh)
.minus(1);
@ -93,6 +93,24 @@ export class BenchmarkService {
return benchmarks;
}
public async getBenchmarkAssetProfiles(): Promise<UniqueAsset[]> {
const benchmarkAssets: UniqueAsset[] =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as UniqueAsset[]) ?? [];
const assetProfiles = await this.symbolProfileService.getSymbolProfiles(
benchmarkAssets
);
return assetProfiles.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol
};
});
}
private getMarketCondition(aPerformanceInPercent: Big) {
return aPerformanceInPercent.lte(-0.2) ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
}

@ -1,5 +1,6 @@
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { InfoService } from './info.service';
@ -8,6 +9,7 @@ export class InfoController {
public constructor(private readonly infoService: InfoService) {}
@Get()
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getInfo(): Promise<InfoItem> {
return this.infoService.get();
}

@ -9,6 +9,7 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.mod
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { InfoController } from './info.controller';
import { InfoService } from './info.service';
@ -16,6 +17,7 @@ import { InfoService } from './info.service';
@Module({
controllers: [InfoController],
imports: [
BenchmarkModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,

@ -1,3 +1,4 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
@ -31,6 +32,7 @@ export class InfoService {
private static CACHE_KEY_STATISTICS = 'STATISTICS';
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly jwtService: JwtService,
@ -108,6 +110,7 @@ export class InfoService {
platforms,
systemMessage,
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(),
statistics: await this.getStatistics(),

@ -5,6 +5,7 @@ import {
Injectable,
NestInterceptor
} from '@nestjs/common';
import { isArray } from 'lodash';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@ -36,6 +37,13 @@ export class TransformDataSourceInResponseInterceptor<T>
});
}
if (isArray(data.benchmarks)) {
data.benchmarks.map((benchmark) => {
benchmark.dataSource = encodeDataSource(benchmark.dataSource);
return benchmark;
});
}
if (data.dataSource) {
data.dataSource = encodeDataSource(data.dataSource);
}

@ -1,7 +1,6 @@
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';

@ -14,8 +14,8 @@ import { AdminOverviewComponent } from './admin-overview.component';
declarations: [AdminOverviewComponent],
exports: [],
imports: [
FormsModule,
CommonModule,
FormsModule,
GfValueModule,
MatButtonModule,
MatCardModule,

@ -0,0 +1,39 @@
<div class="align-items-center d-flex mb-4">
<div class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate">
<span i18n>Benchmarks</span>
<sup i18n>Beta</sup>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Compare with...</mat-label>
<mat-select
name="benchmark"
[value]="value"
(selectionChange)="onChangeBenchmark($event.value)"
>
<mat-option *ngFor="let benchmark of benchmarks" [value]="benchmark">{{
benchmark.symbol
}}</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="chart-container">
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
[theme]="{
height: '100%',
width: '100%'
}"
></ngx-skeleton-loader>
<canvas
#chartCanvas
class="h-100"
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
></canvas>
</div>

@ -0,0 +1,11 @@
:host {
display: block;
.chart-container {
aspect-ratio: 16 / 9;
ngx-skeleton-loader {
height: 100%;
}
}
}

@ -0,0 +1,261 @@
import 'chartjs-adapter-date-fns';
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnDestroy,
ViewChild
} from '@angular/core';
import {
getTooltipOptions,
getTooltipPositionerMapTop,
getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import {
getBackgroundColor,
getDateFormatString,
getTextColor,
parseDate,
transformTickToAbbreviation
} from '@ghostfolio/common/helper';
import { UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import {
Chart,
LineController,
LineElement,
LinearScale,
PointElement,
TimeScale,
Tooltip
} from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
import { addDays, isAfter, parseISO, subDays } from 'date-fns';
@Component({
selector: 'gf-benchmark-comparator',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './benchmark-comparator.component.html',
styleUrls: ['./benchmark-comparator.component.scss']
})
export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
@Input() benchmarks: UniqueAsset[];
@Input() currency: string;
@Input() daysInMarket: number;
@Input() investments: InvestmentItem[];
@Input() isInPercent = false;
@Input() locale: string;
@Input() user: User;
@ViewChild('chartCanvas') chartCanvas;
public chart: Chart;
public isLoading = true;
public value;
private data: InvestmentItem[];
public constructor() {
Chart.register(
annotationPlugin,
LinearScale,
LineController,
LineElement,
PointElement,
TimeScale,
Tooltip
);
Tooltip.positioners['top'] = (elements, position) =>
getTooltipPositionerMapTop(this.chart, position);
}
public ngOnChanges() {
if (this.investments) {
this.initialize();
}
}
public onChangeBenchmark(aBenchmark: any) {
console.log(aBenchmark);
}
public ngOnDestroy() {
this.chart?.destroy();
}
private initialize() {
this.isLoading = true;
// Create a clone
this.data = this.investments.map((a) => Object.assign({}, a));
if (this.data?.length > 0) {
// Extend chart by 5% of days in market (before)
const firstItem = this.data[0];
this.data.unshift({
...firstItem,
date: subDays(
parseISO(firstItem.date),
this.daysInMarket * 0.05 || 90
).toISOString(),
investment: 0
});
// Extend chart by 5% of days in market (after)
const lastItem = this.data[this.data.length - 1];
this.data.push({
...lastItem,
date: addDays(
parseDate(lastItem.date),
this.daysInMarket * 0.05 || 90
).toISOString()
});
}
const data = {
labels: this.data.map((investmentItem) => {
return investmentItem.date;
}),
datasets: [
{
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2,
data: this.data.map((position) => {
return position.investment;
}),
label: $localize`Deposit`,
segment: {
borderColor: (context: unknown) =>
this.isInFuture(
context,
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.67)`
),
borderDash: (context: unknown) => this.isInFuture(context, [2, 2])
},
stepped: true
},
{
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderWidth: 2,
data: this.data.map((position) => {
return position.investment * 1.75;
}),
label: $localize`Benchmark`
}
]
};
if (this.chartCanvas) {
if (this.chart) {
this.chart.data = data;
this.chart.options.plugins.tooltip = <unknown>(
this.getTooltipPluginConfiguration()
);
this.chart.update();
} else {
this.chart = new Chart(this.chartCanvas.nativeElement, {
data,
options: {
animation: false,
elements: {
line: {
tension: 0
},
point: {
hoverBackgroundColor: getBackgroundColor(),
hoverRadius: 2,
radius: 0
}
},
interaction: { intersect: false, mode: 'index' },
maintainAspectRatio: true,
plugins: <unknown>{
annotation: {
annotations: {
yAxis: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
borderWidth: 1,
scaleID: 'y',
type: 'line',
value: 0
}
}
},
legend: {
display: false
},
tooltip: this.getTooltipPluginConfiguration(),
verticalHoverLine: {
color: `rgba(${getTextColor()}, 0.1)`
}
},
responsive: true,
scales: {
x: {
display: true,
grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
borderWidth: 1,
color: `rgba(${getTextColor()}, 0.8)`,
display: false
},
type: 'time',
time: {
tooltipFormat: getDateFormatString(this.locale),
unit: 'year'
}
},
y: {
display: !this.isInPercent,
grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
color: `rgba(${getTextColor()}, 0.8)`,
display: false,
drawBorder: false
},
position: 'right',
ticks: {
callback: (value: number) => {
return transformTickToAbbreviation(value);
},
display: true,
mirror: true,
z: 1
}
}
}
},
plugins: [getVerticalHoverLinePlugin(this.chartCanvas)],
type: 'line'
});
}
}
this.isLoading = false;
}
private getTooltipPluginConfiguration() {
return {
...getTooltipOptions({
locale: this.isInPercent ? undefined : this.locale,
unit: this.isInPercent ? undefined : this.currency
}),
mode: 'index',
position: <unknown>'top',
xAlign: 'center',
yAlign: 'bottom'
};
}
private isInFuture<T>(aContext: any, aValue: T) {
return isAfter(new Date(aContext?.p1?.parsed?.x), new Date())
? aValue
: undefined;
}
}

@ -0,0 +1,20 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatSelectModule } from '@angular/material/select';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { BenchmarkComparatorComponent } from './benchmark-comparator.component';
@NgModule({
declarations: [BenchmarkComparatorComponent],
exports: [BenchmarkComparatorComponent],
imports: [
CommonModule,
FormsModule,
MatSelectModule,
NgxSkeletonLoaderModule,
ReactiveFormsModule
]
})
export class GfBenchmarkComparatorModule {}

@ -57,6 +57,8 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
public chart: Chart;
public isLoading = true;
private data: InvestmentItem[];
public constructor() {
Chart.register(
annotationPlugin,
@ -87,10 +89,13 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
private initialize() {
this.isLoading = true;
if (!this.groupBy && this.investments?.length > 0) {
// Create a clone
this.data = this.investments.map((a) => Object.assign({}, a));
if (!this.groupBy && this.data?.length > 0) {
// Extend chart by 5% of days in market (before)
const firstItem = this.investments[0];
this.investments.unshift({
const firstItem = this.data[0];
this.data.unshift({
...firstItem,
date: subDays(
parseISO(firstItem.date),
@ -100,8 +105,8 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
});
// Extend chart by 5% of days in market (after)
const lastItem = this.investments[this.investments.length - 1];
this.investments.push({
const lastItem = this.data[this.data.length - 1];
this.data.push({
...lastItem,
date: addDays(
parseDate(lastItem.date),
@ -111,7 +116,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}
const data = {
labels: this.investments.map((investmentItem) => {
labels: this.data.map((investmentItem) => {
return investmentItem.date;
}),
datasets: [
@ -119,7 +124,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: this.groupBy ? 0 : 2,
data: this.investments.map((position) => {
data: this.data.map((position) => {
return position.investment;
}),
label: $localize`Deposit`,

@ -2,7 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Position, User } from '@ghostfolio/common/interfaces';
import { Position, UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { GroupBy, ToggleOption } from '@ghostfolio/common/types';
import { differenceInDays } from 'date-fns';
@ -18,6 +18,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './analysis-page.html'
})
export class AnalysisPageComponent implements OnDestroy, OnInit {
public benchmarks: UniqueAsset[];
public bottom3: Position[];
public daysInMarket: number;
public deviceType: string;
@ -40,7 +41,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private userService: UserService
) {}
) {
const { benchmarks } = this.dataService.fetchInfo();
this.benchmarks = benchmarks;
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;

@ -1,53 +1,21 @@
<div class="container">
<div class="row">
<h3 class="d-flex justify-content-center mb-3" i18n>Analysis</h3>
<div *ngIf="user?.settings?.isExperimentalFeatures" class="mb-5 row">
<div class="col-lg">
<h3 class="d-flex justify-content-center mb-3" i18n>Analysis</h3>
<div class="mb-4">
<div class="align-items-center d-flex mb-4">
<div
class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate"
>
<span i18n>Investment Timeline</span>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</div>
<gf-toggle
class="d-none d-lg-block"
[defaultValue]="mode"
[isLoading]="false"
[options]="modeOptions"
(change)="onChangeGroupBy($event.value)"
></gf-toggle>
</div>
<div class="chart-container">
<gf-investment-chart
class="h-100"
[currency]="user?.settings?.baseCurrency"
[daysInMarket]="daysInMarket"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[investments]="investments"
[locale]="user?.settings?.locale"
[ngClass]="{ 'd-none': mode }"
></gf-investment-chart>
<gf-investment-chart
class="h-100"
groupBy="month"
[currency]="user?.settings?.baseCurrency"
[daysInMarket]="daysInMarket"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[investments]="investmentsByMonth"
[locale]="user?.settings?.locale"
[ngClass]="{ 'd-none': !mode }"
[savingsRate]="(hasImpersonationId || user.settings.isRestrictedView) ? undefined : user?.settings?.savingsRate"
></gf-investment-chart>
</div>
</div>
<gf-benchmark-comparator
class="h-100"
[benchmarks]="benchmarks"
[currency]="user?.settings?.baseCurrency"
[daysInMarket]="daysInMarket"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[investments]="investments"
[locale]="user?.settings?.locale"
[user]="user"
></gf-benchmark-comparator>
</div>
</div>
<div class="row">
<div class="mb-5 row">
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header>
@ -124,4 +92,49 @@
</mat-card>
</div>
</div>
<div class="row">
<div class="col-lg">
<div class="align-items-center d-flex mb-4">
<div
class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate"
>
<span i18n>Investment Timeline</span>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</div>
<gf-toggle
class="d-none d-lg-block"
[defaultValue]="mode"
[isLoading]="false"
[options]="modeOptions"
(change)="onChangeGroupBy($event.value)"
></gf-toggle>
</div>
<div class="chart-container">
<gf-investment-chart
class="h-100"
[currency]="user?.settings?.baseCurrency"
[daysInMarket]="daysInMarket"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[investments]="investments"
[locale]="user?.settings?.locale"
[ngClass]="{ 'd-none': mode }"
></gf-investment-chart>
<gf-investment-chart
class="h-100"
groupBy="month"
[currency]="user?.settings?.baseCurrency"
[daysInMarket]="daysInMarket"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[investments]="investmentsByMonth"
[locale]="user?.settings?.locale"
[ngClass]="{ 'd-none': !mode }"
[savingsRate]="(hasImpersonationId || user.settings.isRestrictedView) ? undefined : user?.settings?.savingsRate"
></gf-investment-chart>
</div>
</div>
</div>
</div>

@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { GfBenchmarkComparatorModule } from '@ghostfolio/client/components/benchmark-comparator/benchmark-comparator.module';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
@ -15,6 +16,7 @@ import { AnalysisPageComponent } from './analysis-page.component';
imports: [
AnalysisPageRoutingModule,
CommonModule,
GfBenchmarkComparatorModule,
GfInvestmentChartModule,
GfPremiumIndicatorModule,
GfToggleModule,

@ -42,7 +42,11 @@ export function downloadAsFile({
}
export function encodeDataSource(aDataSource: DataSource) {
return Buffer.from(aDataSource, 'utf-8').toString('hex');
if (aDataSource) {
return Buffer.from(aDataSource, 'utf-8').toString('hex');
}
return undefined;
}
export function extractNumberFromString(aString: string): number {

@ -2,9 +2,11 @@ import { Tag } from '@prisma/client';
import { Statistics } from './statistics.interface';
import { Subscription } from './subscription.interface';
import { UniqueAsset } from './unique-asset.interface';
export interface InfoItem {
baseCurrency: string;
benchmarks: UniqueAsset[];
currencies: string[];
demoAuthToken: string;
fearAndGreedDataSource?: string;

Loading…
Cancel
Save