Feature/add historical data chart of fear and greed index (#515)
* Add historical data chart of market mood * Update changelog
This commit is contained in:
parent
563f354e7e
commit
3e82de6b21
@ -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)
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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<SymbolItem> {
|
||||
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(
|
||||
|
@ -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]
|
||||
})
|
||||
|
@ -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<SymbolItem> {
|
||||
public async get({
|
||||
dataGatheringItem,
|
||||
includeHistoricalData = false
|
||||
}: {
|
||||
dataGatheringItem: IDataGatheringItem;
|
||||
includeHistoricalData?: boolean;
|
||||
}): Promise<SymbolItem> {
|
||||
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
|
||||
};
|
||||
|
@ -1,13 +1,13 @@
|
||||
<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 class="h3 mb-0">
|
||||
<div class="h4 mb-0">
|
||||
<span class="mr-2">{{ fearAndGreedIndexText }}</span>
|
||||
<small class="text-muted"
|
||||
><strong>{{ fearAndGreedIndex }}</strong
|
||||
>/100</small
|
||||
>
|
||||
</div>
|
||||
<small class="d-block" i18n>Market Mood</small>
|
||||
<small class="d-block" i18n>Current Market Mood</small>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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();
|
||||
|
@ -9,17 +9,26 @@
|
||||
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">
|
||||
<mat-card class="h-100">
|
||||
<mat-card-content>
|
||||
<div class="mb-2 text-center text-muted">
|
||||
<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
|
||||
class="d-flex justify-content-center"
|
||||
[fearAndGreedIndex]="fearAndGreedIndex"
|
||||
[hidden]="isLoading"
|
||||
></gf-fear-and-greed-index>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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]
|
||||
})
|
||||
|
@ -2,4 +2,8 @@
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
gf-line-chart {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
}
|
||||
|
@ -127,12 +127,16 @@ export class DataService {
|
||||
|
||||
public fetchSymbolItem({
|
||||
dataSource,
|
||||
includeHistoricalData = false,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
includeHistoricalData?: boolean;
|
||||
symbol: string;
|
||||
}) {
|
||||
return this.http.get<SymbolItem>(`/api/symbol/${dataSource}/${symbol}`);
|
||||
return this.http.get<SymbolItem>(`/api/symbol/${dataSource}/${symbol}`, {
|
||||
params: { includeHistoricalData }
|
||||
});
|
||||
}
|
||||
|
||||
public fetchPositions({
|
||||
|
@ -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);
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user