Browse Source

Add hyperliquid data source and import

pull/6407/head
Anthony Bautista 2 days ago
parent
commit
aea6fd3e19
  1. 100
      README.md
  2. 25
      apps/api/src/app/import/hyperliquid-import.dto.ts
  3. 191
      apps/api/src/app/import/hyperliquid-import.service.spec.ts
  4. 337
      apps/api/src/app/import/hyperliquid-import.service.ts
  5. 77
      apps/api/src/app/import/import.controller.ts
  6. 3
      apps/api/src/app/import/import.module.ts
  7. 5
      apps/api/src/services/data-provider/data-provider.module.ts
  8. 142
      apps/api/src/services/data-provider/hyperliquid/hyperliquid.service.spec.ts
  9. 430
      apps/api/src/services/data-provider/hyperliquid/hyperliquid.service.ts
  10. 4
      docker/docker-compose.dev.yml
  11. 1
      prisma/migrations/20260227120000_added_hyperliquid_to_data_source/migration.sql
  12. 1
      prisma/schema.prisma

100
README.md

@ -85,26 +85,28 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables ### Supported Environment Variables
| Name | Type | Default Value | Description | | Name | Type | Default Value | Description |
| --------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | --------------------------- | --------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | | `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_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro 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` | | `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 | | `DATA_SOURCE_IMPORT` | `string` (optional) | `YAHOO` | Default data source for imports |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | | `DATA_SOURCES` | `string[]` (optional) | `["COINGECKO","MANUAL","YAHOO"]` | Enabled data sources for market data and import validation |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | | `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | | `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | | `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | | `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | | `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | | `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | | `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running | | `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | | `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running | | `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | | `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `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. | | `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) #### OpenID Connect OIDC (Experimental)
@ -237,18 +239,18 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
} }
``` ```
| Field | Type | Description | | Field | Type | Description |
| ------------ | ------------------- | ------------------------------------------------------------------- | | ------------ | ------------------- | ------------------------------------------------------------------------ |
| `accountId` | `string` (optional) | Id of the account | | `accountId` | `string` (optional) | Id of the account |
| `comment` | `string` (optional) | Comment of the activity | | `comment` | `string` (optional) | Comment of the activity |
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. | | `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
| `dataSource` | `string` | `COINGECKO` \| `GHOSTFOLIO` [^1] \| `MANUAL` \| `YAHOO` | | `dataSource` | `string` | `COINGECKO` \| `GHOSTFOLIO` [^1] \| `HYPERLIQUID` \| `MANUAL` \| `YAHOO` |
| `date` | `string` | Date in the format `ISO-8601` | | `date` | `string` | Date in the format `ISO-8601` |
| `fee` | `number` | Fee of the activity | | `fee` | `number` | Fee of the activity |
| `quantity` | `number` | Quantity of the activity | | `quantity` | `number` | Quantity of the activity |
| `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) | | `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
| `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `LIABILITY` \| `SELL` | | `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `LIABILITY` \| `SELL` |
| `unitPrice` | `number` | Price per unit of the activity | | `unitPrice` | `number` | Price per unit of the activity |
#### Response #### Response
@ -269,6 +271,40 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
} }
``` ```
### Import Hyperliquid Activities
#### Prerequisites
[Bearer Token](#authorization-bearer-token) for authorization
#### Request
`POST http://localhost:3333/api/v1/import/hyperliquid?dryRun=true`
#### Body
```
{
"walletAddress": "0x0000000000000000000000000000000000000001",
"from": "2026-01-01T00:00:00.000Z",
"to": "2026-01-31T23:59:59.999Z",
"includeLedger": true
}
```
| Field | Type | Description |
| --------------- | -------------------- | ---------------------------------------------------------------------------- |
| `walletAddress` | `string` | Wallet address (EVM format) |
| `from` | `string` (optional) | Lower time bound in `ISO-8601` format |
| `to` | `string` (optional) | Upper time bound in `ISO-8601` format |
| `includeLedger` | `boolean` (optional) | Includes selected non-funding ledger items (enabled by default) |
| `dryRun` | `boolean` (query) | `true` validates and previews imported activities without persisting changes |
#### Notes
- This endpoint maps Hyperliquid user fills, user funding, and selected ledger updates.
- Unsupported ledger delta types are skipped.
### Portfolio (experimental) ### Portfolio (experimental)
#### Prerequisites #### Prerequisites

25
apps/api/src/app/import/hyperliquid-import.dto.ts

@ -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;
}

191
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);
}

337
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<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;
}
}

77
apps/api/src/app/import/import.controller.ts

