mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
11 changed files with 1268 additions and 44 deletions
@ -0,0 +1,25 @@ |
|||
import { Type } from 'class-transformer'; |
|||
import { |
|||
IsBoolean, |
|||
IsEthereumAddress, |
|||
IsISO8601, |
|||
IsOptional |
|||
} from 'class-validator'; |
|||
|
|||
export class HyperliquidImportDto { |
|||
@IsEthereumAddress() |
|||
walletAddress: string; |
|||
|
|||
@IsISO8601() |
|||
@IsOptional() |
|||
from?: string; |
|||
|
|||
@IsISO8601() |
|||
@IsOptional() |
|||
to?: string; |
|||
|
|||
@IsBoolean() |
|||
@IsOptional() |
|||
@Type(() => Boolean) |
|||
includeLedger?: boolean; |
|||
} |
|||
@ -0,0 +1,191 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
|
|||
import { |
|||
afterEach, |
|||
beforeEach, |
|||
describe, |
|||
expect, |
|||
it, |
|||
jest |
|||
} from '@jest/globals'; |
|||
|
|||
import { HyperliquidImportService } from './hyperliquid-import.service'; |
|||
|
|||
describe('HyperliquidImportService', () => { |
|||
let configurationService: ConfigurationService; |
|||
let hyperliquidImportService: HyperliquidImportService; |
|||
|
|||
beforeEach(() => { |
|||
const getMock = jest.fn().mockImplementation((key: string) => { |
|||
if (key === 'REQUEST_TIMEOUT') { |
|||
return 2000; |
|||
} |
|||
|
|||
return undefined; |
|||
}); |
|||
|
|||
configurationService = { |
|||
get: getMock |
|||
} as unknown as ConfigurationService; |
|||
|
|||
hyperliquidImportService = new HyperliquidImportService( |
|||
configurationService |
|||
); |
|||
|
|||
jest.spyOn(global, 'fetch').mockImplementation(async (_url, init) => { |
|||
const payload = JSON.parse(init.body as string); |
|||
|
|||
if (payload.type === 'spotMeta') { |
|||
return createResponse({ |
|||
tokens: [ |
|||
{ fullName: 'Hyperliquid', index: 0, name: 'HYPE' }, |
|||
{ fullName: 'USD Coin', index: 1, name: 'USDC' } |
|||
], |
|||
universe: [{ name: '@2', tokens: [0, 1] }] |
|||
}); |
|||
} |
|||
|
|||
if (payload.type === 'userFills') { |
|||
return createResponse([ |
|||
{ |
|||
builderFee: '0.05', |
|||
coin: '@2', |
|||
fee: '0.1', |
|||
px: '10', |
|||
side: 'B', |
|||
sz: '2', |
|||
time: Date.UTC(2024, 0, 1) |
|||
} |
|||
]); |
|||
} |
|||
|
|||
if (payload.type === 'userFunding') { |
|||
return createResponse([ |
|||
{ |
|||
delta: { |
|||
coin: 'BTC', |
|||
usdc: '-1.5' |
|||
}, |
|||
time: Date.UTC(2024, 0, 2) |
|||
}, |
|||
{ |
|||
delta: { |
|||
coin: 'ETH', |
|||
usdc: '2.5' |
|||
}, |
|||
time: Date.UTC(2024, 0, 3) |
|||
} |
|||
]); |
|||
} |
|||
|
|||
if (payload.type === 'userNonFundingLedgerUpdates') { |
|||
return createResponse([ |
|||
{ |
|||
delta: { |
|||
amount: '3.25', |
|||
token: 'HYPE', |
|||
type: 'rewardsClaim' |
|||
}, |
|||
time: Date.UTC(2024, 0, 4) |
|||
}, |
|||
{ |
|||
delta: { |
|||
fee: '0.2', |
|||
feeToken: 'USDC', |
|||
type: 'send' |
|||
}, |
|||
time: Date.UTC(2024, 0, 5) |
|||
}, |
|||
{ |
|||
delta: { |
|||
type: 'deposit', |
|||
usdc: '100' |
|||
}, |
|||
time: Date.UTC(2024, 0, 6) |
|||
} |
|||
]); |
|||
} |
|||
|
|||
return createResponse([]); |
|||
}); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
jest.restoreAllMocks(); |
|||
}); |
|||
|
|||
it('maps fills, funding and selected ledger items', async () => { |
|||
const activities = await hyperliquidImportService.getActivities({ |
|||
walletAddress: '0x0000000000000000000000000000000000000001' |
|||
}); |
|||
|
|||
expect(activities).toHaveLength(5); |
|||
|
|||
expect(activities[0]).toMatchObject({ |
|||
dataSource: 'HYPERLIQUID', |
|||
quantity: 2, |
|||
symbol: 'HYPE/USDC', |
|||
type: 'BUY', |
|||
unitPrice: 10 |
|||
}); |
|||
expect(activities[0].fee).toBeCloseTo(0.15); |
|||
|
|||
expect( |
|||
activities.some((activity) => { |
|||
return ( |
|||
activity.type === 'FEE' && |
|||
activity.symbol === 'BTC' && |
|||
activity.unitPrice === 1.5 |
|||
); |
|||
}) |
|||
).toBe(true); |
|||
|
|||
expect( |
|||
activities.some((activity) => { |
|||
return ( |
|||
activity.type === 'INTEREST' && |
|||
activity.symbol === 'ETH' && |
|||
activity.unitPrice === 2.5 |
|||
); |
|||
}) |
|||
).toBe(true); |
|||
|
|||
expect( |
|||
activities.some((activity) => { |
|||
return ( |
|||
activity.type === 'INTEREST' && |
|||
activity.symbol === 'HYPE' && |
|||
activity.unitPrice === 3.25 |
|||
); |
|||
}) |
|||
).toBe(true); |
|||
|
|||
expect( |
|||
activities.some((activity) => { |
|||
return ( |
|||
activity.type === 'FEE' && |
|||
activity.symbol === 'USDC' && |
|||
activity.unitPrice === 0.2 |
|||
); |
|||
}) |
|||
).toBe(true); |
|||
}); |
|||
|
|||
it('skips ledger updates when disabled', async () => { |
|||
const activities = await hyperliquidImportService.getActivities({ |
|||
includeLedger: false, |
|||
walletAddress: '0x0000000000000000000000000000000000000001' |
|||
}); |
|||
|
|||
expect(activities).toHaveLength(3); |
|||
}); |
|||
}); |
|||
|
|||
function createResponse(data: unknown) { |
|||
return Promise.resolve({ |
|||
json: async () => data, |
|||
ok: true, |
|||
status: 200, |
|||
statusText: 'OK' |
|||
} as Response); |
|||
} |
|||
@ -0,0 +1,337 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; |
|||
import { CreateOrderDto } from '@ghostfolio/common/dtos'; |
|||
|
|||
import { Injectable, Logger } from '@nestjs/common'; |
|||
import { DataSource, Type } from '@prisma/client'; |
|||
import { parseISO } from 'date-fns'; |
|||
|
|||
import { HyperliquidImportDto } from './hyperliquid-import.dto'; |
|||
|
|||
interface HyperliquidSpotMetaResponse { |
|||
tokens: { |
|||
fullName: string | null; |
|||
index: number; |
|||
name: string; |
|||
}[]; |
|||
universe: { |
|||
name: string; |
|||
tokens: number[]; |
|||
}[]; |
|||
} |
|||
|
|||
interface HyperliquidFill { |
|||
builderFee?: string; |
|||
coin: string; |
|||
fee: string; |
|||
px: string; |
|||
side: 'A' | 'B'; |
|||
time: number; |
|||
sz: string; |
|||
} |
|||
|
|||
interface HyperliquidFunding { |
|||
delta: { |
|||
coin: string; |
|||
usdc: string; |
|||
}; |
|||
time: number; |
|||
} |
|||
|
|||
interface HyperliquidLedgerUpdate { |
|||
delta: { |
|||
type: string; |
|||
[key: string]: unknown; |
|||
}; |
|||
time: number; |
|||
} |
|||
|
|||
@Injectable() |
|||
export class HyperliquidImportService { |
|||
private static readonly API_URL = 'https://api.hyperliquid.xyz/info'; |
|||
|
|||
public constructor( |
|||
private readonly configurationService: ConfigurationService |
|||
) {} |
|||
|
|||
public async getActivities({ |
|||
from, |
|||
includeLedger = true, |
|||
to, |
|||
walletAddress |
|||
}: HyperliquidImportDto): Promise<CreateOrderDto[]> { |
|||
const [fills, funding, ledgerUpdates, spotSymbolMap] = await Promise.all([ |
|||
this.postInfo<HyperliquidFill[]>({ |
|||
payload: { |
|||
type: 'userFills', |
|||
user: walletAddress |
|||
} |
|||
}), |
|||
this.postInfo<HyperliquidFunding[]>({ |
|||
payload: { |
|||
endTime: to ? parseISO(to).getTime() : undefined, |
|||
startTime: from ? parseISO(from).getTime() : undefined, |
|||
type: 'userFunding', |
|||
user: walletAddress |
|||
} |
|||
}), |
|||
includeLedger |
|||
? this.postInfo<HyperliquidLedgerUpdate[]>({ |
|||
payload: { |
|||
endTime: to ? parseISO(to).getTime() : undefined, |
|||
startTime: from ? parseISO(from).getTime() : undefined, |
|||
type: 'userNonFundingLedgerUpdates', |
|||
user: walletAddress |
|||
} |
|||
}) |
|||
: Promise.resolve([]), |
|||
this.getSpotSymbolMap() |
|||
]); |
|||
|
|||
const activities: CreateOrderDto[] = []; |
|||
|
|||
for (const fill of fills ?? []) { |
|||
const price = this.parseNumber(fill.px); |
|||
const quantity = this.parseNumber(fill.sz); |
|||
|
|||
if (price === undefined || quantity === undefined || !fill.side) { |
|||
continue; |
|||
} |
|||
|
|||
const fee = Math.max( |
|||
0, |
|||
this.parseNumber(fill.fee, 0) + this.parseNumber(fill.builderFee, 0) |
|||
); |
|||
|
|||
activities.push({ |
|||
currency: DEFAULT_CURRENCY, |
|||
dataSource: DataSource.HYPERLIQUID, |
|||
date: new Date(fill.time).toISOString(), |
|||
fee, |
|||
quantity: Math.abs(quantity), |
|||
symbol: this.normalizeSymbol(fill.coin, spotSymbolMap), |
|||
type: fill.side === 'B' ? Type.BUY : Type.SELL, |
|||
unitPrice: price |
|||
}); |
|||
} |
|||
|
|||
for (const fundingItem of funding ?? []) { |
|||
const amount = this.parseNumber(fundingItem?.delta?.usdc); |
|||
const symbol = this.normalizeSymbol( |
|||
fundingItem?.delta?.coin, |
|||
spotSymbolMap |
|||
); |
|||
|
|||
if (amount === undefined || amount === 0 || !symbol) { |
|||
continue; |
|||
} |
|||
|
|||
activities.push({ |
|||
currency: DEFAULT_CURRENCY, |
|||
dataSource: DataSource.HYPERLIQUID, |
|||
date: new Date(fundingItem.time).toISOString(), |
|||
fee: 0, |
|||
quantity: 1, |
|||
symbol, |
|||
type: amount > 0 ? Type.INTEREST : Type.FEE, |
|||
unitPrice: Math.abs(amount) |
|||
}); |
|||
} |
|||
|
|||
for (const ledgerItem of ledgerUpdates ?? []) { |
|||
const mappedActivity = this.mapLedgerUpdate({ |
|||
ledgerItem, |
|||
spotSymbolMap |
|||
}); |
|||
|
|||
if (mappedActivity) { |
|||
activities.push(mappedActivity); |
|||
} |
|||
} |
|||
|
|||
return activities.sort((activity1, activity2) => { |
|||
return ( |
|||
new Date(activity1.date).getTime() - new Date(activity2.date).getTime() |
|||
); |
|||
}); |
|||
} |
|||
|
|||
private mapLedgerUpdate({ |
|||
ledgerItem, |
|||
spotSymbolMap |
|||
}: { |
|||
ledgerItem: HyperliquidLedgerUpdate; |
|||
spotSymbolMap: Record<string, string>; |
|||
}): CreateOrderDto | undefined { |
|||
const { delta } = ledgerItem; |
|||
|
|||
if (delta.type === 'rewardsClaim') { |
|||
const amount = this.parseNumber(this.getString(delta.amount)); |
|||
const token = this.getString(delta.token); |
|||
|
|||
if (amount === undefined || amount <= 0 || !token) { |
|||
return undefined; |
|||
} |
|||
|
|||
return { |
|||
currency: DEFAULT_CURRENCY, |
|||
dataSource: DataSource.HYPERLIQUID, |
|||
date: new Date(ledgerItem.time).toISOString(), |
|||
fee: 0, |
|||
quantity: 1, |
|||
symbol: this.normalizeSymbol(token, spotSymbolMap), |
|||
type: Type.INTEREST, |
|||
unitPrice: amount |
|||
}; |
|||
} |
|||
|
|||
if ( |
|||
['internalTransfer', 'send', 'spotTransfer', 'withdraw'].includes( |
|||
delta.type |
|||
) |
|||
) { |
|||
const amount = this.parseNumber(this.getString(delta.fee)); |
|||
const feeToken = this.getString(delta.feeToken); |
|||
const token = this.getString(delta.token); |
|||
|
|||
if (amount === undefined || amount <= 0) { |
|||
return undefined; |
|||
} |
|||
|
|||
return { |
|||
currency: DEFAULT_CURRENCY, |
|||
dataSource: DataSource.HYPERLIQUID, |
|||
date: new Date(ledgerItem.time).toISOString(), |
|||
fee: 0, |
|||
quantity: 1, |
|||
symbol: this.normalizeSymbol( |
|||
feeToken ?? token ?? DEFAULT_CURRENCY, |
|||
spotSymbolMap |
|||
), |
|||
type: Type.FEE, |
|||
unitPrice: amount |
|||
}; |
|||
} |
|||
|
|||
if (delta.type === 'vaultWithdraw') { |
|||
const amount = |
|||
this.parseNumber(this.getString(delta.commission), 0) + |
|||
this.parseNumber(this.getString(delta.closingCost), 0); |
|||
|
|||
if (amount <= 0) { |
|||
return undefined; |
|||
} |
|||
|
|||
return { |
|||
currency: DEFAULT_CURRENCY, |
|||
dataSource: DataSource.HYPERLIQUID, |
|||
date: new Date(ledgerItem.time).toISOString(), |
|||
fee: 0, |
|||
quantity: 1, |
|||
symbol: DEFAULT_CURRENCY, |
|||
type: Type.FEE, |
|||
unitPrice: amount |
|||
}; |
|||
} |
|||
|
|||
// Unsupported ledger delta types intentionally skipped in phase-2 v1.
|
|||
return undefined; |
|||
} |
|||
|
|||
private async getSpotSymbolMap() { |
|||
try { |
|||
const spotMeta = await this.postInfo<HyperliquidSpotMetaResponse>({ |
|||
payload: { type: 'spotMeta' } |
|||
}); |
|||
|
|||
const tokenByIndex = new Map( |
|||
(spotMeta?.tokens ?? []).map((token) => { |
|||
return [token.index, token.name]; |
|||
}) |
|||
); |
|||
|
|||
return (spotMeta?.universe ?? []).reduce<Record<string, string>>( |
|||
(result, universeItem) => { |
|||
if (!universeItem?.name || universeItem.tokens.length < 2) { |
|||
return result; |
|||
} |
|||
|
|||
const baseToken = tokenByIndex.get(universeItem.tokens[0]); |
|||
const quoteToken = tokenByIndex.get(universeItem.tokens[1]); |
|||
|
|||
if (!baseToken || !quoteToken) { |
|||
return result; |
|||
} |
|||
|
|||
result[universeItem.name] = |
|||
`${baseToken}/${quoteToken}`.toUpperCase(); |
|||
|
|||
return result; |
|||
}, |
|||
{} |
|||
); |
|||
} catch (error) { |
|||
Logger.error(error, 'HyperliquidImportService'); |
|||
return {}; |
|||
} |
|||
} |
|||
|
|||
private normalizeSymbol( |
|||
symbol: string, |
|||
spotSymbolMap: Record<string, string> |
|||
) { |
|||
if (!symbol) { |
|||
return DEFAULT_CURRENCY; |
|||
} |
|||
|
|||
if (spotSymbolMap[symbol]) { |
|||
return spotSymbolMap[symbol]; |
|||
} |
|||
|
|||
return symbol.toUpperCase(); |
|||
} |
|||
|
|||
private parseNumber(value?: string, fallback?: number) { |
|||
if (value === undefined) { |
|||
return fallback; |
|||
} |
|||
|
|||
const parsedValue = Number.parseFloat(value); |
|||
|
|||
if (Number.isFinite(parsedValue)) { |
|||
return parsedValue; |
|||
} |
|||
|
|||
return fallback; |
|||
} |
|||
|
|||
private getString(value: unknown) { |
|||
return typeof value === 'string' ? value : undefined; |
|||
} |
|||
|
|||
private async postInfo<T>({ payload }: { payload: unknown }): Promise<T> { |
|||
const response = await fetch(HyperliquidImportService.API_URL, { |
|||
body: JSON.stringify(payload), |
|||
headers: { |
|||
Accept: 'application/json', |
|||
'Content-Type': 'application/json' |
|||
}, |
|||
method: 'POST', |
|||
signal: AbortSignal.timeout( |
|||
this.configurationService.get('REQUEST_TIMEOUT') |
|||
) |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
throw new Error(`${response.status} ${response.statusText}`); |
|||
} |
|||
|
|||
const data = await response.json(); |
|||
|
|||
if (data?.type === 'error') { |
|||
throw new Error(data?.message ?? 'Hyperliquid API error'); |
|||
} |
|||
|
|||
return data as T; |
|||
} |
|||
} |
|||
@ -0,0 +1,142 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
|
|||
import { |
|||
afterEach, |
|||
beforeEach, |
|||
describe, |
|||
expect, |
|||
it, |
|||
jest |
|||
} from '@jest/globals'; |
|||
|
|||
import { HyperliquidService } from './hyperliquid.service'; |
|||
|
|||
describe('HyperliquidService', () => { |
|||
let configurationService: ConfigurationService; |
|||
let hyperliquidService: HyperliquidService; |
|||
let requestCounter: Record<string, number>; |
|||
|
|||
beforeEach(() => { |
|||
requestCounter = {}; |
|||
|
|||
configurationService = { |
|||
get: (key) => { |
|||
if (key === 'REQUEST_TIMEOUT') { |
|||
return 2000; |
|||
} |
|||
|
|||
return undefined; |
|||
} |
|||
} as any; |
|||
|
|||
hyperliquidService = new HyperliquidService(configurationService); |
|||
|
|||
jest.spyOn(global, 'fetch').mockImplementation(async (_url, init) => { |
|||
const payload = JSON.parse(init.body as string); |
|||
requestCounter[payload.type] = (requestCounter[payload.type] ?? 0) + 1; |
|||
|
|||
if (payload.type === 'meta') { |
|||
return createResponse({ |
|||
universe: [{ name: 'BTC' }, { isDelisted: true, name: 'DELISTED' }] |
|||
}); |
|||
} |
|||
|
|||
if (payload.type === 'spotMeta') { |
|||
return createResponse({ |
|||
tokens: [ |
|||
{ fullName: 'Hyperliquid', index: 0, name: 'HYPE' }, |
|||
{ fullName: 'USD Coin', index: 1, name: 'USDC' } |
|||
], |
|||
universe: [{ name: '@2', tokens: [0, 1] }] |
|||
}); |
|||
} |
|||
|
|||
if (payload.type === 'allMids') { |
|||
return createResponse({ |
|||
'@2': '12.34', |
|||
BTC: '100000' |
|||
}); |
|||
} |
|||
|
|||
if (payload.type === 'candleSnapshot') { |
|||
return createResponse([ |
|||
{ |
|||
c: '10.5', |
|||
t: Date.UTC(2024, 0, 1) |
|||
}, |
|||
{ |
|||
c: '11.25', |
|||
t: Date.UTC(2024, 0, 2) |
|||
} |
|||
]); |
|||
} |
|||
|
|||
return createResponse({}); |
|||
}); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
jest.restoreAllMocks(); |
|||
}); |
|||
|
|||
it('maps quotes for perp and spot symbols', async () => { |
|||
const result = await hyperliquidService.getQuotes({ |
|||
requestTimeout: 1000, |
|||
symbols: ['BTC', 'HYPE/USDC'] |
|||
}); |
|||
|
|||
expect(result.BTC.marketPrice).toBe(100000); |
|||
expect(result.BTC.currency).toBe('USD'); |
|||
expect(result.BTC.dataSource).toBe('HYPERLIQUID'); |
|||
|
|||
expect(result['HYPE/USDC'].marketPrice).toBe(12.34); |
|||
expect(result['HYPE/USDC'].currency).toBe('USD'); |
|||
expect(result['HYPE/USDC'].dataSource).toBe('HYPERLIQUID'); |
|||
}); |
|||
|
|||
it('returns search results with canonical symbols', async () => { |
|||
const result = await hyperliquidService.search({ |
|||
query: 'hyp' |
|||
}); |
|||
|
|||
expect(result.items.some(({ symbol }) => symbol === 'HYPE/USDC')).toBe( |
|||
true |
|||
); |
|||
expect(result.items.some(({ symbol }) => symbol === 'BTC')).toBe(false); |
|||
}); |
|||
|
|||
it('maps historical candles for spot canonical symbol', async () => { |
|||
const result = await hyperliquidService.getHistorical({ |
|||
from: new Date(Date.UTC(2024, 0, 1)), |
|||
requestTimeout: 1000, |
|||
symbol: 'HYPE/USDC', |
|||
to: new Date(Date.UTC(2024, 0, 3)) |
|||
}); |
|||
|
|||
expect(result['HYPE/USDC']['2024-01-01'].marketPrice).toBe(10.5); |
|||
expect(result['HYPE/USDC']['2024-01-02'].marketPrice).toBe(11.25); |
|||
}); |
|||
|
|||
it('reuses cached catalog between calls', async () => { |
|||
await hyperliquidService.search({ |
|||
query: 'btc' |
|||
}); |
|||
|
|||
await hyperliquidService.getQuotes({ |
|||
symbols: ['BTC'] |
|||
}); |
|||
|
|||
expect(requestCounter.meta).toBe(1); |
|||
expect(requestCounter.spotMeta).toBe(1); |
|||
expect(requestCounter.allMids).toBe(1); |
|||
}); |
|||
}); |
|||
|
|||
function createResponse(data: unknown, ok = true) { |
|||
return Promise.resolve({ |
|||
json: async () => data, |
|||
ok, |
|||
status: ok ? 200 : 500, |
|||
statusText: ok ? 'OK' : 'ERROR' |
|||
} as Response); |
|||
} |
|||
@ -0,0 +1,430 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { |
|||
DataProviderInterface, |
|||
GetAssetProfileParams, |
|||
GetDividendsParams, |
|||
GetHistoricalParams, |
|||
GetQuotesParams, |
|||
GetSearchParams |
|||
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; |
|||
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; |
|||
import { DATE_FORMAT } from '@ghostfolio/common/helper'; |
|||
import { |
|||
DataProviderHistoricalResponse, |
|||
DataProviderInfo, |
|||
DataProviderResponse, |
|||
LookupItem, |
|||
LookupResponse |
|||
} from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { Injectable, Logger } from '@nestjs/common'; |
|||
import { |
|||
AssetClass, |
|||
AssetSubClass, |
|||
DataSource, |
|||
SymbolProfile |
|||
} from '@prisma/client'; |
|||
import { addDays, format, isSameDay } from 'date-fns'; |
|||
|
|||
interface HyperliquidMetaResponse { |
|||
universe: { |
|||
isDelisted?: boolean; |
|||
name: string; |
|||
}[]; |
|||
} |
|||
|
|||
interface HyperliquidSpotMetaResponse { |
|||
tokens: { |
|||
fullName: string | null; |
|||
index: number; |
|||
name: string; |
|||
}[]; |
|||
universe: { |
|||
name: string; |
|||
tokens: number[]; |
|||
}[]; |
|||
} |
|||
|
|||
interface HyperliquidCandleItem { |
|||
c: string; |
|||
t: number; |
|||
} |
|||
|
|||
interface SpotSymbolMapItem { |
|||
name: string; |
|||
pairId: string; |
|||
symbol: string; |
|||
} |
|||
|
|||
interface HyperliquidCatalog { |
|||
perpSymbols: Set<string>; |
|||
spotSymbols: Map<string, SpotSymbolMapItem>; |
|||
} |
|||
|
|||
@Injectable() |
|||
export class HyperliquidService implements DataProviderInterface { |
|||
private static readonly API_URL = 'https://api.hyperliquid.xyz/info'; |
|||
private static readonly CATALOG_TTL_MS = 5 * 60 * 1000; |
|||
private catalogCache?: { expiresAt: number; value: HyperliquidCatalog }; |
|||
private catalogPromise?: Promise<HyperliquidCatalog>; |
|||
|
|||
public constructor( |
|||
private readonly configurationService: ConfigurationService |
|||
) {} |
|||
|
|||
public canHandle() { |
|||
return true; |
|||
} |
|||
|
|||
public async getAssetProfile({ |
|||
symbol |
|||
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> { |
|||
const { perpSymbols, spotSymbols } = await this.getCatalog(); |
|||
const upperCaseSymbol = symbol.toUpperCase(); |
|||
|
|||
if (perpSymbols.has(upperCaseSymbol)) { |
|||
return { |
|||
assetClass: AssetClass.LIQUIDITY, |
|||
assetSubClass: AssetSubClass.CRYPTOCURRENCY, |
|||
currency: DEFAULT_CURRENCY, |
|||
dataSource: this.getName(), |
|||
name: `${upperCaseSymbol} Perpetual`, |
|||
symbol: upperCaseSymbol |
|||
}; |
|||
} |
|||
|
|||
const spotSymbol = spotSymbols.get(upperCaseSymbol); |
|||
if (spotSymbol) { |
|||
return { |
|||
assetClass: AssetClass.LIQUIDITY, |
|||
assetSubClass: AssetSubClass.CRYPTOCURRENCY, |
|||
currency: DEFAULT_CURRENCY, |
|||
dataSource: this.getName(), |
|||
name: spotSymbol.name, |
|||
symbol: spotSymbol.symbol |
|||
}; |
|||
} |
|||
|
|||
return undefined; |
|||
} |
|||
|
|||
public getDataProviderInfo(): DataProviderInfo { |
|||
return { |
|||
dataSource: DataSource.HYPERLIQUID, |
|||
isPremium: false, |
|||
name: 'Hyperliquid', |
|||
url: 'https://hyperliquid.xyz' |
|||
}; |
|||
} |
|||
|
|||
public async getDividends({}: GetDividendsParams) { |
|||
return {}; |
|||
} |
|||
|
|||
public async getHistorical({ |
|||
from, |
|||
granularity = 'day', |
|||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), |
|||
symbol, |
|||
to |
|||
}: GetHistoricalParams): Promise<{ |
|||
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; |
|||
}> { |
|||
const result: { |
|||
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; |
|||
} = { |
|||
[symbol]: {} |
|||
}; |
|||
|
|||
try { |
|||
const normalizedSymbol = symbol.toUpperCase(); |
|||
const { perpSymbols, spotSymbols } = await this.getCatalog(); |
|||
const spot = spotSymbols.get(normalizedSymbol); |
|||
const coin = perpSymbols.has(normalizedSymbol) |
|||
? normalizedSymbol |
|||
: spot?.pairId; |
|||
|
|||
if (!coin) { |
|||
return {}; |
|||
} |
|||
|
|||
if (isSameDay(from, to)) { |
|||
to = addDays(to, 1); |
|||
} |
|||
|
|||
const interval = granularity === 'month' ? '1M' : '1d'; |
|||
const candles = await this.postInfo<HyperliquidCandleItem[]>({ |
|||
payload: { |
|||
req: { |
|||
coin, |
|||
endTime: to.getTime(), |
|||
interval, |
|||
startTime: from.getTime() |
|||
}, |
|||
type: 'candleSnapshot' |
|||
}, |
|||
requestTimeout |
|||
}); |
|||
|
|||
for (const candle of candles ?? []) { |
|||
const marketPrice = Number.parseFloat(candle.c); |
|||
|
|||
if (Number.isFinite(marketPrice)) { |
|||
result[symbol][format(new Date(candle.t), DATE_FORMAT)] = { |
|||
marketPrice |
|||
}; |
|||
} |
|||
} |
|||
} catch (error) { |
|||
throw new Error( |
|||
`Could not get historical market data for ${symbol} (${this.getName()}) from ${format( |
|||
from, |
|||
DATE_FORMAT |
|||
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` |
|||
); |
|||
} |
|||
|
|||
return result; |
|||
} |
|||
|
|||
public getName(): DataSource { |
|||
return DataSource.HYPERLIQUID; |
|||
} |
|||
|
|||
public getMaxNumberOfSymbolsPerRequest() { |
|||
return 200; |
|||
} |
|||
|
|||
public async getQuotes({ |
|||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), |
|||
symbols |
|||
}: GetQuotesParams): Promise<{ [symbol: string]: DataProviderResponse }> { |
|||
const response: { [symbol: string]: DataProviderResponse } = {}; |
|||
|
|||
if (symbols.length <= 0) { |
|||
return response; |
|||
} |
|||
|
|||
try { |
|||
const { perpSymbols, spotSymbols } = await this.getCatalog(); |
|||
const mids = await this.postInfo<Record<string, string>>({ |
|||
payload: { type: 'allMids' }, |
|||
requestTimeout |
|||
}); |
|||
|
|||
for (const symbol of symbols) { |
|||
const normalizedSymbol = symbol.toUpperCase(); |
|||
const spot = spotSymbols.get(normalizedSymbol); |
|||
const marketSymbol = perpSymbols.has(normalizedSymbol) |
|||
? normalizedSymbol |
|||
: spot?.pairId; |
|||
const marketPrice = this.parseNumericValue(mids?.[marketSymbol]); |
|||
|
|||
if (!marketSymbol || marketPrice === undefined) { |
|||
continue; |
|||
} |
|||
|
|||
response[symbol] = { |
|||
currency: DEFAULT_CURRENCY, |
|||
dataProviderInfo: this.getDataProviderInfo(), |
|||
dataSource: this.getName(), |
|||
marketPrice, |
|||
marketState: 'open' |
|||
}; |
|||
} |
|||
} catch (error) { |
|||
Logger.error(error, 'HyperliquidService'); |
|||
} |
|||
|
|||
return response; |
|||
} |
|||
|
|||
public getTestSymbol() { |
|||
return 'BTC'; |
|||
} |
|||
|
|||
public async search({ query }: GetSearchParams): Promise<LookupResponse> { |
|||
const normalizedQuery = query?.trim()?.toUpperCase() ?? ''; |
|||
const items: LookupItem[] = []; |
|||
|
|||
if (!normalizedQuery) { |
|||
return { items }; |
|||
} |
|||
|
|||
try { |
|||
const { perpSymbols, spotSymbols } = await this.getCatalog(); |
|||
|
|||
for (const perpSymbol of perpSymbols) { |
|||
const name = `${perpSymbol} Perpetual`; |
|||
|
|||
if ( |
|||
!perpSymbol.includes(normalizedQuery) && |
|||
!name.toUpperCase().includes(normalizedQuery) |
|||
) { |
|||
continue; |
|||
} |
|||
|
|||
items.push({ |
|||
assetClass: AssetClass.LIQUIDITY, |
|||
assetSubClass: AssetSubClass.CRYPTOCURRENCY, |
|||
currency: DEFAULT_CURRENCY, |
|||
dataProviderInfo: this.getDataProviderInfo(), |
|||
dataSource: this.getName(), |
|||
name, |
|||
symbol: perpSymbol |
|||
}); |
|||
} |
|||
|
|||
for (const spotSymbol of spotSymbols.values()) { |
|||
if ( |
|||
!spotSymbol.symbol.includes(normalizedQuery) && |
|||
!spotSymbol.name.toUpperCase().includes(normalizedQuery) |
|||
) { |
|||
continue; |
|||
} |
|||
|
|||
items.push({ |
|||
assetClass: AssetClass.LIQUIDITY, |
|||
assetSubClass: AssetSubClass.CRYPTOCURRENCY, |
|||
currency: DEFAULT_CURRENCY, |
|||
dataProviderInfo: this.getDataProviderInfo(), |
|||
dataSource: this.getName(), |
|||
name: spotSymbol.name, |
|||
symbol: spotSymbol.symbol |
|||
}); |
|||
} |
|||
|
|||
items.sort(({ name: name1 }, { name: name2 }) => { |
|||
return name1.toLowerCase().localeCompare(name2.toLowerCase()); |
|||
}); |
|||
} catch (error) { |
|||
Logger.error(error, 'HyperliquidService'); |
|||
} |
|||
|
|||
return { items }; |
|||
} |
|||
|
|||
private async getCatalog() { |
|||
const now = Date.now(); |
|||
|
|||
if (this.catalogCache && this.catalogCache.expiresAt > now) { |
|||
return this.catalogCache.value; |
|||
} |
|||
|
|||
if (this.catalogPromise) { |
|||
return this.catalogPromise; |
|||
} |
|||
|
|||
this.catalogPromise = this.loadCatalog(); |
|||
|
|||
try { |
|||
const catalog = await this.catalogPromise; |
|||
this.catalogCache = { |
|||
expiresAt: now + HyperliquidService.CATALOG_TTL_MS, |
|||
value: catalog |
|||
}; |
|||
|
|||
return catalog; |
|||
} finally { |
|||
this.catalogPromise = undefined; |
|||
} |
|||
} |
|||
|
|||
private async loadCatalog(): Promise<HyperliquidCatalog> { |
|||
const requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'); |
|||
const [meta, spotMeta] = await Promise.all([ |
|||
this.postInfo<HyperliquidMetaResponse>({ |
|||
payload: { type: 'meta' }, |
|||
requestTimeout |
|||
}), |
|||
this.postInfo<HyperliquidSpotMetaResponse>({ |
|||
payload: { type: 'spotMeta' }, |
|||
requestTimeout |
|||
}) |
|||
]); |
|||
|
|||
const perpSymbols = new Set<string>(); |
|||
const spotSymbols = new Map<string, SpotSymbolMapItem>(); |
|||
|
|||
for (const universeItem of meta?.universe ?? []) { |
|||
if (!universeItem?.name || universeItem.isDelisted) { |
|||
continue; |
|||
} |
|||
|
|||
perpSymbols.add(universeItem.name.toUpperCase()); |
|||
} |
|||
|
|||
const tokenByIndex = new Map( |
|||
spotMeta?.tokens?.map((token) => { |
|||
return [token.index, token]; |
|||
}) |
|||
); |
|||
|
|||
for (const universeItem of spotMeta?.universe ?? []) { |
|||
if (!universeItem?.name || universeItem.tokens.length < 2) { |
|||
continue; |
|||
} |
|||
|
|||
const baseToken = tokenByIndex.get(universeItem.tokens[0]); |
|||
const quoteToken = tokenByIndex.get(universeItem.tokens[1]); |
|||
|
|||
if (!baseToken?.name || !quoteToken?.name) { |
|||
continue; |
|||
} |
|||
|
|||
const canonicalSymbol = |
|||
`${baseToken.name}/${quoteToken.name}`.toUpperCase(); |
|||
const name = `${baseToken.fullName ?? baseToken.name} / ${ |
|||
quoteToken.fullName ?? quoteToken.name |
|||
}`;
|
|||
|
|||
spotSymbols.set(canonicalSymbol, { |
|||
name, |
|||
pairId: universeItem.name, |
|||
symbol: canonicalSymbol |
|||
}); |
|||
} |
|||
|
|||
return { perpSymbols, spotSymbols }; |
|||
} |
|||
|
|||
private parseNumericValue(value?: string) { |
|||
const numericValue = Number.parseFloat(value); |
|||
|
|||
if (Number.isFinite(numericValue)) { |
|||
return numericValue; |
|||
} |
|||
|
|||
return undefined; |
|||
} |
|||
|
|||
private async postInfo<T>({ |
|||
payload, |
|||
requestTimeout |
|||
}: { |
|||
payload: unknown; |
|||
requestTimeout: number; |
|||
}): Promise<T> { |
|||
const response = await fetch(HyperliquidService.API_URL, { |
|||
body: JSON.stringify(payload), |
|||
headers: { |
|||
Accept: 'application/json', |
|||
'Content-Type': 'application/json' |
|||
}, |
|||
method: 'POST', |
|||
signal: AbortSignal.timeout(requestTimeout) |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
throw new Error(`${response.status} ${response.statusText}`); |
|||
} |
|||
|
|||
const data = await response.json(); |
|||
|
|||
if (data?.type === 'error') { |
|||
throw new Error(data?.message ?? 'Hyperliquid API error'); |
|||
} |
|||
|
|||
return data as T; |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
ALTER TYPE "DataSource" ADD VALUE 'HYPERLIQUID'; |
|||
Loading…
Reference in new issue