Browse Source

Feature/add markets endpoint for Fear & Greed index to Ghostfolio data provider (#7323)

* Add markets endpoint

* Update changelog
pull/7326/head
Thomas Kaul 1 day ago
committed by GitHub
parent
commit
69254032a3
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 39
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  3. 2
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts
  4. 33
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  5. 43
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  6. 8
      apps/api/src/app/endpoints/market-data/market-data.module.ts
  7. 46
      apps/api/src/app/symbol/symbol.service.ts
  8. 22
      apps/client/src/app/pages/api/api-page.component.ts
  9. 52
      apps/client/src/app/pages/api/api-page.html

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 markets endpoint for the _Fear & Greed Index_ (market mood) to the `GHOSTFOLIO` data provider
### Changed ### Changed
- Hardened the validation of the countries in the asset profile endpoints - Hardened the validation of the countries in the asset profile endpoints

39
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -8,6 +8,7 @@ import {
DividendsResponse, DividendsResponse,
HistoricalResponse, HistoricalResponse,
LookupResponse, LookupResponse,
MarketDataOfMarketsResponse,
QuotesResponse QuotesResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -19,6 +20,7 @@ import {
HttpException, HttpException,
Inject, Inject,
Param, Param,
ParseIntPipe,
Query, Query,
UseGuards, UseGuards,
Version Version
@ -203,6 +205,43 @@ export class GhostfolioController {
} }
} }
@Get('markets')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
public async getMarketDataOfMarkets(
@Query('includeHistoricalData', new ParseIntPipe({ optional: true }))
includeHistoricalData = 0
): Promise<MarketDataOfMarketsResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const marketDataOfMarkets =
await this.ghostfolioService.getMarketDataOfMarkets({
includeHistoricalData
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return marketDataOfMarkets;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('quotes') @Get('quotes')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)

2
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts

@ -1,4 +1,5 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
@ -33,6 +34,7 @@ import { GhostfolioService } from './ghostfolio.service';
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolModule,
SymbolProfileModule SymbolProfileModule
], ],
providers: [ providers: [

33
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -1,3 +1,4 @@
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service'; import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service';
@ -25,6 +26,7 @@ import {
HistoricalResponse, HistoricalResponse,
LookupItem, LookupItem,
LookupResponse, LookupResponse,
MarketDataOfMarketsResponse,
QuotesResponse QuotesResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
@ -32,6 +34,7 @@ import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isEmpty } from 'lodash';
@Injectable() @Injectable()
export class GhostfolioService { export class GhostfolioService {
@ -42,7 +45,8 @@ export class GhostfolioService {
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly fetchService: FetchService, private readonly fetchService: FetchService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService private readonly propertyService: PropertyService,
private readonly symbolService: SymbolService
) {} ) {}
public async getAssetProfile({ symbol }: GetAssetProfileParams) { public async getAssetProfile({ symbol }: GetAssetProfileParams) {
@ -198,6 +202,33 @@ export class GhostfolioService {
} }
} }
public async getMarketDataOfMarkets({
includeHistoricalData
}: {
includeHistoricalData: number;
}): Promise<MarketDataOfMarketsResponse> {
try {
const marketDataOfMarkets =
await this.symbolService.getMarketDataOfMarkets({
includeHistoricalData
});
for (const symbolItem of Object.values(
marketDataOfMarkets.fearAndGreedIndex
)) {
if (!isEmpty(symbolItem)) {
symbolItem.dataSource = DataSource.GHOSTFOLIO;
}
}
return marketDataOfMarkets;
} catch (error) {
this.logger.error(error);
throw error;
}
}
public async getMaxDailyRequests() { public async getMaxDailyRequests() {
return parseInt( return parseInt(
(await this.propertyService.getByKey<string>( (await this.propertyService.getByKey<string>(

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

@ -1,14 +1,8 @@
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks
} from '@ghostfolio/common/config';
import { UpdateBulkMarketDataDto } from '@ghostfolio/common/dtos'; import { UpdateBulkMarketDataDto } from '@ghostfolio/common/dtos';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces'; import { MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces';
@ -36,7 +30,6 @@ import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@Controller('market-data') @Controller('market-data')
export class MarketDataController { export class MarketDataController {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService,
@ -50,39 +43,9 @@ export class MarketDataController {
@Query('includeHistoricalData', new ParseIntPipe({ optional: true })) @Query('includeHistoricalData', new ParseIntPipe({ optional: true }))
includeHistoricalData = 0 includeHistoricalData = 0
): Promise<MarketDataOfMarketsResponse> { ): Promise<MarketDataOfMarketsResponse> {
const [ return this.symbolService.getMarketDataOfMarkets({
marketDataFearAndGreedIndexCryptocurrencies, includeHistoricalData
marketDataFearAndGreedIndexStocks });
] = await Promise.all([
this.symbolService.get({
includeHistoricalData,
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies
},
useIntradayData: true
}),
this.symbolService.get({
includeHistoricalData,
dataGatheringItem: {
dataSource:
this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(),
symbol: ghostfolioFearAndGreedIndexSymbolStocks
},
useIntradayData: true
})
]);
return {
fearAndGreedIndex: {
CRYPTOCURRENCIES: {
...marketDataFearAndGreedIndexCryptocurrencies
},
STOCKS: {
...marketDataFearAndGreedIndexStocks
}
}
};
} }
@Post(':dataSource/:symbol') @Post(':dataSource/:symbol')

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

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

46
apps/api/src/app/symbol/symbol.service.ts

@ -1,6 +1,11 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import {
ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks
} from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
getAssetProfileIdentifier getAssetProfileIdentifier
@ -9,6 +14,7 @@ import {
DataProviderHistoricalResponse, DataProviderHistoricalResponse,
HistoricalDataItem, HistoricalDataItem,
LookupResponse, LookupResponse,
MarketDataOfMarketsResponse,
SymbolItem SymbolItem
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
@ -122,6 +128,46 @@ export class SymbolService {
}; };
} }
public async getMarketDataOfMarkets({
includeHistoricalData
}: {
includeHistoricalData: number;
}): Promise<MarketDataOfMarketsResponse> {
const [
marketDataFearAndGreedIndexCryptocurrencies,
marketDataFearAndGreedIndexStocks
] = await Promise.all([
this.get({
includeHistoricalData,
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies
},
useIntradayData: true
}),
this.get({
includeHistoricalData,
dataGatheringItem: {
dataSource:
this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(),
symbol: ghostfolioFearAndGreedIndexSymbolStocks
},
useIntradayData: true
})
]);
return {
fearAndGreedIndex: {
CRYPTOCURRENCIES: {
...marketDataFearAndGreedIndexCryptocurrencies
},
STOCKS: {
...marketDataFearAndGreedIndexStocks
}
}
};
}
public async lookup({ public async lookup({
includeIndices = false, includeIndices = false,
query, query,

22
apps/client/src/app/pages/api/api-page.component.ts

@ -1,3 +1,4 @@
import { GfFearAndGreedIndexComponent } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.component';
import { import {
HEADER_KEY_SKIP_INTERCEPTOR, HEADER_KEY_SKIP_INTERCEPTOR,
HEADER_KEY_TOKEN HEADER_KEY_TOKEN
@ -10,6 +11,7 @@ import {
DividendsResponse, DividendsResponse,
HistoricalResponse, HistoricalResponse,
LookupResponse, LookupResponse,
MarketDataOfMarketsResponse,
QuotesResponse QuotesResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -32,7 +34,12 @@ import { FetchFailure, FetchResult } from './interfaces/interfaces';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
imports: [CommonModule, MatCardModule, NgxSkeletonLoaderModule], imports: [
CommonModule,
GfFearAndGreedIndexComponent,
MatCardModule,
NgxSkeletonLoaderModule
],
selector: 'gf-api-page', selector: 'gf-api-page',
styleUrls: ['./api-page.scss'], styleUrls: ['./api-page.scss'],
templateUrl: './api-page.html' templateUrl: './api-page.html'
@ -48,6 +55,9 @@ export class GfApiPageComponent implements OnInit {
>; >;
protected isinLookupItems$: Observable<FetchResult<LookupResponse['items']>>; protected isinLookupItems$: Observable<FetchResult<LookupResponse['items']>>;
protected lookupItems$: Observable<FetchResult<LookupResponse['items']>>; protected lookupItems$: Observable<FetchResult<LookupResponse['items']>>;
protected marketDataOfMarkets$: Observable<
FetchResult<MarketDataOfMarketsResponse>
>;
protected quotes$: Observable<FetchResult<QuotesResponse['quotes']>>; protected quotes$: Observable<FetchResult<QuotesResponse['quotes']>>;
protected status$: Observable< protected status$: Observable<
FetchResult<DataProviderGhostfolioStatusResponse> FetchResult<DataProviderGhostfolioStatusResponse>
@ -68,6 +78,7 @@ export class GfApiPageComponent implements OnInit {
this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL' }); this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL' });
this.isinLookupItems$ = this.fetchLookupItems({ query: 'US0378331005' }); this.isinLookupItems$ = this.fetchLookupItems({ query: 'US0378331005' });
this.lookupItems$ = this.fetchLookupItems({ query: 'apple' }); this.lookupItems$ = this.fetchLookupItems({ query: 'apple' });
this.marketDataOfMarkets$ = this.fetchMarketDataOfMarkets();
this.quotes$ = this.fetchQuotes({ symbols: ['AAPL', 'VOO'] }); this.quotes$ = this.fetchQuotes({ symbols: ['AAPL', 'VOO'] });
this.status$ = this.fetchStatus(); this.status$ = this.fetchStatus();
} }
@ -172,6 +183,15 @@ export class GfApiPageComponent implements OnInit {
); );
} }
private fetchMarketDataOfMarkets() {
return this.http
.get<MarketDataOfMarketsResponse>(
'/api/v1/data-providers/ghostfolio/markets',
{ headers: this.getHeaders() }
)
.pipe(this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef));
}
private fetchQuotes({ symbols }: { symbols: string[] }) { private fetchQuotes({ symbols }: { symbols: string[] }) {
const params = new HttpParams().set('symbols', symbols.join(',')); const params = new HttpParams().set('symbols', symbols.join(','));

52
apps/client/src/app/pages/api/api-page.html

@ -168,6 +168,58 @@
} }
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
<div class="row">
@let marketDataOfMarkets = marketDataOfMarkets$ | async;
<div class="col-xs-12 col-md-6">
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title class="mb-0">Markets</mat-card-title>
<mat-card-subtitle>Stocks</mat-card-subtitle>
</mat-card-header>
<mat-card-content class="pt-3">
@if (isFetchFailure(marketDataOfMarkets)) {
🔴 {{ marketDataOfMarkets.fetchError }}
} @else if (marketDataOfMarkets) {
<gf-fear-and-greed-index
[fearAndGreedIndex]="
marketDataOfMarkets.fearAndGreedIndex.STOCKS.marketPrice
"
/>
} @else {
<ngx-skeleton-loader
animation="pulse"
[theme]="{ height: '2.5rem', width: '100%' }"
/>
}
</mat-card-content>
</mat-card>
</div>
<div class="col-xs-12 col-md-6">
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title class="mb-0">Markets</mat-card-title>
<mat-card-subtitle>Cryptocurrencies</mat-card-subtitle>
</mat-card-header>
<mat-card-content class="pt-3">
@if (isFetchFailure(marketDataOfMarkets)) {
🔴 {{ marketDataOfMarkets.fetchError }}
} @else if (marketDataOfMarkets) {
<gf-fear-and-greed-index
[fearAndGreedIndex]="
marketDataOfMarkets.fearAndGreedIndex.CRYPTOCURRENCIES
.marketPrice
"
/>
} @else {
<ngx-skeleton-loader
animation="pulse"
[theme]="{ height: '2.5rem', width: '100%' }"
/>
}
</mat-card-content>
</mat-card>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

Loading…
Cancel
Save