@ -25,6 +25,8 @@ import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; 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 { ImportDataDto } from './import-data.dto';
import { ImportService } from './import.service'; import { ImportService } from './import.service';
@ -32,6 +34,7 @@ import { ImportService } from './import.service';
export class ImportController { export class ImportController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly hyperliquidImportService: HyperliquidImportService,
private readonly importService: ImportService, private readonly importService: ImportService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -56,25 +59,62 @@ export class ImportController {
); );
} }
let maxActivitiesToImport = this.configurationService.get( try {
'MAX_ACTIVITIES_TO_IMPORT' 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<ImportResponse> {
const isDryRun = isDryRunParam === 'true';
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && !hasPermission(this.request.user.permissions, permissions.createAccount)
this.request.user.subscription.type === 'Premium'
) { ) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER; throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
} }
try { try {
const activitiesDto =
await this.hyperliquidImportService.getActivities(data);
const activities = await this.importService.import({ const activities = await this.importService.import({
isDryRun, isDryRun,
maxActivitiesToImport, maxActivitiesToImport: this.getMaxActivitiesToImport(),
accountsWithBalancesDto: importData.accounts ?? [], accountsWithBalancesDto: [],
activitiesDto: importData.activities, activitiesDto,
assetProfilesWithMarketDataDto: importData.assetProfiles ?? [], assetProfilesWithMarketDataDto: [],
tagsDto: importData.tags ?? [], tagsDto: [],
user: this.request.user user: this.request.user
}); });
@ -109,4 +149,19 @@ export class ImportController {
return { activities }; 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;
}
} }

3
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 { Module } from '@nestjs/common';
import { HyperliquidImportService } from './hyperliquid-import.service';
import { ImportController } from './import.controller'; import { ImportController } from './import.controller';
import { ImportService } from './import.service'; import { ImportService } from './import.service';
@ -42,6 +43,6 @@ import { ImportService } from './import.service';
TransformDataSourceInRequestModule, TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule TransformDataSourceInResponseModule
], ],
providers: [ImportService] providers: [HyperliquidImportService, ImportService]
}) })
export class ImportModule {} export class ImportModule {}

5
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 { 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 { GhostfolioService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service';
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.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 { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.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'; import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
@ -40,6 +41,7 @@ import { DataProviderService } from './data-provider.service';
FinancialModelingPrepService, FinancialModelingPrepService,
GhostfolioService, GhostfolioService,
GoogleSheetsService, GoogleSheetsService,
HyperliquidService,
ManualService, ManualService,
RapidApiService, RapidApiService,
YahooFinanceService, YahooFinanceService,
@ -51,6 +53,7 @@ import { DataProviderService } from './data-provider.service';
FinancialModelingPrepService, FinancialModelingPrepService,
GhostfolioService, GhostfolioService,
GoogleSheetsService, GoogleSheetsService,
HyperliquidService,
ManualService, ManualService,
RapidApiService, RapidApiService,
YahooFinanceService YahooFinanceService
@ -63,6 +66,7 @@ import { DataProviderService } from './data-provider.service';
financialModelingPrepService, financialModelingPrepService,
ghostfolioService, ghostfolioService,
googleSheetsService, googleSheetsService,
hyperliquidService,
manualService, manualService,
rapidApiService, rapidApiService,
yahooFinanceService yahooFinanceService
@ -73,6 +77,7 @@ import { DataProviderService } from './data-provider.service';
financialModelingPrepService, financialModelingPrepService,
ghostfolioService, ghostfolioService,
googleSheetsService, googleSheetsService,
hyperliquidService,
manualService, manualService,
rapidApiService, rapidApiService,
yahooFinanceService yahooFinanceService

142
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<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);
}

430
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<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;
}
}

4
docker/docker-compose.dev.yml

@ -4,7 +4,7 @@ services:
extends: extends:
file: docker-compose.yml file: docker-compose.yml
service: postgres service: postgres
container_name: gf-postgres-dev container_name: gf-postgres-dev-hl
ports: ports:
- ${POSTGRES_PORT:-5432}:5432 - ${POSTGRES_PORT:-5432}:5432
@ -12,7 +12,7 @@ services:
extends: extends:
file: docker-compose.yml file: docker-compose.yml
service: redis service: redis
container_name: gf-redis-dev container_name: gf-redis-dev-hl
ports: ports:
- ${REDIS_PORT:-6379}:6379 - ${REDIS_PORT:-6379}:6379

1
prisma/migrations/20260227120000_added_hyperliquid_to_data_source/migration.sql

@ -0,0 +1 @@
ALTER TYPE "DataSource" ADD VALUE 'HYPERLIQUID';

1
prisma/schema.prisma

@ -321,6 +321,7 @@ enum DataSource {
FINANCIAL_MODELING_PREP FINANCIAL_MODELING_PREP
GHOSTFOLIO GHOSTFOLIO
GOOGLE_SHEETS GOOGLE_SHEETS
HYPERLIQUID
MANUAL MANUAL
RAPID_API RAPID_API
YAHOO YAHOO

Loading…
Cancel
Save