diff --git a/apps/api/src/app/benchmark/benchmark.service.ts b/apps/api/src/app/benchmark/benchmark.service.ts index f72386544..559595cc5 100644 --- a/apps/api/src/app/benchmark/benchmark.service.ts +++ b/apps/api/src/app/benchmark/benchmark.service.ts @@ -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 { + 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'; } diff --git a/apps/api/src/app/info/info.controller.ts b/apps/api/src/app/info/info.controller.ts index 82520f2e7..67333904e 100644 --- a/apps/api/src/app/info/info.controller.ts +++ b/apps/api/src/app/info/info.controller.ts @@ -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 { return this.infoService.get(); } diff --git a/apps/api/src/app/info/info.module.ts b/apps/api/src/app/info/info.module.ts index 338747ebc..9bddb769f 100644 --- a/apps/api/src/app/info/info.module.ts +++ b/apps/api/src/app/info/info.module.ts @@ -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, diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index 74369b9cf..e4f906b9a 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -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(), diff --git a/apps/api/src/interceptors/transform-data-source-in-response.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-response.interceptor.ts index 6c96b3965..4b80038f5 100644 --- a/apps/api/src/interceptors/transform-data-source-in-response.interceptor.ts +++ b/apps/api/src/interceptors/transform-data-source-in-response.interceptor.ts @@ -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 }); } + if (isArray(data.benchmarks)) { + data.benchmarks.map((benchmark) => { + benchmark.dataSource = encodeDataSource(benchmark.dataSource); + return benchmark; + }); + } + if (data.dataSource) { data.dataSource = encodeDataSource(data.dataSource); } diff --git a/apps/api/src/services/twitter-bot/twitter-bot.module.ts b/apps/api/src/services/twitter-bot/twitter-bot.module.ts index 5e9b304ee..c810dad4a 100644 --- a/apps/api/src/services/twitter-bot/twitter-bot.module.ts +++ b/apps/api/src/services/twitter-bot/twitter-bot.module.ts @@ -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'; diff --git a/apps/client/src/app/components/admin-overview/admin-overview.module.ts b/apps/client/src/app/components/admin-overview/admin-overview.module.ts index 4f9dd7a2a..fed4b84df 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.module.ts +++ b/apps/client/src/app/components/admin-overview/admin-overview.module.ts @@ -14,8 +14,8 @@ import { AdminOverviewComponent } from './admin-overview.component'; declarations: [AdminOverviewComponent], exports: [], imports: [ - FormsModule, CommonModule, + FormsModule, GfValueModule, MatButtonModule, MatCardModule, diff --git a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html new file mode 100644 index 000000000..104651b68 --- /dev/null +++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html @@ -0,0 +1,39 @@ +
+
+ Benchmarks + Beta + +
+
+ + Compare with... + + {{ + benchmark.symbol + }} + + +
+
+
+ + +
diff --git a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss new file mode 100644 index 000000000..e02c91e3d --- /dev/null +++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss @@ -0,0 +1,11 @@ +:host { + display: block; + + .chart-container { + aspect-ratio: 16 / 9; + + ngx-skeleton-loader { + height: 100%; + } + } +} diff --git a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts new file mode 100644 index 000000000..af1bcb105 --- /dev/null +++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts @@ -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 = ( + 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: { + 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: 'top', + xAlign: 'center', + yAlign: 'bottom' + }; + } + + private isInFuture(aContext: any, aValue: T) { + return isAfter(new Date(aContext?.p1?.parsed?.x), new Date()) + ? aValue + : undefined; + } +} diff --git a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.module.ts b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.module.ts new file mode 100644 index 000000000..16440d3a3 --- /dev/null +++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.module.ts @@ -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 {} diff --git a/apps/client/src/app/components/investment-chart/investment-chart.component.ts b/apps/client/src/app/components/investment-chart/investment-chart.component.ts index 3a758e43d..5a7e90fc1 100644 --- a/apps/client/src/app/components/investment-chart/investment-chart.component.ts +++ b/apps/client/src/app/components/investment-chart/investment-chart.component.ts @@ -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`, diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts index 36bab3523..0f40cb983 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts @@ -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; diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index cad0f1179..8b7044770 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -1,53 +1,21 @@
-
+

Analysis

+
-

Analysis

-
-
-
- Investment Timeline - -
- -
-
- - -
-
+
-
+
@@ -124,4 +92,49 @@
+ +
+
+
+
+ Investment Timeline + +
+ +
+
+ + +
+
+
diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.module.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.module.ts index 59df2ca91..9e1a99d0f 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.module.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.module.ts @@ -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, diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index cc104b270..9547ac3c5 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -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 { diff --git a/libs/common/src/lib/interfaces/info-item.interface.ts b/libs/common/src/lib/interfaces/info-item.interface.ts index 6bf6acfcf..f2728ae2e 100644 --- a/libs/common/src/lib/interfaces/info-item.interface.ts +++ b/libs/common/src/lib/interfaces/info-item.interface.ts @@ -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;