diff --git a/packages/server/src/common/hyperliquid/index.ts b/packages/server/src/common/hyperliquid/index.ts new file mode 100644 index 0000000..3ee7f61 --- /dev/null +++ b/packages/server/src/common/hyperliquid/index.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; + +import { HyperliquidService } from './service'; + +@Module({ + imports: [HttpModule], + providers: [HyperliquidService], + exports: [HyperliquidService], +}) +class HyperliquidModule {} + +export { HyperliquidModule, HyperliquidService }; +export type { PredictedFundingData, HlPerpData } from './types'; diff --git a/packages/server/src/common/hyperliquid/service.ts b/packages/server/src/common/hyperliquid/service.ts new file mode 100644 index 0000000..abe22f4 --- /dev/null +++ b/packages/server/src/common/hyperliquid/service.ts @@ -0,0 +1,111 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; + +import type { + SpotMetaResponse, + MetaResponse, + PredictedFundingData, + HlPerpData, +} from './types'; + +@Injectable() +export class HyperliquidService { + private readonly logger = new Logger(HyperliquidService.name); + private readonly baseUrl = 'https://api.hyperliquid.xyz'; + + constructor(private readonly httpService: HttpService) {} + + private async requestInfo(request: { type: string }): Promise { + try { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/info`, request, { + headers: { 'Content-Type': 'application/json' }, + }), + ); + return response.data; + } catch (error) { + this.logger.error(`Failed to request info for type: ${request.type}`, error); + throw error; + } + } + + async getSpotIndex(symbol: string) { + const { tokens, universe } = await this.requestInfo({ + type: 'spotMeta', + }); + + const token = tokens.find((token) => token.name === symbol); + if (token?.index === undefined) { + throw new Error(`Token ${symbol} not found`); + } + + const spot = universe.find((asset) => asset.tokens[0] === token.index); + if (spot?.index === undefined) { + throw new Error(`Spot ${symbol} not found`); + } + + return { + tokenIndex: token.index, + spotIndex: spot.index, + meta: { token, spot }, + }; + } + + async getPerpIndex(symbol: string) { + const { universe } = await this.requestInfo({ + type: 'meta', + }); + + const perpIndex = universe.findIndex((asset) => asset.name === symbol); + if (perpIndex === -1) { + throw new Error(`Perpetual ${symbol} not found`); + } + + return { perpIndex, meta: universe[perpIndex] }; + } + + async getIndexesBySymbol(symbol: string) { + const [spot, perp] = await Promise.all([ + this.getSpotIndex(symbol), + this.getPerpIndex(symbol), + ]); + + return { + symbol, + tokenIndex: spot.tokenIndex, + spotIndex: spot.spotIndex, + perpIndex: perp.perpIndex, + }; + } + + async fetchPredictedFundings(): Promise<{ + rawData: PredictedFundingData[]; + hlPerpData: HlPerpData[]; + }> { + try { + const rawData = await this.requestInfo({ + type: 'predictedFundings', + }); + + const hlPerpData: HlPerpData[] = rawData.reduce( + (acc, [ticker, exchanges]) => { + const hlPerpEntry = exchanges.find( + ([exchangeName]) => exchangeName === 'HlPerp', + ); + if (hlPerpEntry && hlPerpEntry[1]) { + acc.push({ ticker, fundingRate: hlPerpEntry[1].fundingRate }); + } + return acc; + }, + [], + ); + + this.logger.log(`Fetched ${hlPerpData.length} HlPerp funding entries`); + return { rawData, hlPerpData }; + } catch (error) { + this.logger.error('Failed to fetch predicted fundings from API', error); + throw error; + } + } +} \ No newline at end of file diff --git a/packages/server/src/common/hyperliquid/types.ts b/packages/server/src/common/hyperliquid/types.ts new file mode 100644 index 0000000..76d1f64 --- /dev/null +++ b/packages/server/src/common/hyperliquid/types.ts @@ -0,0 +1,33 @@ +export interface SpotMetaResponse { + tokens: { name: string; index: number }[]; + universe: { + index: number; + tokens: [tokenIndex: number, quoteTokenIndex: number]; + }[]; +} + +export interface MetaResponse { + universe: { + szDecimals: number; + name: string; + maxLeverage: number; + marginTableId: number; + }[]; +} + +export type PredictedFundingData = [ + string, + [ + string, + { + fundingRate: string; + nextFundingTime: number; + fundingIntervalHours: number; + } | null, + ][], +]; + +export interface HlPerpData { + ticker: string; + fundingRate: string; +} \ No newline at end of file diff --git a/packages/server/src/domain/tasks/controller.ts b/packages/server/src/domain/tasks/controller.ts index 744584f..e7b44bc 100644 --- a/packages/server/src/domain/tasks/controller.ts +++ b/packages/server/src/domain/tasks/controller.ts @@ -1,14 +1,30 @@ -import { Controller, Post } from '@nestjs/common'; +import { Controller, Post, Body } from '@nestjs/common'; -import { FundingRatesJob } from './jobs'; +import { MainnetFundingRatesJob, TestnetFundingRatesJob } from './jobs'; + +interface TriggerJobDto { + network?: 'mainnet' | 'testnet'; +} @Controller('tasks') export class TasksController { - constructor(private readonly fundingRatesJob: FundingRatesJob) {} + constructor( + private readonly mainnetFundingRatesJob: MainnetFundingRatesJob, + private readonly testnetFundingRatesJob: TestnetFundingRatesJob, + ) {} @Post('funding-rates') - async triggerFundingRatesJob() { - await this.fundingRatesJob.fetchAndSaveFundingData(); - return { message: 'Funding rates job executed successfully' }; + async triggerFundingRatesJob(@Body() body: TriggerJobDto) { + const selectedNetwork = body.network || 'mainnet'; + + if (selectedNetwork === 'testnet') { + await this.testnetFundingRatesJob.scheduledFetch(); + } else { + await this.mainnetFundingRatesJob.scheduledFetch(); + } + + return { + message: `${selectedNetwork} funding rates job executed successfully`, + }; } } diff --git a/packages/server/src/domain/tasks/index.ts b/packages/server/src/domain/tasks/index.ts index a263b95..e55514f 100644 --- a/packages/server/src/domain/tasks/index.ts +++ b/packages/server/src/domain/tasks/index.ts @@ -4,18 +4,34 @@ import { ScheduleModule } from '@nestjs/schedule'; import { DatabaseModule } from '@/common/database'; import { ObjectStorageModule } from '@/common/object-storage'; +import { HyperliquidModule } from '@/common/hyperliquid'; -import { FundingRatesJob } from './jobs'; +import { + FundingRatesJob, + MainnetFundingRatesJob, + TestnetFundingRatesJob, +} from './jobs'; +import { + FundingDataProcessorService, + FundingDataStorageService, +} from './services'; import { TasksController } from './controller'; @Module({ imports: [ - HttpModule, - ScheduleModule.forRoot(), DatabaseModule, ObjectStorageModule, + ScheduleModule.forRoot(), + HyperliquidModule, + ], + providers: [ + FundingDataProcessorService, + FundingDataStorageService, + + FundingRatesJob, + MainnetFundingRatesJob, + TestnetFundingRatesJob, ], - providers: [FundingRatesJob], controllers: [TasksController], }) class TasksModule {} diff --git a/packages/server/src/domain/tasks/jobs/base-funding-rates.ts b/packages/server/src/domain/tasks/jobs/base-funding-rates.ts new file mode 100644 index 0000000..2dcbc0d --- /dev/null +++ b/packages/server/src/domain/tasks/jobs/base-funding-rates.ts @@ -0,0 +1,46 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { Network } from '@crest/database'; + +import { HyperliquidService } from '@/common/hyperliquid'; + +import { FundingDataProcessorService } from '../services/funding-data-processor'; +import { FundingDataStorageService } from '../services/funding-data-storage'; + +@Injectable() +export abstract class BaseFundingRatesJob { + protected readonly logger = new Logger(this.constructor.name); + + constructor( + protected readonly hyperliquidService: HyperliquidService, + protected readonly dataProcessor: FundingDataProcessorService, + protected readonly dataStorage: FundingDataStorageService, + ) {} + + protected abstract getNetwork(): Network; + + protected async fetchAndSaveFundingData(): Promise { + const network = this.getNetwork(); + + try { + this.logger.log(`Starting scheduled ${network} funding data fetch`); + + const { rawData, hlPerpData } = + await this.hyperliquidService.fetchPredictedFundings(); + + await Promise.all([ + this.dataStorage.saveRawData(rawData, network), + this.dataProcessor.processFundingData(hlPerpData, network), + ]); + + this.logger.log( + `Successfully completed ${network} funding data fetch and save`, + ); + } catch (error) { + this.logger.error( + `Failed to fetch and save ${network} funding data`, + error, + ); + } + } +} diff --git a/packages/server/src/domain/tasks/jobs/funding-rates.ts b/packages/server/src/domain/tasks/jobs/funding-rates.ts index 868bece..a3a4c38 100644 --- a/packages/server/src/domain/tasks/jobs/funding-rates.ts +++ b/packages/server/src/domain/tasks/jobs/funding-rates.ts @@ -1,44 +1,28 @@ import { Injectable, Logger, Inject } from '@nestjs/common'; -import { HttpService } from '@nestjs/axios'; import { Cron, CronExpression } from '@nestjs/schedule'; -import { firstValueFrom } from 'rxjs'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import { EnvService } from '@/common/env'; import { DatabaseService } from '@/common/database'; import { S3_CLIENT } from '@/common/object-storage'; - -type PredictedFundingData = [ - string, - [ - string, - { - fundingRate: string; - nextFundingTime: number; - fundingIntervalHours: number; - } | null, - ][], -]; - -interface HlPerpData { - ticker: string; - fundingRate: string; -} +import { + HyperliquidService, + type PredictedFundingData, + type HlPerpData, +} from '@/common/hyperliquid'; @Injectable() export class FundingRatesJob { private readonly logger = new Logger(FundingRatesJob.name); - private readonly apiUrl: string; private readonly bucket: string; constructor( - private readonly httpService: HttpService, + private readonly hyperliquidService: HyperliquidService, private readonly envService: EnvService, private readonly databaseService: DatabaseService, @Inject(S3_CLIENT) private readonly s3Client: S3Client, ) { - const { HYPERLIQUID_API_URL, S3_BUCKET } = this.envService.get(); - this.apiUrl = HYPERLIQUID_API_URL; + const { S3_BUCKET } = this.envService.get(); this.bucket = S3_BUCKET; } @@ -47,7 +31,8 @@ export class FundingRatesJob { try { this.logger.log('Starting scheduled funding data fetch'); - const { rawData, hlPerpData } = await this.fetchPredictedFundings(); + const { rawData, hlPerpData } = + await this.hyperliquidService.fetchPredictedFundings(); await Promise.all([ this.saveRawData(rawData), @@ -60,40 +45,6 @@ export class FundingRatesJob { } } - private async fetchPredictedFundings(): Promise<{ - rawData: PredictedFundingData[]; - hlPerpData: HlPerpData[]; - }> { - try { - const response = await firstValueFrom( - this.httpService.post(`${this.apiUrl}/info`, { - type: 'predictedFundings', - }), - ); - - const rawData: PredictedFundingData[] = response.data; - - const hlPerpData: HlPerpData[] = rawData.reduce( - (acc, [ticker, exchanges]) => { - const hlPerpEntry = exchanges.find( - ([exchangeName]) => exchangeName === 'HlPerp', - ); - if (hlPerpEntry && hlPerpEntry[1]) { - acc.push({ ticker, fundingRate: hlPerpEntry[1].fundingRate }); - } - return acc; - }, - [], - ); - - this.logger.log(`Fetched ${hlPerpData.length} HlPerp funding entries`); - return { rawData, hlPerpData }; - } catch (error) { - this.logger.error('Failed to fetch predicted fundings from API', error); - throw error; - } - } - private async saveRawData(rawData: PredictedFundingData[]): Promise { try { const timestamp = new Date().toISOString(); diff --git a/packages/server/src/domain/tasks/jobs/index.ts b/packages/server/src/domain/tasks/jobs/index.ts index aab87dc..eb11eb2 100644 --- a/packages/server/src/domain/tasks/jobs/index.ts +++ b/packages/server/src/domain/tasks/jobs/index.ts @@ -1 +1,4 @@ export { FundingRatesJob } from './funding-rates'; +export { BaseFundingRatesJob } from './base-funding-rates'; +export { MainnetFundingRatesJob } from './mainnet-funding-rates'; +export { TestnetFundingRatesJob } from './testnet-funding-rates'; diff --git a/packages/server/src/domain/tasks/jobs/mainnet-funding-rates.ts b/packages/server/src/domain/tasks/jobs/mainnet-funding-rates.ts new file mode 100644 index 0000000..5d65060 --- /dev/null +++ b/packages/server/src/domain/tasks/jobs/mainnet-funding-rates.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; + +import { Network } from '@crest/database'; + +import { HyperliquidService } from '@/common/hyperliquid'; + +import { FundingDataProcessorService } from '../services/funding-data-processor'; +import { FundingDataStorageService } from '../services/funding-data-storage'; +import { BaseFundingRatesJob } from './base-funding-rates'; + +@Injectable() +export class MainnetFundingRatesJob extends BaseFundingRatesJob { + constructor( + hyperliquidService: HyperliquidService, + dataProcessor: FundingDataProcessorService, + dataStorage: FundingDataStorageService, + ) { + super(hyperliquidService, dataProcessor, dataStorage); + } + + protected getNetwork(): Network { + return Network.Mainnet; + } + + @Cron(CronExpression.EVERY_HOUR) + async scheduledFetch(): Promise { + await this.fetchAndSaveFundingData(); + } +} diff --git a/packages/server/src/domain/tasks/jobs/testnet-funding-rates.ts b/packages/server/src/domain/tasks/jobs/testnet-funding-rates.ts new file mode 100644 index 0000000..3fad31c --- /dev/null +++ b/packages/server/src/domain/tasks/jobs/testnet-funding-rates.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { HyperliquidService } from '@/common/hyperliquid'; +import { FundingDataProcessorService } from '../services/funding-data-processor'; +import { FundingDataStorageService } from '../services/funding-data-storage'; +import { BaseFundingRatesJob } from './base-funding-rates'; +import { Network } from '@crest/database'; + +@Injectable() +export class TestnetFundingRatesJob extends BaseFundingRatesJob { + constructor( + hyperliquidService: HyperliquidService, + dataProcessor: FundingDataProcessorService, + dataStorage: FundingDataStorageService, + ) { + super(hyperliquidService, dataProcessor, dataStorage); + } + + protected getNetwork(): Network { + return Network.Testnet; + } + + @Cron(CronExpression.EVERY_HOUR) + async scheduledFetch(): Promise { + await this.fetchAndSaveFundingData(); + } +} diff --git a/packages/server/src/domain/tasks/services/funding-data-processor.ts b/packages/server/src/domain/tasks/services/funding-data-processor.ts new file mode 100644 index 0000000..d905b02 --- /dev/null +++ b/packages/server/src/domain/tasks/services/funding-data-processor.ts @@ -0,0 +1,40 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { Network } from '@crest/database'; + +import { DatabaseService } from '@/common/database'; +import type { HlPerpData } from '@/common/hyperliquid'; + +@Injectable() +export class FundingDataProcessorService { + private readonly logger = new Logger(FundingDataProcessorService.name); + + constructor(private readonly databaseService: DatabaseService) {} + + async processFundingData( + fundingData: HlPerpData[], + network: Network, + ): Promise { + try { + const readingTime = new Date(); + const fundingRateEntries = fundingData.map(({ ticker, fundingRate }) => ({ + ticker, + fundingRate, + readingTime, + network, + })); + + await this.databaseService.fundingRates.createMany({ + data: fundingRateEntries, + skipDuplicates: true, + }); + + this.logger.log( + `Successfully saved ${fundingRateEntries.length} ${network} funding rate entries`, + ); + } catch (error) { + this.logger.error(`Failed to process ${network} funding data`, error); + throw error; + } + } +} diff --git a/packages/server/src/domain/tasks/services/funding-data-storage.ts b/packages/server/src/domain/tasks/services/funding-data-storage.ts new file mode 100644 index 0000000..31d8c28 --- /dev/null +++ b/packages/server/src/domain/tasks/services/funding-data-storage.ts @@ -0,0 +1,50 @@ +import { Injectable, Logger, Inject } from '@nestjs/common'; +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; + +import { Network } from '@crest/database'; + +import { EnvService } from '@/common/env'; +import { S3_CLIENT } from '@/common/object-storage'; +import type { PredictedFundingData } from '@/common/hyperliquid'; + +@Injectable() +export class FundingDataStorageService { + private readonly logger = new Logger(FundingDataStorageService.name); + private readonly bucket: string; + + constructor( + private readonly envService: EnvService, + @Inject(S3_CLIENT) private readonly s3Client: S3Client, + ) { + const { S3_BUCKET } = this.envService.get(); + this.bucket = S3_BUCKET; + } + + async saveRawData( + rawData: PredictedFundingData[], + network: Network, + ): Promise { + try { + const timestamp = new Date().toISOString(); + const key = `funding-rates/${network.toLowerCase()}/${timestamp}.json`; + + const jsonString = JSON.stringify(rawData, null, 2); + + await this.s3Client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: jsonString, + ContentType: 'application/json', + }), + ); + + this.logger.log(`Saved raw ${network} funding data to ${key}`); + } catch (error) { + this.logger.error( + `Failed to save raw ${network} data: ${(error as Error).message}`, + ); + throw error; + } + } +} diff --git a/packages/server/src/domain/tasks/services/index.ts b/packages/server/src/domain/tasks/services/index.ts new file mode 100644 index 0000000..902f2ad --- /dev/null +++ b/packages/server/src/domain/tasks/services/index.ts @@ -0,0 +1,2 @@ +export { FundingDataProcessorService } from './funding-data-processor'; +export { FundingDataStorageService } from './funding-data-storage';