From aea6fd3e190a02b31012cf52972b663bd2413256 Mon Sep 17 00:00:00 2001 From: Anthony Bautista Date: Fri, 27 Feb 2026 20:38:28 -0600 Subject: [PATCH 1/2] Add hyperliquid data source and import --- README.md | 100 ++-- .../src/app/import/hyperliquid-import.dto.ts | 25 + .../import/hyperliquid-import.service.spec.ts | 191 ++++++++ .../app/import/hyperliquid-import.service.ts | 337 ++++++++++++++ apps/api/src/app/import/import.controller.ts | 77 +++- apps/api/src/app/import/import.module.ts | 3 +- .../data-provider/data-provider.module.ts | 5 + .../hyperliquid/hyperliquid.service.spec.ts | 142 ++++++ .../hyperliquid/hyperliquid.service.ts | 430 ++++++++++++++++++ docker/docker-compose.dev.yml | 4 +- .../migration.sql | 1 + prisma/schema.prisma | 1 + 12 files changed, 1270 insertions(+), 46 deletions(-) create mode 100644 apps/api/src/app/import/hyperliquid-import.dto.ts create mode 100644 apps/api/src/app/import/hyperliquid-import.service.spec.ts create mode 100644 apps/api/src/app/import/hyperliquid-import.service.ts create mode 100644 apps/api/src/services/data-provider/hyperliquid/hyperliquid.service.spec.ts create mode 100644 apps/api/src/services/data-provider/hyperliquid/hyperliquid.service.ts create mode 100644 prisma/migrations/20260227120000_added_hyperliquid_to_data_source/migration.sql diff --git a/README.md b/README.md index 3be15e49f..e917b8715 100644 --- a/README.md +++ b/README.md @@ -85,26 +85,28 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c ### Supported Environment Variables -| Name | Type | Default Value | Description | -| --------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | -| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | -| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | -| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | -| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | -| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | -| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | -| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | -| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | -| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | -| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | -| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | -| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | -| `REDIS_HOST` | `string` | | The host where _Redis_ is running | -| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | -| `REDIS_PORT` | `number` | | The port where _Redis_ is running | -| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | -| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | +| Name | Type | Default Value | Description | +| --------------------------- | --------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | +| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | +| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | +| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | +| `DATA_SOURCE_IMPORT` | `string` (optional) | `YAHOO` | Default data source for imports | +| `DATA_SOURCES` | `string[]` (optional) | `["COINGECKO","MANUAL","YAHOO"]` | Enabled data sources for market data and import validation | +| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | +| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | +| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | +| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | +| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | +| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | +| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | +| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | +| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | +| `REDIS_HOST` | `string` | | The host where _Redis_ is running | +| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | +| `REDIS_PORT` | `number` | | The port where _Redis_ is running | +| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | +| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | #### OpenID Connect OIDC (Experimental) @@ -237,18 +239,18 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/ Boolean) + includeLedger?: boolean; +} diff --git a/apps/api/src/app/import/hyperliquid-import.service.spec.ts b/apps/api/src/app/import/hyperliquid-import.service.spec.ts new file mode 100644 index 000000000..b108dcf90 --- /dev/null +++ b/apps/api/src/app/import/hyperliquid-import.service.spec.ts @@ -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); +} diff --git a/apps/api/src/app/import/hyperliquid-import.service.ts b/apps/api/src/app/import/hyperliquid-import.service.ts new file mode 100644 index 000000000..3b8657417 --- /dev/null +++ b/apps/api/src/app/import/hyperliquid-import.service.ts @@ -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 { + const [fills, funding, ledgerUpdates, spotSymbolMap] = await Promise.all([ + this.postInfo({ + payload: { + type: 'userFills', + user: walletAddress + } + }), + this.postInfo({ + payload: { + endTime: to ? parseISO(to).getTime() : undefined, + startTime: from ? parseISO(from).getTime() : undefined, + type: 'userFunding', + user: walletAddress + } + }), + includeLedger + ? this.postInfo({ + 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; + }): 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({ + payload: { type: 'spotMeta' } + }); + + const tokenByIndex = new Map( + (spotMeta?.tokens ?? []).map((token) => { + return [token.index, token.name]; + }) + ); + + return (spotMeta?.universe ?? []).reduce>( + (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 + ) { + 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({ payload }: { payload: unknown }): Promise { + 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; + } +} diff --git a/apps/api/src/app/import/import.controller.ts b/apps/api/src/app/import/import.controller.ts index 81481fd65..628755e44 100644 --- a/apps/api/src/app/import/import.controller.ts +++ b/apps/api/src/app/import/import.controller.ts @@ -25,6 +25,8 @@ import { AuthGuard } from '@nestjs/passport'; import { DataSource } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { HyperliquidImportDto } from './hyperliquid-import.dto'; +import { HyperliquidImportService } from './hyperliquid-import.service'; import { ImportDataDto } from './import-data.dto'; import { ImportService } from './import.service'; @@ -32,6 +34,7 @@ import { ImportService } from './import.service'; export class ImportController { public constructor( private readonly configurationService: ConfigurationService, + private readonly hyperliquidImportService: HyperliquidImportService, private readonly importService: ImportService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} @@ -56,25 +59,62 @@ export class ImportController { ); } - let maxActivitiesToImport = this.configurationService.get( - 'MAX_ACTIVITIES_TO_IMPORT' - ); + try { + const activities = await this.importService.import({ + isDryRun, + maxActivitiesToImport: this.getMaxActivitiesToImport(), + accountsWithBalancesDto: importData.accounts ?? [], + activitiesDto: importData.activities, + assetProfilesWithMarketDataDto: importData.assetProfiles ?? [], + tagsDto: importData.tags ?? [], + user: this.request.user + }); + + return { activities }; + } catch (error) { + Logger.error(error, ImportController); + + throw new HttpException( + { + error: getReasonPhrase(StatusCodes.BAD_REQUEST), + message: [error.message] + }, + StatusCodes.BAD_REQUEST + ); + } + } + + @Post('hyperliquid') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @HasPermission(permissions.createOrder) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async importHyperliquid( + @Body() data: HyperliquidImportDto, + @Query('dryRun') isDryRunParam = 'false' + ): Promise { + const isDryRun = isDryRunParam === 'true'; if ( - this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription.type === 'Premium' + !hasPermission(this.request.user.permissions, permissions.createAccount) ) { - maxActivitiesToImport = Number.MAX_SAFE_INTEGER; + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); } try { + const activitiesDto = + await this.hyperliquidImportService.getActivities(data); + const activities = await this.importService.import({ isDryRun, - maxActivitiesToImport, - accountsWithBalancesDto: importData.accounts ?? [], - activitiesDto: importData.activities, - assetProfilesWithMarketDataDto: importData.assetProfiles ?? [], - tagsDto: importData.tags ?? [], + maxActivitiesToImport: this.getMaxActivitiesToImport(), + accountsWithBalancesDto: [], + activitiesDto, + assetProfilesWithMarketDataDto: [], + tagsDto: [], user: this.request.user }); @@ -109,4 +149,19 @@ export class ImportController { return { activities }; } + + private getMaxActivitiesToImport() { + let maxActivitiesToImport = this.configurationService.get( + 'MAX_ACTIVITIES_TO_IMPORT' + ); + + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + this.request.user.subscription.type === 'Premium' + ) { + maxActivitiesToImport = Number.MAX_SAFE_INTEGER; + } + + return maxActivitiesToImport; + } } diff --git a/apps/api/src/app/import/import.module.ts b/apps/api/src/app/import/import.module.ts index a4a13f941..07e17b21e 100644 --- a/apps/api/src/app/import/import.module.ts +++ b/apps/api/src/app/import/import.module.ts @@ -18,6 +18,7 @@ import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; +import { HyperliquidImportService } from './hyperliquid-import.service'; import { ImportController } from './import.controller'; import { ImportService } from './import.service'; @@ -42,6 +43,6 @@ import { ImportService } from './import.service'; TransformDataSourceInRequestModule, TransformDataSourceInResponseModule ], - providers: [ImportService] + providers: [HyperliquidImportService, ImportService] }) export class ImportModule {} diff --git a/apps/api/src/services/data-provider/data-provider.module.ts b/apps/api/src/services/data-provider/data-provider.module.ts index 71b54f01e..b441d21c0 100644 --- a/apps/api/src/services/data-provider/data-provider.module.ts +++ b/apps/api/src/services/data-provider/data-provider.module.ts @@ -7,6 +7,7 @@ import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service'; import { GhostfolioService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service'; import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service'; +import { HyperliquidService } from '@ghostfolio/api/services/data-provider/hyperliquid/hyperliquid.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service'; import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; @@ -40,6 +41,7 @@ import { DataProviderService } from './data-provider.service'; FinancialModelingPrepService, GhostfolioService, GoogleSheetsService, + HyperliquidService, ManualService, RapidApiService, YahooFinanceService, @@ -51,6 +53,7 @@ import { DataProviderService } from './data-provider.service'; FinancialModelingPrepService, GhostfolioService, GoogleSheetsService, + HyperliquidService, ManualService, RapidApiService, YahooFinanceService @@ -63,6 +66,7 @@ import { DataProviderService } from './data-provider.service'; financialModelingPrepService, ghostfolioService, googleSheetsService, + hyperliquidService, manualService, rapidApiService, yahooFinanceService @@ -73,6 +77,7 @@ import { DataProviderService } from './data-provider.service'; financialModelingPrepService, ghostfolioService, googleSheetsService, + hyperliquidService, manualService, rapidApiService, yahooFinanceService diff --git a/apps/api/src/services/data-provider/hyperliquid/hyperliquid.service.spec.ts b/apps/api/src/services/data-provider/hyperliquid/hyperliquid.service.spec.ts new file mode 100644 index 000000000..daa730e71 --- /dev/null +++ b/apps/api/src/services/data-provider/hyperliquid/hyperliquid.service.spec.ts @@ -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; + + 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); +} diff --git a/apps/api/src/services/data-provider/hyperliquid/hyperliquid.service.ts b/apps/api/src/services/data-provider/hyperliquid/hyperliquid.service.ts new file mode 100644 index 000000000..668d7ee32 --- /dev/null +++ b/apps/api/src/services/data-provider/hyperliquid/hyperliquid.service.ts @@ -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; + spotSymbols: Map; +} + +@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; + + public constructor( + private readonly configurationService: ConfigurationService + ) {} + + public canHandle() { + return true; + } + + public async getAssetProfile({ + symbol + }: GetAssetProfileParams): Promise> { + 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({ + 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>({ + 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 { + 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 { + const requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'); + const [meta, spotMeta] = await Promise.all([ + this.postInfo({ + payload: { type: 'meta' }, + requestTimeout + }), + this.postInfo({ + payload: { type: 'spotMeta' }, + requestTimeout + }) + ]); + + const perpSymbols = new Set(); + const spotSymbols = new Map(); + + 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({ + payload, + requestTimeout + }: { + payload: unknown; + requestTimeout: number; + }): Promise { + 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; + } +} diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index ec91025ea..a5606af86 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -4,7 +4,7 @@ services: extends: file: docker-compose.yml service: postgres - container_name: gf-postgres-dev + container_name: gf-postgres-dev-hl ports: - ${POSTGRES_PORT:-5432}:5432 @@ -12,7 +12,7 @@ services: extends: file: docker-compose.yml service: redis - container_name: gf-redis-dev + container_name: gf-redis-dev-hl ports: - ${REDIS_PORT:-6379}:6379 diff --git a/prisma/migrations/20260227120000_added_hyperliquid_to_data_source/migration.sql b/prisma/migrations/20260227120000_added_hyperliquid_to_data_source/migration.sql new file mode 100644 index 000000000..ca679e54e --- /dev/null +++ b/prisma/migrations/20260227120000_added_hyperliquid_to_data_source/migration.sql @@ -0,0 +1 @@ +ALTER TYPE "DataSource" ADD VALUE 'HYPERLIQUID'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 232dde9ca..3a0652ba3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -321,6 +321,7 @@ enum DataSource { FINANCIAL_MODELING_PREP GHOSTFOLIO GOOGLE_SHEETS + HYPERLIQUID MANUAL RAPID_API YAHOO From 6a481d89738f4c18183d50194e10956bd54bb216 Mon Sep 17 00:00:00 2001 From: anthonybautista-gauntlet Date: Sat, 28 Feb 2026 03:07:40 +0000 Subject: [PATCH 2/2] revert change to docker-compose.dev.yml --- docker/docker-compose.dev.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index a5606af86..ec91025ea 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -4,7 +4,7 @@ services: extends: file: docker-compose.yml service: postgres - container_name: gf-postgres-dev-hl + container_name: gf-postgres-dev ports: - ${POSTGRES_PORT:-5432}:5432 @@ -12,7 +12,7 @@ services: extends: file: docker-compose.yml service: redis - container_name: gf-redis-dev-hl + container_name: gf-redis-dev ports: - ${REDIS_PORT:-6379}:6379