openbroker 1.9.3 → 1.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,28 @@ import type {
21
21
  import { loadConfig, isMainnet } from './config.js';
22
22
  import { roundPrice, roundSize } from './utils.js';
23
23
 
24
+ export interface RealtimeBookSnapshot {
25
+ coin: string;
26
+ time: number;
27
+ levels: [
28
+ Array<{ px: string; sz: string; n: number }>,
29
+ Array<{ px: string; sz: string; n: number }>,
30
+ ];
31
+ }
32
+
33
+ export interface RealtimeDataProvider {
34
+ readonly connected: boolean;
35
+ getAllMids(): Record<string, string> | null;
36
+ getMainAssetCtxs(): AssetCtx[] | null;
37
+ getUserState(user: string, dex?: string): ClearinghouseState | null;
38
+ getUserStateAll(user: string): ClearinghouseState | null;
39
+ getSpotBalances(user: string): {
40
+ balances: Array<{ coin: string; token: number; hold: string; total: string; entryNtl: string }>;
41
+ } | null;
42
+ getOpenOrders(user: string): OpenOrder[] | null;
43
+ getL2Book(coin: string): Promise<RealtimeBookSnapshot | null>;
44
+ }
45
+
24
46
  export class HyperliquidClient {
25
47
  private config: OpenBrokerConfig;
26
48
  private account: PrivateKeyAccount;
@@ -33,6 +55,8 @@ export class HyperliquidClient {
33
55
  private szDecimalsMap: Map<string, number> = new Map();
34
56
  /** Maps coin name → dex info for HIP-3 assets. Main dex assets have dexName=null */
35
57
  private coinDexMap: Map<string, { dexName: string | null; dexIdx: number; localName: string }> = new Map();
58
+ /** Static positional universe per dex, used to join allDexsAssetCtxs WS pushes. */
59
+ private dexUniverseMap: Map<string, string[]> = new Map();
36
60
  /** Cache of perpDexs list */
37
61
  private perpDexsCache: Array<{ name: string; fullName: string; deployer: string } | null> | null = null;
38
62
  /** Whether HIP-3 assets have been loaded into maps */
@@ -63,6 +87,16 @@ export class HyperliquidClient {
63
87
  private spotMetaLoaded: boolean = false;
64
88
  /** HIP-4 outcome metadata cache */
65
89
  private outcomeMeta: OutcomeMetaResponse | null = null;
90
+ /** Static main-dex universe retained while live asset contexts arrive over WebSocket. */
91
+ private staticMeta: MetaAndAssetCtxs['meta'] | null = null;
92
+ /** Runtime-owned live data cache. CLI calls leave this detached and remain REST-only. */
93
+ private realtime: RealtimeDataProvider | null = null;
94
+ private predictedFundingsCache: {
95
+ value: Array<[string, Array<[string, { fundingRate: string; nextFundingTime: number }]>]>;
96
+ timestamp: number;
97
+ } | null = null;
98
+ private predictedFundingsInFlight: Promise<Array<[string, Array<[string, { fundingRate: string; nextFundingTime: number }]>]>> | null = null;
99
+ private spotMetaCache: Awaited<ReturnType<HyperliquidClient['getSpotMeta']>> | null = null;
66
100
  public verbose: boolean = false;
67
101
 
68
102
  constructor(config?: OpenBrokerConfig) {
@@ -226,6 +260,11 @@ export class HyperliquidClient {
226
260
  return !isMainnet();
227
261
  }
228
262
 
263
+ /** Attach/detach the automation runtime's WebSocket-first data cache. */
264
+ setRealtimeDataProvider(provider: RealtimeDataProvider | null): void {
265
+ this.realtime = provider;
266
+ }
267
+
229
268
  /**
230
269
  * Returns vaultAddress param for SDK exchange calls.
231
270
  * Only used for vault trading (HYPERLIQUID_VAULT_ADDRESS set explicitly).
@@ -255,6 +294,10 @@ export class HyperliquidClient {
255
294
  // ============ Market Data ============
256
295
 
257
296
  async getMetaAndAssetCtxs(): Promise<MetaAndAssetCtxs> {
297
+ const liveCtxs = this.realtime?.connected ? this.realtime.getMainAssetCtxs() : null;
298
+ if (this.staticMeta && liveCtxs) {
299
+ return { meta: this.staticMeta, assetCtxs: liveCtxs };
300
+ }
258
301
  if (this.meta) return this.meta;
259
302
 
260
303
  this.log('Fetching metaAndAssetCtxs...');
@@ -277,6 +320,8 @@ export class HyperliquidClient {
277
320
  assetCtxs: response[1] as AssetCtx[],
278
321
  };
279
322
  this.meta = meta;
323
+ this.staticMeta = meta.meta;
324
+ this.dexUniverseMap.set('', meta.meta.universe.map((asset) => asset.name));
280
325
 
281
326
  // Build lookup maps for main dex
282
327
  meta.meta.universe.forEach((asset, index) => {
@@ -294,6 +339,29 @@ export class HyperliquidClient {
294
339
  return meta;
295
340
  }
296
341
 
342
+ /**
343
+ * Warm only the native/main static universe for an automation runtime.
344
+ * Dynamic contexts arrive via allDexsAssetCtxs, and HIP-3 metadata is loaded
345
+ * on demand when a strategy references a prefixed market. This avoids the
346
+ * old startup burst that fetched every HIP-3 dex before the socket opened.
347
+ */
348
+ async initializeRealtimeMetadata(): Promise<void> {
349
+ if (this.staticMeta) return;
350
+ const response = await this.withRetry(() => this.info.metaAndAssetCtxs(), 'metaAndAssetCtxs(realtime-init)');
351
+ const meta: MetaAndAssetCtxs = {
352
+ meta: { universe: response[0].universe as AssetMeta[] },
353
+ assetCtxs: response[1] as AssetCtx[],
354
+ };
355
+ this.meta = meta;
356
+ this.staticMeta = meta.meta;
357
+ this.dexUniverseMap.set('', meta.meta.universe.map((asset) => asset.name));
358
+ meta.meta.universe.forEach((asset, index) => {
359
+ this.assetMap.set(asset.name, index);
360
+ this.szDecimalsMap.set(asset.name, asset.szDecimals);
361
+ this.coinDexMap.set(asset.name, { dexName: null, dexIdx: 0, localName: asset.name });
362
+ });
363
+ }
364
+
297
365
  /**
298
366
  * Load HIP-3 perp dex assets into the asset/szDecimals maps.
299
367
  * Asset index formula: 100000 + dexIdx * 10000 + assetIdx
@@ -422,11 +490,16 @@ export class HyperliquidClient {
422
490
  this.coinDexMap.set(coinName, { dexName, dexIdx, localName });
423
491
  if (asset.maxLeverage) this.hip3MaxLeverageMap.set(coinName, asset.maxLeverage);
424
492
  });
493
+ this.dexUniverseMap.set(dexName, universe.map((asset) => (
494
+ asset.name.startsWith(`${dexName}:`) ? asset.name : `${dexName}:${asset.name}`
495
+ )));
425
496
  }
426
497
  this.loadedHip3Dexes.add(dexName);
427
498
  }
428
499
 
429
500
  async getAllMids(): Promise<Record<string, string>> {
501
+ const live = this.realtime?.connected ? this.realtime.getAllMids() : null;
502
+ if (live) return { ...live };
430
503
  this.log('Fetching allMids...');
431
504
  let response: Record<string, string>;
432
505
  try {
@@ -584,6 +657,7 @@ export class HyperliquidClient {
584
657
  isCanonical: boolean;
585
658
  }>;
586
659
  }> {
660
+ if (this.spotMetaCache) return this.spotMetaCache;
587
661
  this.log('Fetching spotMeta...');
588
662
  const data = await this.postInfo<{
589
663
  tokens: Array<{
@@ -602,6 +676,7 @@ export class HyperliquidClient {
602
676
  isCanonical: boolean;
603
677
  }>;
604
678
  }>({ type: 'spotMeta' }, 'spotMeta');
679
+ this.spotMetaCache = data;
605
680
  this.log('spotMeta response:', JSON.stringify(data).slice(0, 500));
606
681
  return data;
607
682
  }
@@ -634,6 +709,23 @@ export class HyperliquidClient {
634
709
  prevDayPx: string;
635
710
  }>;
636
711
  }> {
712
+ const liveMids = this.realtime?.connected ? this.realtime.getAllMids() : null;
713
+ if (liveMids) {
714
+ const meta = await this.getSpotMeta();
715
+ return {
716
+ meta,
717
+ assetCtxs: meta.universe.map((pair) => {
718
+ const price = liveMids[pair.name] ?? '0';
719
+ return {
720
+ coin: pair.name,
721
+ dayNtlVlm: '0',
722
+ markPx: price,
723
+ midPx: price,
724
+ prevDayPx: price,
725
+ };
726
+ }),
727
+ };
728
+ }
637
729
  this.log('Fetching spotMetaAndAssetCtxs...');
638
730
  const data = await this.postInfo<unknown>(
639
731
  { type: 'spotMetaAndAssetCtxs' },
@@ -1003,6 +1095,9 @@ export class HyperliquidClient {
1003
1095
  entryNtl: string;
1004
1096
  }>;
1005
1097
  }> {
1098
+ const target = user ?? this.address;
1099
+ const live = this.realtime?.connected ? this.realtime.getSpotBalances(target) : null;
1100
+ if (live) return live;
1006
1101
  this.log('Fetching spotClearinghouseState for:', user ?? this.address);
1007
1102
  const data = await this.postInfo<{
1008
1103
  balances: Array<{
@@ -1076,6 +1171,32 @@ export class HyperliquidClient {
1076
1171
  async getPredictedFundings(): Promise<Array<[
1077
1172
  string, // coin
1078
1173
  Array<[string, { fundingRate: string; nextFundingTime: number }]> // venue funding rates
1174
+ ]>> {
1175
+ const ttlMs = 60_000;
1176
+ if (this.predictedFundingsCache && Date.now() - this.predictedFundingsCache.timestamp < ttlMs) {
1177
+ return this.predictedFundingsCache.value;
1178
+ }
1179
+ if (this.predictedFundingsInFlight) return this.predictedFundingsInFlight;
1180
+
1181
+ this.predictedFundingsInFlight = this.fetchPredictedFundings();
1182
+ try {
1183
+ const value = await this.predictedFundingsInFlight;
1184
+ this.predictedFundingsCache = { value, timestamp: Date.now() };
1185
+ return value;
1186
+ } catch (error) {
1187
+ if (this.predictedFundingsCache) {
1188
+ this.log('predictedFundings refresh failed; using stale cache:', this.describeError(error));
1189
+ return this.predictedFundingsCache.value;
1190
+ }
1191
+ throw error;
1192
+ } finally {
1193
+ this.predictedFundingsInFlight = null;
1194
+ }
1195
+ }
1196
+
1197
+ private async fetchPredictedFundings(): Promise<Array<[
1198
+ string,
1199
+ Array<[string, { fundingRate: string; nextFundingTime: number }]>
1079
1200
  ]>> {
1080
1201
  this.log('Fetching predictedFundings...');
1081
1202
  const baseUrl = isMainnet()
@@ -1091,11 +1212,15 @@ export class HyperliquidClient {
1091
1212
  },
1092
1213
  body: JSON.stringify({ type: 'predictedFundings' }),
1093
1214
  });
1215
+ if (!response.ok) {
1216
+ throw new Error(`predictedFundings failed: HTTP ${response.status} ${response.statusText}`);
1217
+ }
1094
1218
  const data = await response.json() as Array<[
1095
1219
  string,
1096
1220
  Array<[string, { fundingRate: string; nextFundingTime: number }]>
1097
1221
  ]>;
1098
- this.log('predictedFundings response length:', data?.length);
1222
+ if (!Array.isArray(data)) throw new Error('predictedFundings returned malformed payload');
1223
+ this.log('predictedFundings response length:', data.length);
1099
1224
  return data;
1100
1225
  }
1101
1226
 
@@ -1112,6 +1237,9 @@ export class HyperliquidClient {
1112
1237
  spread: number;
1113
1238
  spreadBps: number;
1114
1239
  }> {
1240
+ const live = this.realtime?.connected ? await this.realtime.getL2Book(coin) : null;
1241
+ if (live) return this.normalizeL2Book(live.levels);
1242
+
1115
1243
  this.log('Fetching l2Book for:', coin);
1116
1244
  // API accepts prefixed names directly (e.g., "xyz:CL")
1117
1245
  let response: Awaited<ReturnType<typeof this.info.l2Book>>;
@@ -1129,8 +1257,20 @@ export class HyperliquidClient {
1129
1257
  throw new Error(`l2Book(${coin}) returned empty/malformed payload.`);
1130
1258
  }
1131
1259
 
1132
- const bids = (response.levels[0] ?? []) as Array<{ px: string; sz: string; n: number }>;
1133
- const asks = (response.levels[1] ?? []) as Array<{ px: string; sz: string; n: number }>;
1260
+ return this.normalizeL2Book(response.levels as RealtimeBookSnapshot['levels']);
1261
+ }
1262
+
1263
+ private normalizeL2Book(levels: RealtimeBookSnapshot['levels']): {
1264
+ bids: Array<{ px: string; sz: string; n: number }>;
1265
+ asks: Array<{ px: string; sz: string; n: number }>;
1266
+ bestBid: number;
1267
+ bestAsk: number;
1268
+ midPrice: number;
1269
+ spread: number;
1270
+ spreadBps: number;
1271
+ } {
1272
+ const bids = levels[0] ?? [];
1273
+ const asks = levels[1] ?? [];
1134
1274
 
1135
1275
  const bestBid = bids.length > 0 ? parseFloat(bids[0].px) : 0;
1136
1276
  const bestAsk = asks.length > 0 ? parseFloat(asks[0].px) : 0;
@@ -1266,6 +1406,28 @@ export class HyperliquidClient {
1266
1406
  // Keep the asset/szDecimals/coinDex maps - they don't change
1267
1407
  }
1268
1408
 
1409
+ /** Join an allDexsAssetCtxs WebSocket payload with the cached static universes. */
1410
+ fundingRatesFromWs(ctxs: ReadonlyArray<[
1411
+ string,
1412
+ ReadonlyArray<{ funding?: string | number | null; premium?: string | number | null }>,
1413
+ ]>): Map<string, { rate: number; premium: number }> {
1414
+ const rates = new Map<string, { rate: number; premium: number }>();
1415
+ for (const [dexName, values] of ctxs) {
1416
+ const universe = this.dexUniverseMap.get(dexName || '');
1417
+ if (!universe) continue;
1418
+ for (let index = 0; index < Math.min(universe.length, values.length); index++) {
1419
+ const coin = universe[index];
1420
+ const value = values[index];
1421
+ if (!coin || !value) continue;
1422
+ rates.set(coin, {
1423
+ rate: Number(value.funding ?? 0),
1424
+ premium: Number(value.premium ?? 0),
1425
+ });
1426
+ }
1427
+ }
1428
+ return rates;
1429
+ }
1430
+
1269
1431
  /**
1270
1432
  * Get all loaded asset names (main + HIP-3)
1271
1433
  */
@@ -1952,6 +2114,9 @@ export class HyperliquidClient {
1952
2114
  }
1953
2115
 
1954
2116
  async getUserState(user?: string, dex?: string): Promise<ClearinghouseState> {
2117
+ const target = user ?? this.address;
2118
+ const live = this.realtime?.connected ? this.realtime.getUserState(target, dex) : null;
2119
+ if (live) return live;
1955
2120
  this.log('Fetching clearinghouseState for:', user ?? this.address, dex ? `dex: ${dex}` : '');
1956
2121
  const params: { user: string; dex?: string } = { user: user ?? this.address };
1957
2122
  if (dex !== undefined) params.dex = dex;
@@ -2107,6 +2272,9 @@ export class HyperliquidClient {
2107
2272
  * For standard accounts: aggregates margin summaries from each dex.
2108
2273
  */
2109
2274
  async getUserStateAll(user?: string): Promise<ClearinghouseState> {
2275
+ const target = user ?? this.address;
2276
+ const live = this.realtime?.connected ? this.realtime.getUserStateAll(target) : null;
2277
+ if (live) return live;
2110
2278
  await this.getMetaAndAssetCtxs(); // Ensure HIP-3 dex list is loaded
2111
2279
 
2112
2280
  const unified = await this.isUnifiedAccount(user);
@@ -2149,7 +2317,10 @@ export class HyperliquidClient {
2149
2317
  }
2150
2318
 
2151
2319
  async getOpenOrders(user?: string): Promise<OpenOrder[]> {
2152
- this.log('Fetching openOrders for:', user ?? this.address);
2320
+ const target = user ?? this.address;
2321
+ const live = this.realtime?.connected ? this.realtime.getOpenOrders(target) : null;
2322
+ if (live) return live;
2323
+ this.log('Fetching openOrders for:', target);
2153
2324
  await this.getMetaAndAssetCtxs(); // Ensure HIP-3 dex list is loaded
2154
2325
 
2155
2326
  // Fetch main dex orders
@@ -12,8 +12,9 @@ import type {
12
12
  AllDexsAssetCtxsWsEvent,
13
13
  AllDexsClearinghouseStateWsEvent,
14
14
  SpotStateWsEvent,
15
+ OpenOrdersWsEvent,
15
16
  } from '@nktkas/hyperliquid';
16
- import type { ClearinghouseState } from './types.js';
17
+ import type { ClearinghouseState, OpenOrder } from './types.js';
17
18
  import { isMainnet } from './config.js';
18
19
 
19
20
  // ── Event types ────────────────────────────────────────────────────
@@ -71,6 +72,8 @@ export interface WsEventMap {
71
72
  spotState: {
72
73
  balances: Array<{ coin: string; token: number; total: string; hold: string; entryNtl?: string }>;
73
74
  };
75
+ /** Complete open-order snapshot for one user + dex (empty dex = native/main). */
76
+ openOrders: { user: string; dex: string; orders: OpenOrder[] };
74
77
  /** Order status changed (filled, canceled, rejected, etc.) */
75
78
  orderUpdate: {
76
79
  order: {
@@ -136,6 +139,7 @@ export class WebSocketManager {
136
139
  private subscriptions: ISubscription[] = [];
137
140
  private handlers = new Map<WsEventType, Set<Function>>();
138
141
  private _connected = false;
142
+ private closing = false;
139
143
  private verbose: boolean;
140
144
 
141
145
  constructor(verbose = false) {
@@ -158,17 +162,38 @@ export class WebSocketManager {
158
162
  this.transport = new WebSocketTransport({
159
163
  isTestnet: !isMainnet(),
160
164
  resubscribe: true, // auto-resubscribe on reconnect
165
+ reconnect: { maxRetries: Infinity },
166
+ });
167
+
168
+ const socket = this.transport.socket as unknown as WebSocket;
169
+ socket.addEventListener('open', () => {
170
+ if (this._connected) return;
171
+ this._connected = true;
172
+ this.emit('connected', undefined);
173
+ this.log('Connected to', isMainnet() ? 'mainnet' : 'testnet');
174
+ });
175
+ socket.addEventListener('close', () => {
176
+ if (!this._connected) return;
177
+ this._connected = false;
178
+ this.emit('disconnected', { reason: this.closing ? 'manual close' : 'socket closed' });
179
+ this.log(this.closing ? 'Closed' : 'Disconnected; transport will reconnect');
180
+ });
181
+ socket.addEventListener('error', () => {
182
+ this.emit('error', { error: new Error('Hyperliquid WebSocket transport error') });
161
183
  });
162
184
 
163
185
  this.client = new SubscriptionClient({ transport: this.transport });
164
186
 
165
187
  await this.transport.ready();
166
- this._connected = true;
167
- this.emit('connected', undefined);
168
- this.log('Connected to', isMainnet() ? 'mainnet' : 'testnet');
188
+ if (!this._connected) {
189
+ this._connected = true;
190
+ this.emit('connected', undefined);
191
+ this.log('Connected to', isMainnet() ? 'mainnet' : 'testnet');
192
+ }
169
193
  }
170
194
 
171
195
  async close(): Promise<void> {
196
+ this.closing = true;
172
197
  for (const sub of this.subscriptions) {
173
198
  try { await sub.unsubscribe(); } catch { /* ignore */ }
174
199
  }
@@ -180,9 +205,12 @@ export class WebSocketManager {
180
205
  this.client = null;
181
206
  }
182
207
 
183
- this._connected = false;
184
- this.emit('disconnected', { reason: 'manual close' });
185
- this.log('Closed');
208
+ if (this._connected) {
209
+ this._connected = false;
210
+ this.emit('disconnected', { reason: 'manual close' });
211
+ this.log('Closed');
212
+ }
213
+ this.closing = false;
186
214
  }
187
215
 
188
216
  // ── Event system ───────────────────────────────────────────────
@@ -304,6 +332,19 @@ export class WebSocketManager {
304
332
  return this.trackSub(sub);
305
333
  }
306
334
 
335
+ /** Subscribe to complete open-order snapshots for one dex. */
336
+ async subscribeOpenOrders(user: `0x${string}`, dex = ''): Promise<ISubscription> {
337
+ const client = this.ensureClient();
338
+ const sub = await client.openOrders({ user, dex }, (data: OpenOrdersWsEvent) => {
339
+ this.emit('openOrders', {
340
+ user: data.user,
341
+ dex: data.dex,
342
+ orders: data.orders as unknown as OpenOrder[],
343
+ });
344
+ });
345
+ return this.trackSub(sub);
346
+ }
347
+
307
348
  /**
308
349
  * Subscribe to order lifecycle events (fill, cancel, reject, etc.).
309
350
  * This is the most important subscription for trading automations.
@@ -377,16 +418,24 @@ export class WebSocketManager {
377
418
  /**
378
419
  * Start all subscriptions needed for the automation runtime:
379
420
  * - allMids (price feed)
421
+ * - allDexsAssetCtxs (funding / mark / oracle contexts)
422
+ * - allDexsClearinghouseState + spotState (positions, margin, balances)
423
+ * - openOrders for main + requested HIP-3 dexes
380
424
  * - orderUpdates (order lifecycle)
381
425
  * - userFills (trade fills)
382
426
  * - userEvents (liquidations, funding payments, system cancels)
383
427
  */
384
- async subscribeAll(user: `0x${string}`): Promise<void> {
428
+ async subscribeAll(user: `0x${string}`, dexNames: string[] = []): Promise<void> {
385
429
  await this.connect();
386
430
  this.log('Subscribing to all feeds for', user);
387
431
 
388
432
  await Promise.all([
389
433
  this.subscribeAllMids(),
434
+ this.subscribeAllDexsAssetCtxs(),
435
+ this.subscribeAllDexsClearinghouseState(user),
436
+ this.subscribeSpotState(user),
437
+ this.subscribeOpenOrders(user),
438
+ ...dexNames.map((dex) => this.subscribeOpenOrders(user, dex)),
390
439
  this.subscribeOrderUpdates(user),
391
440
  this.subscribeUserFills(user),
392
441
  this.subscribeUserEvents(user),