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
|
## 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)
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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(
|
||||||
|
@ -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]
|
||||||
})
|
})
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
|
@ -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();
|
||||||
|
@ -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>
|
||||||
<gf-fear-and-greed-index
|
</div>
|
||||||
class="d-flex justify-content-center"
|
<gf-line-chart
|
||||||
[fearAndGreedIndex]="fearAndGreedIndex"
|
class="mb-5"
|
||||||
[hidden]="isLoading"
|
yMax="100"
|
||||||
></gf-fear-and-greed-index>
|
yMaxLabel="Greed"
|
||||||
</mat-card-content>
|
yMin="0"
|
||||||
</mat-card>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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]
|
||||||
})
|
})
|
||||||
|
@ -2,4 +2,8 @@
|
|||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
gf-line-chart {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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({
|
||||||
|
@ -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…
x
Reference in New Issue
Block a user