Browse Source

Feature/add historical data chart of fear and greed index (#515)

* Add historical data chart of market mood

* Update changelog
pull/516/head
Thomas Kaul 3 years ago
committed by GitHub
parent
commit
3e82de6b21
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      CHANGELOG.md
  2. 2
      apps/api/src/app/symbol/interfaces/symbol-item.interface.ts
  3. 11
      apps/api/src/app/symbol/symbol.controller.ts
  4. 8
      apps/api/src/app/symbol/symbol.module.ts
  5. 31
      apps/api/src/app/symbol/symbol.service.ts
  6. 6
      apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html
  7. 13
      apps/client/src/app/components/home-market/home-market.component.ts
  8. 19
      apps/client/src/app/components/home-market/home-market.html
  9. 4
      apps/client/src/app/components/home-market/home-market.module.ts
  10. 4
      apps/client/src/app/components/home-market/home-market.scss
  11. 6
      apps/client/src/app/services/data.service.ts
  12. 17
      libs/ui/src/lib/line-chart/line-chart.component.ts

4
CHANGELOG.md

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added the historical data chart of the _Fear & Greed Index_ (market mood)
### Changed ### Changed
- Improved the historical data view in the admin control panel (hide invalid and future dates) - Improved the historical data view in the admin control panel (hide invalid and future dates)

2
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'; import { DataSource } from '@prisma/client';
export interface SymbolItem { export interface SymbolItem {
currency: string; currency: string;
dataSource: DataSource; dataSource: DataSource;
historicalData: HistoricalDataItem[];
marketPrice: number; marketPrice: number;
} }

11
apps/api/src/app/symbol/symbol.controller.ts

@ -1,10 +1,12 @@
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
DefaultValuePipe,
Get, Get,
HttpException, HttpException,
Inject, Inject,
Param, Param,
ParseBoolPipe,
Query, Query,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
@ -51,7 +53,9 @@ export class SymbolController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getSymbolData( public async getSymbolData(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string,
@Query('includeHistoricalData', new DefaultValuePipe(false), ParseBoolPipe)
includeHistoricalData: boolean
): Promise<SymbolItem> { ): Promise<SymbolItem> {
if (!DataSource[dataSource]) { if (!DataSource[dataSource]) {
throw new HttpException( 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)) { if (!result || isEmpty(result)) {
throw new HttpException( throw new HttpException(

8
apps/api/src/app/symbol/symbol.module.ts

@ -1,5 +1,6 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -7,7 +8,12 @@ import { SymbolController } from './symbol.controller';
import { SymbolService } from './symbol.service'; import { SymbolService } from './symbol.service';
@Module({ @Module({
imports: [ConfigurationModule, DataProviderModule, PrismaModule], imports: [
ConfigurationModule,
DataProviderModule,
MarketDataModule,
PrismaModule
],
controllers: [SymbolController], controllers: [SymbolController],
providers: [SymbolService] providers: [SymbolService]
}) })

31
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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; 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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { subDays } from 'date-fns';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface';
@ -11,16 +14,42 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
export class SymbolService { export class SymbolService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
public async get(dataGatheringItem: IDataGatheringItem): Promise<SymbolItem> { public async get({
dataGatheringItem,
includeHistoricalData = false
}: {
dataGatheringItem: IDataGatheringItem;
includeHistoricalData?: boolean;
}): Promise<SymbolItem> {
const response = await this.dataProviderService.get([dataGatheringItem]); const response = await this.dataProviderService.get([dataGatheringItem]);
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {}; const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
if (dataGatheringItem.dataSource && marketPrice) { 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 { return {
currency, currency,
historicalData,
marketPrice, marketPrice,
dataSource: dataGatheringItem.dataSource dataSource: dataGatheringItem.dataSource
}; };

6
apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html

@ -1,13 +1,13 @@
<div class="align-items-center d-flex flex-row"> <div class="align-items-center d-flex flex-row">
<div class="h3 mb-0 mr-2">{{ fearAndGreedIndexEmoji }}</div> <div class="h2 mb-0 mr-2">{{ fearAndGreedIndexEmoji }}</div>
<div> <div>
<div class="h3 mb-0"> <div class="h4 mb-0">
<span class="mr-2">{{ fearAndGreedIndexText }}</span> <span class="mr-2">{{ fearAndGreedIndexText }}</span>
<small class="text-muted" <small class="text-muted"
><strong>{{ fearAndGreedIndex }}</strong ><strong>{{ fearAndGreedIndex }}</strong
>/100</small >/100</small
> >
</div> </div>
<small class="d-block" i18n>Market Mood</small> <small class="d-block" i18n>Current Market Mood</small>
</div> </div>
</div> </div>

13
apps/client/src/app/components/home-market/home-market.component.ts

@ -1,7 +1,9 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; 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 { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -16,6 +18,7 @@ import { takeUntil } from 'rxjs/operators';
export class HomeMarketComponent implements OnDestroy, OnInit { export class HomeMarketComponent implements OnDestroy, OnInit {
public fearAndGreedIndex: number; public fearAndGreedIndex: number;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public historicalData: HistoricalDataItem[];
public isLoading = true; public isLoading = true;
public user: User; public user: User;
@ -46,11 +49,19 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchSymbolItem({ .fetchSymbolItem({
dataSource: DataSource.RAKUTEN, dataSource: DataSource.RAKUTEN,
includeHistoricalData: true,
symbol: ghostfolioFearAndGreedIndexSymbol symbol: ghostfolioFearAndGreedIndexSymbol
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketPrice }) => { .subscribe(({ historicalData, marketPrice }) => {
this.fearAndGreedIndex = marketPrice; this.fearAndGreedIndex = marketPrice;
this.historicalData = [
...historicalData,
{
date: resetHours(new Date()).toISOString(),
value: marketPrice
}
];
this.isLoading = false; this.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

19
apps/client/src/app/components/home-market/home-market.html

@ -9,17 +9,26 @@
w-100 w-100
" "
> >
<div class="row w-100"> <div class="no-gutters row w-100">
<div class="col-xs-12 col-md-8 offset-md-2"> <div class="col-xs-12 col-md-8 offset-md-2">
<mat-card class="h-100"> <div class="mb-2 text-center text-muted">
<mat-card-content> <small i18n>Last 7 Days</small>
</div>
<gf-line-chart
class="mb-5"
yMax="100"
yMaxLabel="Greed"
yMin="0"
yMinLabel="Fear"
[historicalDataItems]="historicalData"
[showXAxis]="true"
[showYAxis]="true"
></gf-line-chart>
<gf-fear-and-greed-index <gf-fear-and-greed-index
class="d-flex justify-content-center" class="d-flex justify-content-center"
[fearAndGreedIndex]="fearAndGreedIndex" [fearAndGreedIndex]="fearAndGreedIndex"
[hidden]="isLoading" [hidden]="isLoading"
></gf-fear-and-greed-index> ></gf-fear-and-greed-index>
</mat-card-content>
</mat-card>
</div> </div>
</div> </div>
</div> </div>

4
apps/client/src/app/components/home-market/home-market.module.ts

@ -1,14 +1,14 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; 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 { 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'; import { HomeMarketComponent } from './home-market.component';
@NgModule({ @NgModule({
declarations: [HomeMarketComponent], declarations: [HomeMarketComponent],
exports: [], exports: [],
imports: [CommonModule, GfFearAndGreedIndexModule, MatCardModule], imports: [CommonModule, GfFearAndGreedIndexModule, GfLineChartModule],
providers: [], providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

4
apps/client/src/app/components/home-market/home-market.scss

@ -2,4 +2,8 @@
:host { :host {
display: block; display: block;
gf-line-chart {
aspect-ratio: 16 / 9;
}
} }

6
apps/client/src/app/services/data.service.ts

@ -127,12 +127,16 @@ export class DataService {
public fetchSymbolItem({ public fetchSymbolItem({
dataSource, dataSource,
includeHistoricalData = false,
symbol symbol
}: { }: {
dataSource: DataSource; dataSource: DataSource;
includeHistoricalData?: boolean;
symbol: string; symbol: string;
}) { }) {
return this.http.get<SymbolItem>(`/api/symbol/${dataSource}/${symbol}`); return this.http.get<SymbolItem>(`/api/symbol/${dataSource}/${symbol}`, {
params: { includeHistoricalData }
});
} }
public fetchPositions({ public fetchPositions({

17
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() showXAxis = false;
@Input() showYAxis = false; @Input() showYAxis = false;
@Input() symbol: string; @Input() symbol: string;
@Input() yMax: number;
@Input() yMaxLabel: string;
@Input() yMin: number;
@Input() yMinLabel: string;
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas;
@ -170,11 +174,22 @@ export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy {
grid: { grid: {
display: false display: false
}, },
max: this.yMax,
min: this.yMin,
ticks: { ticks: {
display: this.showYAxis, display: this.showYAxis,
callback: function (tickValue, index, ticks) { callback: (tickValue, index, ticks) => {
if (index === 0 || index === ticks.length - 1) { if (index === 0 || index === ticks.length - 1) {
// Only print last and first legend entry // 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') { if (typeof tickValue === 'number') {
return tickValue.toFixed(2); return tickValue.toFixed(2);
} }

Loading…
Cancel
Save