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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 11 additions & 46 deletions cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,7 @@ const SERVICES = {
hyperliquid: {
path: './services/hyperliquid/index.ts',
description:
'Fetch and store Hyperliquid spot pair name lookups from the Info API spotMeta endpoint',
},
'hyperliquid-outcomes': {
path: './services/hyperliquid-outcomes/index.ts',
description:
'Fetch HIP-4 outcome + question metadata (outcomeMeta + per-id settledOutcome recovery) into state_outcome_meta and state_question_meta',
'Fetch Hyperliquid spot pair names + HIP-4 outcome / question metadata in one cycle (spotMeta + outcomeMeta + per-id settledOutcome recovery) into state_spot_pair_names, state_outcome_meta, and state_question_meta',
},
'kalshi-live': {
path: './services/kalshi/live.ts',
Expand Down Expand Up @@ -102,14 +97,12 @@ const SETUP_ACTIONS = {
'Deploy polymarket tables (polymarket_markets, polymarket_assets)',
},
hyperliquid: {
files: ['./sql.schemas/schema.hyperliquid.sql'],
files: [
'./sql.schemas/schema.hyperliquid.sql',
'./sql.schemas/schema.hyperliquid_outcomes.sql',
],
description:
'Deploy hyperliquid spot pair name lookup table (state_spot_pair_names)',
},
'hyperliquid-outcomes': {
files: ['./sql.schemas/schema.hyperliquid_outcomes.sql'],
description:
'Deploy hyperliquid HIP-4 outcome metadata tables (state_outcome_meta, state_question_meta)',
'Deploy hyperliquid scraper-managed tables (state_spot_pair_names + HIP-4 state_outcome_meta + state_question_meta)',
},
kalshi: {
files: ['./sql.schemas/schema.kalshi.sql'],
Expand Down Expand Up @@ -426,7 +419,6 @@ Services:
metadata-balances ${SERVICES['metadata-balances'].description}
polymarket ${SERVICES['polymarket'].description}
hyperliquid ${SERVICES['hyperliquid'].description}
hyperliquid-outcomes ${SERVICES['hyperliquid-outcomes'].description}
kalshi-live ${SERVICES['kalshi-live'].description}
kalshi-backfill ${SERVICES['kalshi-backfill'].description}
metadata-solana-rpc ${SERVICES['metadata-solana-rpc'].description}
Expand All @@ -439,7 +431,6 @@ Examples:
$ npm run cli run metadata-balances
$ npm run cli run polymarket
$ npm run cli run hyperliquid
$ npm run cli run hyperliquid-outcomes
$ npm run cli run metadata-solana-rpc
$ npm run cli run metadata-solana-extras-rpc
$ npm run cli run metadata-solana-clickhouse
Expand Down Expand Up @@ -661,54 +652,28 @@ const setupHyperliquid = setupCommand
.addHelpText(
'after',
`
This command deploys the Hyperliquid spot pair name lookup table.
It only needs to be run once per database to initialize the table.
This command deploys the Hyperliquid scraper-managed tables. It only
needs to be run once per database to initialize the tables.

Tables created:
- state_spot_pair_names: Resolves \`@N\` and canonical pair coin values to BASE/QUOTE strings
- state_outcome_meta: HIP-4 per-outcome metadata (name, description, side specs, settlement)
- state_question_meta: HIP-4 multi-outcome question groupings (namedOutcomes, fallbackOutcome)

Example:
$ npm run cli setup hyperliquid
$ npm run cli setup hyperliquid --cluster my_cluster
`,
)
.action(async (options: any) => {
log.info('Setting up hyperliquid spot pair name lookup table');
log.info('Setting up hyperliquid scraper-managed tables');
const files = SETUP_ACTIONS.hyperliquid.files.map((f) =>
resolve(__dirname, f),
);
await handleSetupCommand(files, options);
});
addClickhouseOptions(setupHyperliquid);

// ---- setup hyperliquid-outcomes ----
const setupHyperliquidOutcomes = setupCommand
.command('hyperliquid-outcomes')
.description(SETUP_ACTIONS['hyperliquid-outcomes'].description)
.addHelpText(
'after',
`
This command deploys the Hyperliquid HIP-4 outcome metadata tables.
It only needs to be run once per database to initialize the tables.

Tables created:
- state_outcome_meta: Per-outcome metadata (name, description, side specs, settlement)
- state_question_meta: Multi-outcome question groupings (namedOutcomes, fallbackOutcome)

Example:
$ npm run cli setup hyperliquid-outcomes
$ npm run cli setup hyperliquid-outcomes --cluster my_cluster
`,
)
.action(async (options: any) => {
log.info('Setting up hyperliquid outcome metadata tables');
const files = SETUP_ACTIONS['hyperliquid-outcomes'].files.map((f) =>
resolve(__dirname, f),
);
await handleSetupCommand(files, options);
});
addClickhouseOptions(setupHyperliquidOutcomes);

// ---- setup kalshi ----
const setupKalshi = setupCommand
.command('kalshi')
Expand Down
2 changes: 1 addition & 1 deletion lib/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ describe('schema files', () => {
expect(plainMergeTree).toBeNull();
});

test('should parse hyperliquid-outcomes schema and transform for cluster', async () => {
test('should parse hyperliquid outcomes schema and transform for cluster', async () => {
const sql = await Bun.file(
'./sql.schemas/schema.hyperliquid_outcomes.sql',
).text();
Expand Down
4 changes: 2 additions & 2 deletions scripts/check-hyperliquid-outcomes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env bun
/**
* Validate the `hyperliquid-outcomes` scraper deployment against whichever
* ClickHouse the env points at.
* Validate the outcome-metadata side of the consolidated `hyperliquid`
* scraper deployment against whichever ClickHouse the env points at.
*
* Reports:
* - schema presence + column shape for `state_outcome_meta` /
Expand Down
185 changes: 89 additions & 96 deletions services/hyperliquid/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,82 +1,38 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';

const mockInsert = mock(() => Promise.resolve());
const mockQuery = mock(() =>
Promise.resolve({
data: [],
metrics: { httpRequestTimeMs: 0, dataFetchTimeMs: 0, totalTimeMs: 0 },
}),
);
const mockRunSpot = mock((_url: string) => Promise.resolve());
const mockRunOutcomes = mock((_url: string) => Promise.resolve());
const mockInitService = mock(() => {});
const mockMarkServiceAlive = mock(() => {});
const mockIncrementSuccess = mock(() => {});
const mockIncrementError = mock(() => {});
const mockInitService = mock(() => {});

// `mock.module` applies process-wide for the test session. Mirror the full set
// of exports other test files mock so a later `mock.module` call in another
// suite (e.g. polymarket) can override cleanly without leaving dangling
// undefined imports.
mock.module('../../lib/clickhouse', () => ({
insertClient: { insert: mockInsert },
query: mockQuery,
mock.module('./spot', () => ({ runSpotCycle: mockRunSpot }));
mock.module('./outcomes', () => ({ runOutcomesCycle: mockRunOutcomes }));
mock.module('../../lib/service-init', () => ({
initService: mockInitService,
markServiceAlive: mockMarkServiceAlive,
}));

mock.module('../../lib/prometheus', () => ({
incrementSuccess: mockIncrementSuccess,
incrementError: mockIncrementError,
}));

mock.module('../../lib/service-init', () => ({
initService: mockInitService,
}));

const sampleMeta = {
tokens: [
{
name: 'USDC',
fullName: null,
index: 0,
tokenId: '0x00',
szDecimals: 8,
weiDecimals: 8,
isCanonical: true,
evmContract: null,
deployerTradingFeeShare: '0.0',
},
{
name: 'HYPE',
fullName: null,
index: 150,
tokenId: '0x96',
szDecimals: 2,
weiDecimals: 8,
isCanonical: false,
evmContract: null,
deployerTradingFeeShare: '0.0',
},
],
universe: [
{
tokens: [150, 0],
name: '@107',
index: 107,
isCanonical: false,
},
],
};

describe('hyperliquid run()', () => {
const originalFetch = globalThis.fetch;
describe('hyperliquid run() — combined orchestrator', () => {
const originalUrl = process.env.HYPERLIQUID_INFO_URL;

beforeEach(() => {
mockInsert.mockClear();
mockRunSpot.mockClear();
mockRunOutcomes.mockClear();
mockInitService.mockClear();
mockMarkServiceAlive.mockClear();
mockIncrementSuccess.mockClear();
mockIncrementError.mockClear();
mockInitService.mockClear();
mockRunSpot.mockImplementation(() => Promise.resolve());
mockRunOutcomes.mockImplementation(() => Promise.resolve());
});

afterEach(() => {
globalThis.fetch = originalFetch;
if (originalUrl === undefined) {
delete process.env.HYPERLIQUID_INFO_URL;
} else {
Expand All @@ -88,54 +44,91 @@ describe('hyperliquid run()', () => {
delete process.env.HYPERLIQUID_INFO_URL;
const { run } = await import('./index');
await expect(run()).rejects.toThrow(/HYPERLIQUID_INFO_URL/);
expect(mockRunSpot).not.toHaveBeenCalled();
expect(mockRunOutcomes).not.toHaveBeenCalled();
expect(mockMarkServiceAlive).not.toHaveBeenCalled();
expect(mockIncrementSuccess).not.toHaveBeenCalled();
});

test('inserts resolved spot pair names with refresh_time', async () => {
test('runs spot + outcomes cycles in parallel with the configured info URL', async () => {
process.env.HYPERLIQUID_INFO_URL = 'http://example/info';
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(JSON.stringify(sampleMeta), { status: 200 }),
),
) as unknown as typeof fetch;

const { run } = await import('./index');
await run();

expect(mockInsert).toHaveBeenCalledTimes(1);
const arg = mockInsert.mock.calls[0]![0] as {
table: string;
values: Array<{
coin: string;
market_name: string;
base_token: string;
quote_token: string;
refresh_time: string;
}>;
format: string;
};
expect(arg.table).toBe('state_spot_pair_names');
expect(arg.format).toBe('JSONEachRow');
expect(arg.values).toHaveLength(1);
expect(arg.values[0]!.coin).toBe('@107');
expect(arg.values[0]!.market_name).toBe('HYPE/USDC');
expect(arg.values[0]!.base_token).toBe('HYPE');
expect(arg.values[0]!.quote_token).toBe('USDC');
expect(arg.values[0]!.refresh_time).toMatch(
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}$/,
);
expect(mockInitService).toHaveBeenCalledTimes(1);
expect(mockRunSpot).toHaveBeenCalledTimes(1);
expect(mockRunOutcomes).toHaveBeenCalledTimes(1);
expect(mockRunSpot.mock.calls[0]![0]).toBe('http://example/info');
expect(mockRunOutcomes.mock.calls[0]![0]).toBe('http://example/info');
});

test('advances heartbeat + success metric only when both sub-cycles succeed', async () => {
process.env.HYPERLIQUID_INFO_URL = 'http://example/info';

const { run } = await import('./index');
await run();

expect(mockMarkServiceAlive).toHaveBeenCalledTimes(1);
expect(mockIncrementSuccess).toHaveBeenCalledTimes(1);
expect(mockIncrementError).not.toHaveBeenCalled();
});

test('records an error metric and rethrows when fetch fails', async () => {
test('does NOT advance heartbeat when outcomes sub-cycle fails', async () => {
process.env.HYPERLIQUID_INFO_URL = 'http://example/info';
globalThis.fetch = mock(() =>
Promise.resolve(new Response('boom', { status: 502 })),
) as unknown as typeof fetch;
mockRunOutcomes.mockImplementation(() =>
Promise.reject(new Error('outcomes boom')),
);

const { run } = await import('./index');
await expect(run()).rejects.toThrow('outcomes boom');

// Both sub-cycles still attempted — failure in one must not short-circuit
// the other (`Promise.allSettled` semantics).
expect(mockRunSpot).toHaveBeenCalledTimes(1);
expect(mockRunOutcomes).toHaveBeenCalledTimes(1);
// Liveness contract: partial success is not success. /live must reflect
// that the service is degraded even though spot completed.
expect(mockMarkServiceAlive).not.toHaveBeenCalled();
expect(mockIncrementSuccess).not.toHaveBeenCalled();
});

test('does NOT advance heartbeat when spot sub-cycle fails', async () => {
process.env.HYPERLIQUID_INFO_URL = 'http://example/info';
mockRunSpot.mockImplementation(() =>
Promise.reject(new Error('spot boom')),
);

const { run } = await import('./index');
await expect(run()).rejects.toThrow();
expect(mockIncrementError).toHaveBeenCalledTimes(1);
expect(mockInsert).not.toHaveBeenCalled();
await expect(run()).rejects.toThrow('spot boom');

expect(mockMarkServiceAlive).not.toHaveBeenCalled();
expect(mockIncrementSuccess).not.toHaveBeenCalled();
});

test('both failures surface together via AggregateError, no heartbeat', async () => {
process.env.HYPERLIQUID_INFO_URL = 'http://example/info';
mockRunSpot.mockImplementation(() =>
Promise.reject(new Error('spot boom')),
);
mockRunOutcomes.mockImplementation(() =>
Promise.reject(new Error('outcomes boom')),
);

const { run } = await import('./index');
const err = await run().then(
() => null,
(e) => e,
);
expect(err).toBeInstanceOf(AggregateError);
// Both sub-cycle errors preserved — operator sees both reasons in the
// supervisor's stack instead of losing the second to a silent drop.
const reasons = (err as AggregateError).errors.map(
(e: unknown) => (e as Error).message,
);
expect(reasons).toContain('spot boom');
expect(reasons).toContain('outcomes boom');

expect(mockMarkServiceAlive).not.toHaveBeenCalled();
expect(mockIncrementSuccess).not.toHaveBeenCalled();
});
});
Loading
Loading