Browse Source

Extend markets

pull/5076/head
Thomas Kaul 2 months ago
parent
commit
e15360636f
  1. 52
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  2. 8
      apps/api/src/app/endpoints/market-data/market-data.module.ts
  3. 141
      apps/client/src/app/components/markets/markets.component.ts
  4. 64
      apps/client/src/app/components/markets/markets.html
  5. 7
      apps/client/src/app/components/markets/markets.scss
  6. 6
      apps/client/src/app/pages/home/home-page-routing.module.ts
  7. 5
      apps/client/src/app/pages/home/home-page.component.ts
  8. 27
      apps/client/src/app/services/data.service.ts
  9. 2
      libs/common/src/lib/interfaces/index.ts
  10. 6
      libs/common/src/lib/interfaces/responses/market-data-of-markets-response.interface.ts
  11. 2
      libs/common/src/lib/permissions.ts
  12. 1
      libs/common/src/lib/types/fear-and-greed-index.type.ts
  13. 2
      libs/common/src/lib/types/index.ts

52
apps/api/src/app/endpoints/market-data/market-data.controller.ts

@ -1,8 +1,18 @@
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
ghostfolioFearAndGreedIndexDataSource,
ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { MarketDataDetailsResponse } from '@ghostfolio/common/interfaces';
import {
MarketDataDetailsResponse,
MarketDataOfMarketsResponse
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
@ -14,6 +24,7 @@ import {
Inject,
Param,
Post,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
@ -30,9 +41,46 @@ export class MarketDataController {
private readonly adminService: AdminService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly symbolProfileService: SymbolProfileService
private readonly symbolProfileService: SymbolProfileService,
private readonly symbolService: SymbolService
) {}
@Get('markets')
@HasPermission(permissions.readMarketDataOfMarkets)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketDataOfMarkets(
@Query('includeHistoricalData') includeHistoricalData = 0
): Promise<MarketDataOfMarketsResponse> {
const [
marketDataFearAndGreedIndexCryptocurrencies,
marketDataFearAndGreedIndexStocks
] = await Promise.all([
this.symbolService.get({
includeHistoricalData,
dataGatheringItem: {
dataSource: 'MANUAL',
symbol: 'GF_FEAR_AND_GREED_INDEX_CRYPTO'
}
}),
this.symbolService.get({
includeHistoricalData,
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSource,
symbol: ghostfolioFearAndGreedIndexSymbol
}
})
]);
return {
CRYPTOCURRENCIES: {
...marketDataFearAndGreedIndexCryptocurrencies
},
STOCKS: {
...marketDataFearAndGreedIndexStocks
}
};
}
@Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async getMarketDataBySymbol(

8
apps/api/src/app/endpoints/market-data/market-data.module.ts

@ -1,4 +1,5 @@
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -8,6 +9,11 @@ import { MarketDataController } from './market-data.controller';
@Module({
controllers: [MarketDataController],
imports: [AdminModule, MarketDataServiceModule, SymbolProfileModule]
imports: [
AdminModule,
MarketDataServiceModule,
SymbolModule,
SymbolProfileModule
]
})
export class MarketDataModule {}

141
apps/client/src/app/components/markets/markets.component.ts

@ -0,0 +1,141 @@
import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { resetHours } from '@ghostfolio/common/helper';
import {
Benchmark,
HistoricalDataItem,
InfoItem,
MarketDataOfMarketsResponse,
ToggleOption,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { FearAndGreedIndexMode } from '@ghostfolio/common/types';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
OnDestroy,
OnInit
} from '@angular/core';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
GfBenchmarkComponent,
GfFearAndGreedIndexModule,
GfLineChartComponent,
GfToggleModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-markets',
styleUrls: ['./markets.scss'],
templateUrl: './markets.html'
})
export class MarketsComponent implements OnDestroy, OnInit {
public benchmarks: Benchmark[];
public deviceType: string;
public fearAndGreedIndex: number;
public fearAndGreedIndexData: MarketDataOfMarketsResponse;
public fearLabel = $localize`Fear`;
public greedLabel = $localize`Greed`;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public historicalDataItems: HistoricalDataItem[];
public fearAndGreedIndexMode: FearAndGreedIndexMode = 'STOCKS';
public fearAndGreedIndexModeOptions: ToggleOption[] = [
{ label: $localize`Stocks`, value: 'STOCKS' },
{ label: $localize`Cryptocurrencies`, value: 'CRYPTOCURRENCIES' }
];
public info: InfoItem;
public readonly numberOfDays = 365;
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private userService: UserService
) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.info = this.dataService.fetchInfo();
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnInit() {
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.info?.globalPermissions,
permissions.enableFearAndGreedIndex
);
if (this.hasPermissionToAccessFearAndGreedIndex) {
this.dataService
.fetchMarketDataOfMarkets({ includeHistoricalData: this.numberOfDays })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((fearAndGreedIndexData) => {
this.fearAndGreedIndexData = fearAndGreedIndexData;
this.initialize();
this.changeDetectorRef.markForCheck();
});
}
this.dataService
.fetchBenchmarks()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ benchmarks }) => {
this.benchmarks = benchmarks;
this.changeDetectorRef.markForCheck();
});
}
public initialize() {
this.fearAndGreedIndex =
this.fearAndGreedIndexData[this.fearAndGreedIndexMode].marketPrice;
this.historicalDataItems = [
...this.fearAndGreedIndexData[this.fearAndGreedIndexMode].historicalData,
{
date: resetHours(new Date()).toISOString(),
value: this.fearAndGreedIndex
}
];
}
// TODO
public onChangeFearAndGreedIndexMode(
aFearAndGreedIndexMode: FearAndGreedIndexMode
) {
this.fearAndGreedIndexMode = aFearAndGreedIndexMode;
this.initialize();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

64
apps/client/src/app/components/markets/markets.html

@ -0,0 +1,64 @@
<div class="container">
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Markets</h1>
@if (hasPermissionToAccessFearAndGreedIndex) {
<div class="mb-5 row">
<div class="col-xs-12 col-md-10 offset-md-1">
<div class="d-flex">
<div
class="align-items-center d-flex flex-grow-1 justify-content-end"
>
<gf-toggle
class="d-none d-lg-block"
[defaultValue]="fearAndGreedIndexMode"
[isLoading]="false"
[options]="fearAndGreedIndexModeOptions"
(change)="onChangeFearAndGreedIndexMode($event.value)"
/>
</div>
</div>
<div class="mb-2 text-center text-muted">
<small i18n>Last {{ numberOfDays }} Days</small>
</div>
<gf-line-chart
class="mb-3"
symbol="Fear & Greed Index"
[colorScheme]="user?.settings?.colorScheme"
[historicalDataItems]="historicalDataItems"
[isAnimated]="true"
[locale]="user?.settings?.locale || undefined"
[showXAxis]="true"
[showYAxis]="true"
[yMax]="100"
[yMaxLabel]="greedLabel"
[yMin]="0"
[yMinLabel]="fearLabel"
/>
<gf-fear-and-greed-index
class="d-flex justify-content-center"
[fearAndGreedIndex]="fearAndGreedIndex"
/>
</div>
</div>
}
<div class="mb-3 row">
<div class="col-xs-12 col-md-10 offset-md-1">
<gf-benchmark
[benchmarks]="benchmarks"
[deviceType]="deviceType"
[locale]="user?.settings?.locale || undefined"
[user]="user"
/>
@if (benchmarks?.length > 0) {
<div
class="gf-text-wrap-balance line-height-1 mt-3 text-center text-muted"
>
<small i18n>
Calculations are based on delayed market data and may not be
displayed in real-time.</small
>
</div>
}
</div>
</div>
</div>

7
apps/client/src/app/components/markets/markets.scss

@ -0,0 +1,7 @@
:host {
display: block;
gf-line-chart {
aspect-ratio: 16 / 9;
}
}

6
apps/client/src/app/pages/home/home-page-routing.module.ts

@ -3,6 +3,7 @@ import { HomeMarketComponent } from '@ghostfolio/client/components/home-market/h
import { HomeOverviewComponent } from '@ghostfolio/client/components/home-overview/home-overview.component';
import { HomeSummaryComponent } from '@ghostfolio/client/components/home-summary/home-summary.component';
import { HomeWatchlistComponent } from '@ghostfolio/client/components/home-watchlist/home-watchlist.component';
import { MarketsComponent } from '@ghostfolio/client/components/markets/markets.component';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
@ -34,6 +35,11 @@ const routes: Routes = [
component: HomeMarketComponent,
title: internalRoutes.home.subRoutes.markets.title
},
{
path: internalRoutes.home.subRoutes.markets.path + '-new', // TODO
component: MarketsComponent,
title: internalRoutes.home.subRoutes.markets.title
},
{
path: internalRoutes.home.subRoutes.watchlist.path,
component: HomeWatchlistComponent,

5
apps/client/src/app/pages/home/home-page.component.ts

@ -58,6 +58,11 @@ export class HomePageComponent implements OnDestroy, OnInit {
iconName: 'newspaper-outline',
label: internalRoutes.home.subRoutes.markets.title,
routerLink: internalRoutes.home.subRoutes.markets.routerLink
},
{
iconName: 'newspaper-outline',
label: internalRoutes.home.subRoutes.markets.title + ' (new)',
routerLink: ['/home', 'markets-new'] // TODO
}
];

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

@ -38,6 +38,7 @@ import {
InfoItem,
LookupResponse,
MarketDataDetailsResponse,
MarketDataOfMarketsResponse,
OAuthResponse,
PortfolioDetails,
PortfolioDividends,
@ -483,6 +484,32 @@ export class DataService {
);
}
public fetchMarketDataOfMarkets({
includeHistoricalData
}: {
includeHistoricalData?: number;
}): Observable<MarketDataOfMarketsResponse> {
let params = new HttpParams();
if (includeHistoricalData) {
params = params.append('includeHistoricalData', includeHistoricalData);
}
return this.http.get<any>('/api/v1/market-data/markets', { params }).pipe(
map((data) => {
for (const item of data.CRYPTOCURRENCIES.historicalData) {
item.date = parseISO(item.date);
}
for (const item of data.STOCKS.historicalData) {
item.date = parseISO(item.date);
}
return data;
})
);
}
public fetchSymbolItem({
dataSource,
includeHistoricalData,

2
libs/common/src/lib/interfaces/index.ts

@ -51,6 +51,7 @@ import type { HistoricalResponse } from './responses/historical-response.interfa
import type { ImportResponse } from './responses/import-response.interface';
import type { LookupResponse } from './responses/lookup-response.interface';
import type { MarketDataDetailsResponse } from './responses/market-data-details-response.interface';
import type { MarketDataOfMarketsResponse } from './responses/market-data-of-markets-response.interface';
import type { OAuthResponse } from './responses/oauth-response.interface';
import { PortfolioHoldingResponse } from './responses/portfolio-holding-response.interface';
import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface';
@ -111,6 +112,7 @@ export {
LookupItem,
LookupResponse,
MarketDataDetailsResponse,
MarketDataOfMarketsResponse,
OAuthResponse,
PortfolioChart,
PortfolioDetails,

6
libs/common/src/lib/interfaces/responses/market-data-of-markets-response.interface.ts

@ -0,0 +1,6 @@
import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface';
export interface MarketDataOfMarketsResponse {
CRYPTOCURRENCIES: SymbolItem;
STOCKS: SymbolItem;
}

2
libs/common/src/lib/permissions.ts

@ -40,6 +40,7 @@ export const permissions = {
impersonateAllUsers: 'impersonateAllUsers',
readAiPrompt: 'readAiPrompt',
readMarketData: 'readMarketData',
readMarketDataOfMarkets: 'readMarketDataOfMarkets', // TODO
readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile',
readPlatforms: 'readPlatforms',
readTags: 'readTags',
@ -87,6 +88,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.deleteUser,
permissions.readAiPrompt,
permissions.readMarketData,
permissions.readMarketDataOfMarkets,
permissions.readMarketDataOfOwnAssetProfile,
permissions.readPlatforms,
permissions.readTags,

1
libs/common/src/lib/types/fear-and-greed-index.type.ts

@ -0,0 +1 @@
export type FearAndGreedIndexMode = 'CRYPTOCURRENCIES' | 'STOCKS';

2
libs/common/src/lib/types/index.ts

@ -6,6 +6,7 @@ import type { AiPromptMode } from './ai-prompt-mode.type';
import type { BenchmarkTrend } from './benchmark-trend.type';
import type { ColorScheme } from './color-scheme.type';
import type { DateRange } from './date-range.type';
import type { FearAndGreedIndexMode } from './fear-and-greed-index.type';
import type { Granularity } from './granularity.type';
import type { GroupBy } from './group-by.type';
import type { HoldingType } from './holding-type.type';
@ -30,6 +31,7 @@ export type {
BenchmarkTrend,
ColorScheme,
DateRange,
FearAndGreedIndexMode,
Granularity,
GroupBy,
HoldingType,

Loading…
Cancel
Save