Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/server/src/common/hyperliquid/index.ts
Original file line number Diff line number Diff line change
@@ -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';
111 changes: 111 additions & 0 deletions packages/server/src/common/hyperliquid/service.ts
Original file line number Diff line number Diff line change
@@ -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<T extends object>(request: { type: string }): Promise<T> {
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<SpotMetaResponse>({
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<MetaResponse>({
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<PredictedFundingData[]>({
type: 'predictedFundings',
});

const hlPerpData: HlPerpData[] = rawData.reduce<HlPerpData[]>(
(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;
}
}
}
33 changes: 33 additions & 0 deletions packages/server/src/common/hyperliquid/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
28 changes: 22 additions & 6 deletions packages/server/src/domain/tasks/controller.ts
Original file line number Diff line number Diff line change
@@ -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`,
};
}
}
24 changes: 20 additions & 4 deletions packages/server/src/domain/tasks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
46 changes: 46 additions & 0 deletions packages/server/src/domain/tasks/jobs/base-funding-rates.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
);
}
}
}
67 changes: 9 additions & 58 deletions packages/server/src/domain/tasks/jobs/funding-rates.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Expand All @@ -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),
Expand All @@ -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<HlPerpData[]>(
(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<void> {
try {
const timestamp = new Date().toISOString();
Expand Down
3 changes: 3 additions & 0 deletions packages/server/src/domain/tasks/jobs/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading