mirror of https://github.com/ghostfolio/ghostfolio
12 changed files with 1270 additions and 46 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