From 3e82de6b21517374c49ba483cb7d237502df5982 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 4 Dec 2021 11:49:00 +0100 Subject: [PATCH] Feature/add historical data chart of fear and greed index (#515) * Add historical data chart of market mood * Update changelog --- CHANGELOG.md | 4 +++ .../interfaces/symbol-item.interface.ts | 2 ++ apps/api/src/app/symbol/symbol.controller.ts | 11 +++++-- apps/api/src/app/symbol/symbol.module.ts | 8 ++++- apps/api/src/app/symbol/symbol.service.ts | 31 ++++++++++++++++++- .../fear-and-greed-index.component.html | 6 ++-- .../home-market/home-market.component.ts | 13 +++++++- .../components/home-market/home-market.html | 29 +++++++++++------ .../home-market/home-market.module.ts | 4 +-- .../components/home-market/home-market.scss | 4 +++ apps/client/src/app/services/data.service.ts | 6 +++- .../lib/line-chart/line-chart.component.ts | 17 +++++++++- 12 files changed, 113 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9cbe5b8b..98c910aea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added the historical data chart of the _Fear & Greed Index_ (market mood) + ### Changed - Improved the historical data view in the admin control panel (hide invalid and future dates) diff --git a/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts b/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts index 03554d649..787547901 100644 --- a/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts +++ b/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts @@ -1,7 +1,9 @@ +import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface'; import { DataSource } from '@prisma/client'; export interface SymbolItem { currency: string; dataSource: DataSource; + historicalData: HistoricalDataItem[]; marketPrice: number; } diff --git a/apps/api/src/app/symbol/symbol.controller.ts b/apps/api/src/app/symbol/symbol.controller.ts index 845645574..a364de6bc 100644 --- a/apps/api/src/app/symbol/symbol.controller.ts +++ b/apps/api/src/app/symbol/symbol.controller.ts @@ -1,10 +1,12 @@ import type { RequestWithUser } from '@ghostfolio/common/types'; import { Controller, + DefaultValuePipe, Get, HttpException, Inject, Param, + ParseBoolPipe, Query, UseGuards } from '@nestjs/common'; @@ -51,7 +53,9 @@ export class SymbolController { @UseGuards(AuthGuard('jwt')) public async getSymbolData( @Param('dataSource') dataSource: DataSource, - @Param('symbol') symbol: string + @Param('symbol') symbol: string, + @Query('includeHistoricalData', new DefaultValuePipe(false), ParseBoolPipe) + includeHistoricalData: boolean ): Promise { if (!DataSource[dataSource]) { throw new HttpException( @@ -60,7 +64,10 @@ export class SymbolController { ); } - const result = await this.symbolService.get({ dataSource, symbol }); + const result = await this.symbolService.get({ + includeHistoricalData, + dataGatheringItem: { dataSource, symbol } + }); if (!result || isEmpty(result)) { throw new HttpException( diff --git a/apps/api/src/app/symbol/symbol.module.ts b/apps/api/src/app/symbol/symbol.module.ts index 7b0da89a2..c5143632b 100644 --- a/apps/api/src/app/symbol/symbol.module.ts +++ b/apps/api/src/app/symbol/symbol.module.ts @@ -1,5 +1,6 @@ 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 { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { Module } from '@nestjs/common'; @@ -7,7 +8,12 @@ import { SymbolController } from './symbol.controller'; import { SymbolService } from './symbol.service'; @Module({ - imports: [ConfigurationModule, DataProviderModule, PrismaModule], + imports: [ + ConfigurationModule, + DataProviderModule, + MarketDataModule, + PrismaModule + ], controllers: [SymbolController], providers: [SymbolService] }) diff --git a/apps/api/src/app/symbol/symbol.service.ts b/apps/api/src/app/symbol/symbol.service.ts index 60a5e481c..3f377c551 100644 --- a/apps/api/src/app/symbol/symbol.service.ts +++ b/apps/api/src/app/symbol/symbol.service.ts @@ -1,8 +1,11 @@ +import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { Injectable, Logger } from '@nestjs/common'; import { DataSource } from '@prisma/client'; +import { subDays } from 'date-fns'; import { LookupItem } from './interfaces/lookup-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface'; @@ -11,16 +14,42 @@ import { SymbolItem } from './interfaces/symbol-item.interface'; export class SymbolService { public constructor( private readonly dataProviderService: DataProviderService, + private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService ) {} - public async get(dataGatheringItem: IDataGatheringItem): Promise { + public async get({ + dataGatheringItem, + includeHistoricalData = false + }: { + dataGatheringItem: IDataGatheringItem; + includeHistoricalData?: boolean; + }): Promise { const response = await this.dataProviderService.get([dataGatheringItem]); const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {}; if (dataGatheringItem.dataSource && marketPrice) { + let historicalData: HistoricalDataItem[]; + + if (includeHistoricalData) { + const days = 7; + + const marketData = await this.marketDataService.getRange({ + dateQuery: { gte: subDays(new Date(), days) }, + symbols: [dataGatheringItem.symbol] + }); + + historicalData = marketData.map(({ date, marketPrice }) => { + return { + date: date.toISOString(), + value: marketPrice + }; + }); + } + return { currency, + historicalData, marketPrice, dataSource: dataGatheringItem.dataSource }; diff --git a/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html b/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html index 0de6ac211..16b2ea602 100644 --- a/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html +++ b/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html @@ -1,13 +1,13 @@
-
{{ fearAndGreedIndexEmoji }}
+
{{ fearAndGreedIndexEmoji }}
-
+
{{ fearAndGreedIndexText }} {{ fearAndGreedIndex }}/100
- Market Mood + Current Market Mood
diff --git a/apps/client/src/app/components/home-market/home-market.component.ts b/apps/client/src/app/components/home-market/home-market.component.ts index 380ac9c8d..87a86814d 100644 --- a/apps/client/src/app/components/home-market/home-market.component.ts +++ b/apps/client/src/app/components/home-market/home-market.component.ts @@ -1,7 +1,9 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface'; import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; +import { resetHours } from '@ghostfolio/common/helper'; import { User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { DataSource } from '@prisma/client'; @@ -16,6 +18,7 @@ import { takeUntil } from 'rxjs/operators'; export class HomeMarketComponent implements OnDestroy, OnInit { public fearAndGreedIndex: number; public hasPermissionToAccessFearAndGreedIndex: boolean; + public historicalData: HistoricalDataItem[]; public isLoading = true; public user: User; @@ -46,11 +49,19 @@ export class HomeMarketComponent implements OnDestroy, OnInit { this.dataService .fetchSymbolItem({ dataSource: DataSource.RAKUTEN, + includeHistoricalData: true, symbol: ghostfolioFearAndGreedIndexSymbol }) .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ marketPrice }) => { + .subscribe(({ historicalData, marketPrice }) => { this.fearAndGreedIndex = marketPrice; + this.historicalData = [ + ...historicalData, + { + date: resetHours(new Date()).toISOString(), + value: marketPrice + } + ]; this.isLoading = false; this.changeDetectorRef.markForCheck(); diff --git a/apps/client/src/app/components/home-market/home-market.html b/apps/client/src/app/components/home-market/home-market.html index c92d29950..2705530bc 100644 --- a/apps/client/src/app/components/home-market/home-market.html +++ b/apps/client/src/app/components/home-market/home-market.html @@ -9,17 +9,26 @@ w-100 " > -
+
- - - - - +
+ Last 7 Days +
+ +
diff --git a/apps/client/src/app/components/home-market/home-market.module.ts b/apps/client/src/app/components/home-market/home-market.module.ts index 436552e61..01267b426 100644 --- a/apps/client/src/app/components/home-market/home-market.module.ts +++ b/apps/client/src/app/components/home-market/home-market.module.ts @@ -1,14 +1,14 @@ import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; -import { MatCardModule } from '@angular/material/card'; import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module'; +import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module'; import { HomeMarketComponent } from './home-market.component'; @NgModule({ declarations: [HomeMarketComponent], exports: [], - imports: [CommonModule, GfFearAndGreedIndexModule, MatCardModule], + imports: [CommonModule, GfFearAndGreedIndexModule, GfLineChartModule], providers: [], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) diff --git a/apps/client/src/app/components/home-market/home-market.scss b/apps/client/src/app/components/home-market/home-market.scss index b97d286cc..2d7ffa0dd 100644 --- a/apps/client/src/app/components/home-market/home-market.scss +++ b/apps/client/src/app/components/home-market/home-market.scss @@ -2,4 +2,8 @@ :host { display: block; + + gf-line-chart { + aspect-ratio: 16 / 9; + } } diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index a14dc3486..0af1990ac 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -127,12 +127,16 @@ export class DataService { public fetchSymbolItem({ dataSource, + includeHistoricalData = false, symbol }: { dataSource: DataSource; + includeHistoricalData?: boolean; symbol: string; }) { - return this.http.get(`/api/symbol/${dataSource}/${symbol}`); + return this.http.get(`/api/symbol/${dataSource}/${symbol}`, { + params: { includeHistoricalData } + }); } public fetchPositions({ diff --git a/libs/ui/src/lib/line-chart/line-chart.component.ts b/libs/ui/src/lib/line-chart/line-chart.component.ts index 4265aad14..601990715 100644 --- a/libs/ui/src/lib/line-chart/line-chart.component.ts +++ b/libs/ui/src/lib/line-chart/line-chart.component.ts @@ -43,6 +43,10 @@ export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy { @Input() showXAxis = false; @Input() showYAxis = false; @Input() symbol: string; + @Input() yMax: number; + @Input() yMaxLabel: string; + @Input() yMin: number; + @Input() yMinLabel: string; @ViewChild('chartCanvas') chartCanvas; @@ -170,11 +174,22 @@ export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy { grid: { display: false }, + max: this.yMax, + min: this.yMin, ticks: { display: this.showYAxis, - callback: function (tickValue, index, ticks) { + callback: (tickValue, index, ticks) => { if (index === 0 || index === ticks.length - 1) { // Only print last and first legend entry + + if (index === 0 && this.yMinLabel) { + return this.yMinLabel; + } + + if (index === ticks.length - 1 && this.yMaxLabel) { + return this.yMaxLabel; + } + if (typeof tickValue === 'number') { return tickValue.toFixed(2); }