perp-cli 0.14.1 → 0.14.3

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.
Files changed (66) hide show
  1. package/dist/arb/sizing.js +29 -7
  2. package/dist/bot/conditions.d.ts +5 -1
  3. package/dist/bot/conditions.js +16 -11
  4. package/dist/bot/engine.d.ts +12 -0
  5. package/dist/bot/engine.js +55 -44
  6. package/dist/bot/strategies/claude-agent.js +2 -2
  7. package/dist/bot/strategies/funding-arb-strategy.js +22 -26
  8. package/dist/bot/strategies/funding-arb-utils.d.ts +33 -1
  9. package/dist/bot/strategies/funding-arb-utils.js +82 -19
  10. package/dist/bot/strategies/funding-auto.js +61 -37
  11. package/dist/bot/strategies/grid-strategy.d.ts +2 -0
  12. package/dist/bot/strategies/grid-strategy.js +21 -2
  13. package/dist/bot/strategies/spot-perp-arb-strategy.js +48 -29
  14. package/dist/bot/tui/BotDashboard.d.ts +13 -2
  15. package/dist/bot/tui/BotDashboard.js +8 -3
  16. package/dist/bridge-engine.d.ts +2 -0
  17. package/dist/bridge-engine.js +68 -31
  18. package/dist/commands/account.js +2 -2
  19. package/dist/commands/arb/index.d.ts +20 -0
  20. package/dist/commands/arb/index.js +121 -36
  21. package/dist/commands/arb-auto.d.ts +36 -0
  22. package/dist/commands/arb-auto.js +202 -76
  23. package/dist/commands/arb.js +51 -35
  24. package/dist/commands/bot.d.ts +6 -0
  25. package/dist/commands/bot.js +55 -45
  26. package/dist/commands/funds-onchain.js +6 -5
  27. package/dist/commands/funds.js +18 -5
  28. package/dist/commands/history.js +135 -53
  29. package/dist/commands/market.js +2 -2
  30. package/dist/commands/portfolio.d.ts +5 -0
  31. package/dist/commands/portfolio.js +58 -20
  32. package/dist/commands/rebalance.js +46 -10
  33. package/dist/commands/settings.js +2 -1
  34. package/dist/commands/trade.js +8 -4
  35. package/dist/commands/wallet.js +1 -1
  36. package/dist/cross-chain-margin.d.ts +29 -2
  37. package/dist/cross-chain-margin.js +30 -4
  38. package/dist/dashboard/server.d.ts +45 -8
  39. package/dist/dashboard/server.js +113 -42
  40. package/dist/dashboard/ui.js +47 -28
  41. package/dist/dashboard/ws-feeds.d.ts +25 -3
  42. package/dist/dashboard/ws-feeds.js +219 -82
  43. package/dist/dex-asset-map.d.ts +23 -4
  44. package/dist/dex-asset-map.js +39 -13
  45. package/dist/exchanges/arcus/adapter.js +33 -6
  46. package/dist/exchanges/arcus/public.d.ts +6 -0
  47. package/dist/exchanges/arcus/public.js +12 -4
  48. package/dist/exchanges/aster.d.ts +24 -7
  49. package/dist/exchanges/aster.js +178 -93
  50. package/dist/exchanges/hyperliquid.d.ts +30 -4
  51. package/dist/exchanges/hyperliquid.js +159 -55
  52. package/dist/exchanges/interface.d.ts +3 -2
  53. package/dist/exchanges/interface.js +3 -2
  54. package/dist/exchanges/lighter.d.ts +1 -1
  55. package/dist/exchanges/lighter.js +11 -9
  56. package/dist/exchanges/ondo/funds.d.ts +0 -1
  57. package/dist/exchanges/pacifica.d.ts +7 -0
  58. package/dist/exchanges/pacifica.js +52 -26
  59. package/dist/rebalance.d.ts +20 -1
  60. package/dist/rebalance.js +31 -5
  61. package/dist/strategies/funding-arb.js +38 -7
  62. package/dist/trade-validator.d.ts +1 -1
  63. package/dist/trade-validator.js +7 -2
  64. package/package.json +1 -1
  65. package/skills/perp-cli/SKILL.md +2 -2
  66. package/skills/perp-cli/references/commands.md +5 -3
@@ -6,14 +6,36 @@
6
6
  * Arb/market data stays on REST (cross-exchange aggregation, no single WS covers it).
7
7
  */
8
8
  import { WebSocket as NodeWebSocket } from "ws";
9
- const EMPTY_BALANCE = { equity: "0", available: "0", marginUsed: "0", unrealizedPnl: "0" };
9
+ /** Venue number → string, or null when the field is absent / not a finite number. */
10
+ function numStr(v) {
11
+ if (v === null || v === undefined || v === "")
12
+ return null;
13
+ const n = Number(v);
14
+ return Number.isFinite(n) ? String(v) : null;
15
+ }
16
+ /** A feed is stale when it has no data yet, its last failure is newer than its last success,
17
+ * or (polling mode) its last success is older than this. */
18
+ export const FEED_STALE_MS = 30_000;
19
+ export function isFeedStale(state, now = Date.now()) {
20
+ if (state.lastOkAt === null)
21
+ return true;
22
+ if (state.lastErrorAt !== null && state.lastErrorAt > state.lastOkAt)
23
+ return true;
24
+ return state.mode !== "ws" && now - state.lastOkAt > FEED_STALE_MS;
25
+ }
26
+ /** Log the first failure of a source, then every Nth consecutive one (avoid log spam). */
27
+ const LOG_EVERY_N_FAILURES = 20;
10
28
  const MAX_RECONNECT_DELAY = 30_000;
11
29
  // ── Base WS Feed ──
12
30
  class ExchangeWsFeed {
13
31
  exchange;
14
32
  onUpdate;
15
33
  ws = null;
16
- state = { balance: { ...EMPTY_BALANCE }, positions: [], orders: [], lastUpdate: 0, mode: "connecting" };
34
+ state = {
35
+ balance: null, positions: null, orders: null, lastUpdate: 0, mode: "connecting",
36
+ lastOkAt: null, lastError: null, lastErrorAt: null,
37
+ };
38
+ failureCounts = new Map();
17
39
  reconnectDelay = 1000;
18
40
  reconnectTimer = null;
19
41
  closed = false;
@@ -27,6 +49,29 @@ class ExchangeWsFeed {
27
49
  this.onUpdate = onUpdate;
28
50
  }
29
51
  getState() { return this.state; }
52
+ /** A read / parse succeeded: the data is fresh again. */
53
+ recordOk(source) {
54
+ this.state.lastOkAt = Date.now();
55
+ this.failureCounts.delete(source);
56
+ }
57
+ /** A read / parse failed: record it on the state (the UI marks the feed stale) and log it
58
+ * on the first failure and every LOG_EVERY_N_FAILURES consecutive ones after that. */
59
+ recordError(source, err) {
60
+ const msg = this.logFailure(source, err);
61
+ this.state.lastError = `${source}: ${msg}`;
62
+ this.state.lastErrorAt = Date.now();
63
+ this.onUpdate(this.state);
64
+ }
65
+ /** Log-only (data is still flowing from another path, e.g. REST while WS reconnects). */
66
+ logFailure(source, err) {
67
+ const msg = err instanceof Error ? err.message : String(err);
68
+ const n = (this.failureCounts.get(source) ?? 0) + 1;
69
+ this.failureCounts.set(source, n);
70
+ if (n === 1 || n % LOG_EVERY_N_FAILURES === 0) {
71
+ process.stderr.write(`[warn] dashboard ${this.exchange.name} ${source} failed (${n}x): ${msg}\n`);
72
+ }
73
+ return msg;
74
+ }
30
75
  /** Start a timer: if no WS data arrives within WS_DATA_TIMEOUT, switch to REST */
31
76
  startWsDataTimeout() {
32
77
  if (this.wsDataTimer)
@@ -40,6 +85,7 @@ class ExchangeWsFeed {
40
85
  }
41
86
  emitUpdate() {
42
87
  this.state.lastUpdate = Date.now();
88
+ this.state.lastOkAt = this.state.lastUpdate;
43
89
  this.wsDataReceived = true;
44
90
  // WS is delivering data — stop REST fallback if running
45
91
  if (this.state.mode === "rest" && this.ws?.readyState === NodeWebSocket.OPEN) {
@@ -54,9 +100,13 @@ class ExchangeWsFeed {
54
100
  try {
55
101
  const { setCached, TTL_ACCOUNT } = await import("../cache.js");
56
102
  const name = this.exchange.name;
57
- setCached(`dash:${name}:balance`, this.state.balance, TTL_ACCOUNT);
58
- setCached(`dash:${name}:positions`, this.state.positions, TTL_ACCOUNT);
59
- setCached(`dash:${name}:orders`, this.state.orders, TTL_ACCOUNT);
103
+ // Only data actually read is shared — an unread (null) part is never cached as empty.
104
+ if (this.state.balance)
105
+ setCached(`dash:${name}:balance`, this.state.balance, TTL_ACCOUNT);
106
+ if (this.state.positions)
107
+ setCached(`dash:${name}:positions`, this.state.positions, TTL_ACCOUNT);
108
+ if (this.state.orders)
109
+ setCached(`dash:${name}:orders`, this.state.orders, TTL_ACCOUNT);
60
110
  }
61
111
  catch { /* non-fatal */ }
62
112
  }
@@ -67,8 +117,11 @@ class ExchangeWsFeed {
67
117
  try {
68
118
  await this.connect();
69
119
  this.reconnectDelay = 1000; // reset on success
120
+ this.failureCounts.delete("ws-reconnect");
70
121
  }
71
- catch {
122
+ catch (e) {
123
+ // REST fallback keeps the data flowing; the reconnect failure is logged, not hidden.
124
+ this.logFailure("ws-reconnect", e);
72
125
  this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY);
73
126
  this.scheduleReconnect();
74
127
  }
@@ -91,7 +144,10 @@ class ExchangeWsFeed {
91
144
  this.state.orders = orders;
92
145
  this.emitUpdate();
93
146
  }
94
- catch { /* ignore */ }
147
+ catch (e) {
148
+ // Keep the last-read data but mark the feed stale (lastError) — never blank it to 0 / empty.
149
+ this.recordError("rest-poll", e);
150
+ }
95
151
  };
96
152
  doRestPoll(); // immediate first fetch
97
153
  this.restFallbackTimer = setInterval(doRestPoll, 5000);
@@ -161,7 +217,9 @@ class HyperliquidFeed extends ExchangeWsFeed {
161
217
  this.handleWebData2(msg.data);
162
218
  }
163
219
  }
164
- catch { /* ignore parse errors */ }
220
+ catch (e) {
221
+ this.recordError("ws-parse", e);
222
+ }
165
223
  });
166
224
  this.ws.on("close", () => {
167
225
  this.stopBalancePolling();
@@ -190,8 +248,11 @@ class HyperliquidFeed extends ExchangeWsFeed {
190
248
  const poll = async () => {
191
249
  try {
192
250
  this.state.balance = await this.exchange.adapter.getBalance();
251
+ this.recordOk("balance-poll");
252
+ }
253
+ catch (e) {
254
+ this.recordError("balance-poll", e);
193
255
  }
194
- catch { /* ignore */ }
195
256
  };
196
257
  poll(); // immediate
197
258
  this.balanceTimer = setInterval(poll, 5000);
@@ -212,19 +273,24 @@ class HyperliquidFeed extends ExchangeWsFeed {
212
273
  this.state.positions = assetPositions
213
274
  .filter((ap) => {
214
275
  const pos = (ap.position ?? ap);
215
- return Number(pos.szi ?? 0) !== 0;
276
+ const szi = numStr(pos.szi);
277
+ if (szi === null)
278
+ throw new Error(`webData2 position without szi: ${JSON.stringify(pos).slice(0, 200)}`);
279
+ return Number(szi) !== 0;
216
280
  })
217
281
  .map((ap) => {
218
282
  const pos = (ap.position ?? ap);
219
283
  const szi = Number(pos.szi);
284
+ const posValue = numStr(pos.positionValue);
220
285
  return {
221
286
  symbol: String(pos.coin ?? ""),
222
287
  side: (szi > 0 ? "long" : "short"),
223
288
  size: String(Math.abs(szi)),
224
- entryPrice: pos.entryPx ?? "0",
225
- markPrice: String(pos.positionValue ? (Number(pos.positionValue) / Math.abs(szi)).toFixed(2) : "0"),
289
+ // A field the message did not carry is unknown (null), never "0".
290
+ entryPrice: numStr(pos.entryPx),
291
+ markPrice: posValue !== null ? (Number(posValue) / Math.abs(szi)).toFixed(2) : null,
226
292
  liquidationPrice: pos.liquidationPx ?? null, // HL sends null when there is no liquidation price
227
- unrealizedPnl: pos.unrealizedPnl ?? "0",
293
+ unrealizedPnl: numStr(pos.unrealizedPnl),
228
294
  leverage: Number(ap.leverage?.value ?? 1),
229
295
  };
230
296
  });
@@ -246,7 +312,9 @@ class HyperliquidFeed extends ExchangeWsFeed {
246
312
  }
247
313
  this.emitUpdate();
248
314
  }
249
- catch { /* ignore */ }
315
+ catch (e) {
316
+ this.recordError("ws-webData2", e);
317
+ }
250
318
  }
251
319
  close() {
252
320
  this.stopBalancePolling();
@@ -302,7 +370,9 @@ class PacificaFeed extends ExchangeWsFeed {
302
370
  else if (ch === "account_order_updates")
303
371
  this.handleOrders(msg.data ?? msg);
304
372
  }
305
- catch { /* ignore */ }
373
+ catch (e) {
374
+ this.recordError("ws-parse", e);
375
+ }
306
376
  });
307
377
  this.ws.on("close", () => {
308
378
  this.clearHeartbeat();
@@ -342,28 +412,35 @@ class PacificaFeed extends ExchangeWsFeed {
342
412
  for (const p of positions) {
343
413
  this.markPrices.set(p.symbol, p.markPrice);
344
414
  }
415
+ this.recordOk("mark-poll");
345
416
  // Update existing WS positions with mark prices + compute PnL
346
- if (this.state.positions.length > 0) {
417
+ if (this.state.positions && this.state.positions.length > 0) {
347
418
  let changed = false;
348
419
  for (const pos of this.state.positions) {
349
420
  const mark = this.markPrices.get(pos.symbol);
350
- if (mark && mark !== "0" && pos.markPrice === "0") {
421
+ if (mark && Number(mark) > 0 && pos.markPrice !== mark) {
351
422
  pos.markPrice = mark;
352
- const markNum = Number(mark);
353
- const entry = Number(pos.entryPrice);
354
- const amount = Number(pos.size);
355
- const pnl = pos.side === "long"
356
- ? (markNum - entry) * amount
357
- : (entry - markNum) * amount;
358
- pos.unrealizedPnl = pnl.toFixed(4);
423
+ if (pos.entryPrice !== null) {
424
+ const markNum = Number(mark);
425
+ const entry = Number(pos.entryPrice);
426
+ const amount = Number(pos.size);
427
+ const pnl = pos.side === "long"
428
+ ? (markNum - entry) * amount
429
+ : (entry - markNum) * amount;
430
+ pos.unrealizedPnl = pnl.toFixed(4);
431
+ }
359
432
  changed = true;
360
433
  }
361
434
  }
435
+ if (changed && this.state.balance)
436
+ this.state.balance = { ...this.state.balance, unrealizedPnl: this.positionsUpnl() };
362
437
  if (changed)
363
438
  this.emitUpdate();
364
439
  }
365
440
  }
366
- catch { /* ignore */ }
441
+ catch (e) {
442
+ this.recordError("mark-poll", e);
443
+ }
367
444
  };
368
445
  poll(); // immediate
369
446
  this.markPriceTimer = setInterval(poll, 5000);
@@ -376,57 +453,86 @@ class PacificaFeed extends ExchangeWsFeed {
376
453
  }
377
454
  handleAccountInfo(data) {
378
455
  // Abbreviated fields: ae=account_equity, as=available_to_spend, mu=margin_used
379
- const eq = String(data.ae ?? data.account_equity ?? this.state.balance.equity);
380
- const av = String(data.as ?? data.available_to_spend ?? this.state.balance.available);
381
- const mu = String(data.mu ?? data.margin_used ?? this.state.balance.marginUsed);
382
- this.state.balance = { equity: eq, available: av, marginUsed: mu, unrealizedPnl: this.state.balance.unrealizedPnl };
456
+ // A field missing from this (partial) update keeps its previously-read value; with no
457
+ // previous value it is unknown → the update is rejected (recorded), never read as 0.
458
+ const prev = this.state.balance;
459
+ const eq = numStr(data.ae ?? data.account_equity) ?? prev?.equity;
460
+ const av = numStr(data.as ?? data.available_to_spend) ?? prev?.available;
461
+ const mu = numStr(data.mu ?? data.margin_used) ?? prev?.marginUsed;
462
+ if (eq == null || av == null || mu == null) {
463
+ this.recordError("ws-account_info", new Error("account_info without equity / available / margin_used"));
464
+ return;
465
+ }
466
+ // account_info carries no unrealized PnL: the sum of the positions' PnL when all are known, else null.
467
+ this.state.balance = { equity: eq, available: av, marginUsed: mu, unrealizedPnl: this.positionsUpnl() };
383
468
  this.emitUpdate();
384
469
  }
470
+ /** Sum of the positions' unrealized PnL; null when positions are unread or any PnL is unknown. */
471
+ positionsUpnl() {
472
+ const ps = this.state.positions;
473
+ if (!ps || ps.some(p => p.unrealizedPnl === null))
474
+ return null;
475
+ return ps.reduce((s, p) => s + Number(p.unrealizedPnl), 0).toFixed(4);
476
+ }
385
477
  handlePositions(data) {
386
478
  if (!Array.isArray(data))
387
479
  return;
388
480
  // WS uses abbreviated fields: s=symbol, d=side, a=amount, p=entry_price, l=liquidation, m=margin
389
- this.state.positions = data
390
- .filter((p) => {
391
- const amount = Number(p.a ?? p.amount ?? 0);
481
+ const positions = [];
482
+ for (const p of data) {
392
483
  const symbol = p.s ?? p.symbol;
393
- return amount !== 0 && symbol;
394
- })
395
- .map((p) => {
396
- const symbol = String(p.s ?? p.symbol ?? "");
484
+ const amount = numStr(p.a ?? p.amount);
485
+ if (!symbol || amount === null) {
486
+ // A row without a symbol / amount cannot be shown — reported, not read as "no position".
487
+ this.recordError("ws-account_positions", new Error(`position row without symbol/amount: ${JSON.stringify(p).slice(0, 200)}`));
488
+ continue;
489
+ }
490
+ const size = Number(amount);
491
+ if (size === 0)
492
+ continue;
397
493
  const side = String(p.d ?? p.side ?? "") === "bid" ? "long" : "short";
398
- const size = Number(p.a ?? p.amount ?? 0);
399
- const entryPrice = Number(p.p ?? p.entry_price ?? 0);
400
- const mark = this.markPrices.get(symbol);
401
- const markNum = mark ? Number(mark) : 0;
402
- const pnl = markNum > 0
403
- ? (side === "long" ? (markNum - entryPrice) * size : (entryPrice - markNum) * size)
404
- : 0;
405
- return {
406
- symbol,
494
+ const entry = numStr(p.p ?? p.entry_price);
495
+ const mark = this.markPrices.get(String(symbol)) ?? null;
496
+ const markNum = mark !== null ? Number(mark) : NaN;
497
+ // PnL needs both prices; unknown (null) otherwise — never 0.
498
+ const pnl = entry !== null && markNum > 0
499
+ ? (side === "long" ? (markNum - Number(entry)) * size : (Number(entry) - markNum) * size).toFixed(4)
500
+ : null;
501
+ positions.push({
502
+ symbol: String(symbol),
407
503
  side,
408
504
  size: String(size),
409
- entryPrice: String(entryPrice),
410
- markPrice: mark ?? "0",
505
+ entryPrice: entry,
506
+ markPrice: mark,
411
507
  liquidationPrice: p.l != null ? String(p.l) : p.liquidation_price != null ? String(p.liquidation_price) : null,
412
- unrealizedPnl: pnl.toFixed(4),
508
+ unrealizedPnl: pnl,
413
509
  leverage: Number(p.leverage ?? 1),
414
- };
415
- });
510
+ });
511
+ }
512
+ this.state.positions = positions;
513
+ if (this.state.balance)
514
+ this.state.balance = { ...this.state.balance, unrealizedPnl: this.positionsUpnl() };
416
515
  this.emitUpdate();
417
516
  }
418
517
  handleOrders(data) {
419
518
  if (!Array.isArray(data))
420
519
  return;
421
- this.state.orders = data
422
- .filter((o) => String(o.os ?? o.order_status ?? "") === "open")
520
+ const open = data.filter((o) => String(o.os ?? o.order_status ?? "") === "open");
521
+ // A row without a readable price / size / filled amount is reported, never shown as "0".
522
+ const readable = open.filter((o) => {
523
+ const ok = numStr(o.p ?? o.price) !== null && numStr(o.a ?? o.amount) !== null && numStr(o.f ?? o.filled) !== null;
524
+ if (!ok)
525
+ this.recordError("order-row", `order ${String(o.i ?? o.order_id ?? "?")} without price / amount / filled`);
526
+ return ok;
527
+ });
528
+ this.state.orders = readable
423
529
  .map((o) => ({
424
530
  orderId: String(o.i ?? o.order_id ?? ""),
425
531
  symbol: String(o.s ?? o.symbol ?? ""),
426
532
  side: (o.d ?? o.side) === "bid" ? "buy" : "sell",
427
- price: String(o.p ?? o.price ?? "0"),
428
- size: String(o.a ?? o.amount ?? ""),
429
- filled: String(o.f ?? o.filled ?? "0"),
533
+ price: numStr(o.p ?? o.price),
534
+ size: numStr(o.a ?? o.amount),
535
+ filled: numStr(o.f ?? o.filled),
430
536
  status: "open",
431
537
  type: String(o.ot ?? o.order_type ?? ""),
432
538
  triggerPrice: Number(o.sp ?? o.stop_price) > 0 ? String(o.sp ?? o.stop_price) : null,
@@ -470,7 +576,9 @@ class LighterFeed extends ExchangeWsFeed {
470
576
  const auth = await adapter.signer.createAuthToken(deadline);
471
577
  return auth.authToken;
472
578
  }
473
- catch {
579
+ catch (e) {
580
+ // No token → REST polling serves the data; the signer failure is logged, not hidden.
581
+ this.logFailure("ws-auth", e);
474
582
  return null;
475
583
  }
476
584
  }
@@ -510,7 +618,9 @@ class LighterFeed extends ExchangeWsFeed {
510
618
  const msg = JSON.parse(String(raw));
511
619
  this.handleMessage(msg);
512
620
  }
513
- catch { /* ignore */ }
621
+ catch (e) {
622
+ this.recordError("ws-parse", e);
623
+ }
514
624
  });
515
625
  this.ws.on("close", () => {
516
626
  if (!this.closed) {
@@ -540,11 +650,23 @@ class LighterFeed extends ExchangeWsFeed {
540
650
  const stats = (msg.stats ?? msg);
541
651
  // total_stats includes cross-margin + isolated totals
542
652
  const src = (stats.total_stats ?? stats);
543
- const equity = String(src.portfolio_value ?? src.collateral ?? this.state.balance.equity);
544
- const available = String(src.available_balance ?? this.state.balance.available);
545
- const marginUsed = String(src.margin_usage ?? this.state.balance.marginUsed);
546
- this.state.balance = { equity, available, marginUsed, unrealizedPnl: this.state.balance.unrealizedPnl };
547
- this.emitUpdate();
653
+ // A field missing from this update keeps its previously-read value; never read as 0.
654
+ const prev = this.state.balance;
655
+ const equity = numStr(src.portfolio_value ?? src.collateral) ?? prev?.equity;
656
+ const available = numStr(src.available_balance) ?? prev?.available;
657
+ const marginUsed = numStr(src.margin_usage) ?? prev?.marginUsed;
658
+ if (equity == null || available == null || marginUsed == null) {
659
+ this.recordError("ws-user_stats", new Error("user_stats without portfolio_value / available_balance / margin_usage"));
660
+ }
661
+ else {
662
+ // user_stats carries no unrealized PnL: the sum over the positions when all are known, else null.
663
+ const ps = this.state.positions;
664
+ const upnl = ps && ps.every(p => p.unrealizedPnl !== null)
665
+ ? ps.reduce((s, p) => s + Number(p.unrealizedPnl), 0).toFixed(4)
666
+ : null;
667
+ this.state.balance = { equity, available, marginUsed, unrealizedPnl: upnl };
668
+ this.emitUpdate();
669
+ }
548
670
  }
549
671
  // Positions: subscribed/ = full snapshot, update/ = incremental
550
672
  if (type.includes("account_all_positions")) {
@@ -566,19 +688,26 @@ class LighterFeed extends ExchangeWsFeed {
566
688
  }
567
689
  // Rebuild positions from map
568
690
  this.state.positions = [...this.positionMap.values()]
569
- .filter(p => Number(p.position ?? 0) !== 0)
691
+ .filter(p => {
692
+ // A position entry without a size cannot be shown — reported, not read as flat.
693
+ if (numStr(p.position) === null) {
694
+ this.recordError("ws-account_all_positions", new Error(`position without size (market ${String(p.market_id)})`));
695
+ return false;
696
+ }
697
+ return Number(p.position) !== 0;
698
+ })
570
699
  .map(p => {
571
- const posSize = Number(p.position ?? 0);
700
+ const posSize = Number(p.position);
701
+ const posValue = numStr(p.position_value);
572
702
  return {
573
703
  symbol: String(p.symbol ?? `Market-${p.market_id}`),
574
704
  side: (Number(p.sign) > 0 ? "long" : "short"),
575
705
  size: String(Math.abs(posSize)),
576
- entryPrice: String(p.avg_entry_price ?? "0"),
577
- markPrice: posSize !== 0
578
- ? String((Number(p.position_value ?? 0) / Math.abs(posSize)).toFixed(4))
579
- : "0",
706
+ // A field the message did not carry is unknown (null), never "0".
707
+ entryPrice: numStr(p.avg_entry_price),
708
+ markPrice: posValue !== null ? String((Number(posValue) / Math.abs(posSize)).toFixed(4)) : null,
580
709
  liquidationPrice: p.liquidation_price ? String(p.liquidation_price) : null,
581
- unrealizedPnl: String(p.unrealized_pnl ?? "0"),
710
+ unrealizedPnl: numStr(p.unrealized_pnl),
582
711
  leverage: Number(p.initial_margin_fraction ?? 0) > 0
583
712
  ? Math.round(10000 / Number(p.initial_margin_fraction))
584
713
  : 1,
@@ -592,19 +721,27 @@ class LighterFeed extends ExchangeWsFeed {
592
721
  const orders = msg.orders;
593
722
  if (orders) {
594
723
  const allOrders = Object.values(orders).flat();
595
- this.state.orders = allOrders
596
- .filter(o => String(o.status ?? "") === "open")
597
- .map(o => ({
598
- orderId: String(o.order_id ?? o.order_index ?? ""),
599
- symbol: String(o.symbol ?? ""),
600
- side: (o.side === "buy" || o.is_ask === false ? "buy" : "sell"),
601
- price: String(o.price ?? "0"),
602
- size: String(o.remaining_base_amount ?? o.initial_base_amount ?? ""),
603
- filled: String(o.filled_base_amount ?? "0"),
604
- status: "open",
605
- type: String(o.type ?? "limit"),
606
- triggerPrice: Number(o.trigger_price) > 0 ? String(o.trigger_price) : null,
607
- }));
724
+ // A row without a readable price / size / filled / type is reported, never shown as "0" / "limit".
725
+ const rows = [];
726
+ for (const o of allOrders.filter(o => String(o.status ?? "") === "open")) {
727
+ const price = numStr(o.price);
728
+ const size = numStr(o.remaining_base_amount ?? o.initial_base_amount);
729
+ const filled = numStr(o.filled_base_amount);
730
+ if (price === null || size === null || filled === null || typeof o.type !== "string" || o.type === "") {
731
+ this.recordError("order-row", `order ${String(o.order_id ?? o.order_index ?? "?")} without price / size / filled / type`);
732
+ continue;
733
+ }
734
+ rows.push({
735
+ orderId: String(o.order_id ?? o.order_index ?? ""),
736
+ symbol: String(o.symbol ?? ""),
737
+ side: (o.side === "buy" || o.is_ask === false ? "buy" : "sell"),
738
+ price, size, filled,
739
+ status: "open",
740
+ type: o.type,
741
+ triggerPrice: Number(o.trigger_price) > 0 ? String(o.trigger_price) : null,
742
+ });
743
+ }
744
+ this.state.orders = rows;
608
745
  this.emitUpdate();
609
746
  }
610
747
  }
@@ -52,7 +52,23 @@ export interface DexArbPair {
52
52
  viability: "A" | "B" | "C" | "D";
53
53
  }
54
54
  /**
55
- * Fetch all assets from all Hyperliquid dexes (native + deployed).
55
+ * Result of one all-dex read. `failedDexes` maps each dex whose metaAndAssetCtxs read
56
+ * failed (timeout / 429 / …) to its error: that dex's assets are UNKNOWN, not absent —
57
+ * a consumer must never treat a pair on a failed dex as gone.
58
+ */
59
+ export interface DexAssetsResult {
60
+ assets: DexAsset[];
61
+ failedDexes: Record<string, string>;
62
+ }
63
+ /**
64
+ * Fetch all assets from all Hyperliquid dexes (native + deployed), with the dexes whose
65
+ * read failed. Only a complete read (no failed dex) is cached.
66
+ */
67
+ export declare function fetchAllDexAssetsDetailed(): Promise<DexAssetsResult>;
68
+ /**
69
+ * Fetch all assets from all Hyperliquid dexes (display use). A dex whose read failed is
70
+ * reported on stderr (its assets are missing from the result). Trading paths use
71
+ * fetchAllDexAssetsDetailed() so a failed dex is never mistaken for an absent pair.
56
72
  */
57
73
  export declare function fetchAllDexAssets(): Promise<DexAsset[]>;
58
74
  /**
@@ -70,11 +86,14 @@ export declare function findDexArbPairs(assets: DexAsset[], opts?: {
70
86
  includeNative?: boolean;
71
87
  }): DexArbPair[];
72
88
  /**
73
- * Fetch and return arb opportunities across all HIP-3 dexes.
74
- * Single convenience function for CLI commands.
89
+ * Fetch and return arb opportunities across all HIP-3 dexes, plus the dexes whose read
90
+ * failed (pairs on those dexes are unknown this scan — not gone).
75
91
  */
76
92
  export declare function scanDexArb(opts?: {
77
93
  minAnnualSpread?: number;
78
94
  maxPriceGapPct?: number;
79
95
  includeNative?: boolean;
80
- }): Promise<DexArbPair[]>;
96
+ }): Promise<{
97
+ pairs: DexArbPair[];
98
+ failedDexes: Record<string, string>;
99
+ }>;
@@ -56,17 +56,37 @@ async function hlInfoPost(body) {
56
56
  return res.json();
57
57
  }
58
58
  /**
59
- * Fetch all assets from all Hyperliquid dexes (native + deployed).
59
+ * Fetch all assets from all Hyperliquid dexes (native + deployed), with the dexes whose
60
+ * read failed. Only a complete read (no failed dex) is cached.
60
61
  */
61
- export async function fetchAllDexAssets() {
62
- const { withCache, TTL_MARKET } = await import("./cache.js");
62
+ export async function fetchAllDexAssetsDetailed() {
63
+ const { getCached, setCached, TTL_MARKET } = await import("./cache.js");
63
64
  // Network in the key: a mainnet snapshot must never answer a testnet run (L7).
64
- return withCache(`pub:hl:${sharedApiNetwork()}:allDexAssets`, TTL_MARKET, () => _fetchAllDexAssetsLive());
65
+ const key = `pub:hl:${sharedApiNetwork()}:allDexAssets`;
66
+ const cached = getCached(key);
67
+ if (cached !== null)
68
+ return { assets: cached, failedDexes: {} };
69
+ const res = await _fetchAllDexAssetsLive();
70
+ if (Object.keys(res.failedDexes).length === 0)
71
+ setCached(key, res.assets, TTL_MARKET);
72
+ return res;
73
+ }
74
+ /**
75
+ * Fetch all assets from all Hyperliquid dexes (display use). A dex whose read failed is
76
+ * reported on stderr (its assets are missing from the result). Trading paths use
77
+ * fetchAllDexAssetsDetailed() so a failed dex is never mistaken for an absent pair.
78
+ */
79
+ export async function fetchAllDexAssets() {
80
+ const { assets, failedDexes } = await fetchAllDexAssetsDetailed();
81
+ for (const [dex, err] of Object.entries(failedDexes)) {
82
+ process.stderr.write(`[perp] HIP-3 dex ${dex} unavailable: ${err} — its assets are missing from this read\n`);
83
+ }
84
+ return assets;
65
85
  }
66
86
  async function _fetchAllDexAssetsLive() {
67
87
  const allMetas = await hlInfoPost({ type: "allPerpMetas" });
68
88
  if (!Array.isArray(allMetas))
69
- return [];
89
+ throw new Error("HL API error: allPerpMetas returned a non-array response");
70
90
  // Collect dex names (index 0 = native "hl", rest = deployed dexes)
71
91
  const dexNames = [];
72
92
  for (let i = 0; i < allMetas.length; i++) {
@@ -86,6 +106,7 @@ async function _fetchAllDexAssetsLive() {
86
106
  // Fetch metaAndAssetCtxs for each dex in batches of 3 to avoid 429 rate limits
87
107
  const BATCH_SIZE = 3;
88
108
  const allAssets = [];
109
+ const failedDexes = {};
89
110
  const tasks = dexNames.map((dex, i) => ({ dex, i })).filter(t => t.dex !== "");
90
111
  for (let b = 0; b < tasks.length; b += BATCH_SIZE) {
91
112
  const batch = tasks.slice(b, b + BATCH_SIZE);
@@ -95,8 +116,11 @@ async function _fetchAllDexAssetsLive() {
95
116
  ? { type: "metaAndAssetCtxs" }
96
117
  : { type: "metaAndAssetCtxs", dex };
97
118
  const data = await hlInfoPost(body);
98
- const universe = data[0]?.universe ?? [];
99
- const ctxs = data[1] ?? [];
119
+ if (!Array.isArray(data) || !Array.isArray(data[0]?.universe) || !Array.isArray(data[1])) {
120
+ throw new Error("metaAndAssetCtxs returned an unexpected shape");
121
+ }
122
+ const universe = data[0].universe;
123
+ const ctxs = data[1];
100
124
  return universe
101
125
  .map((asset, j) => {
102
126
  if (asset.isDelisted)
@@ -118,13 +142,15 @@ async function _fetchAllDexAssetsLive() {
118
142
  })
119
143
  .filter((a) => a !== null && a.markPrice > 0);
120
144
  }
121
- catch {
145
+ catch (e) {
146
+ // Explicit, not empty: the dex's assets are unknown this read.
147
+ failedDexes[dex] = e instanceof Error ? e.message : String(e);
122
148
  return [];
123
149
  }
124
150
  }));
125
151
  allAssets.push(...results.flat());
126
152
  }
127
- return allAssets;
153
+ return { assets: allAssets, failedDexes };
128
154
  }
129
155
  /**
130
156
  * Normalize a base name to its canonical underlying.
@@ -194,10 +220,10 @@ export function findDexArbPairs(assets, opts = {}) {
194
220
  return pairs.sort((a, b) => b.annualSpread - a.annualSpread);
195
221
  }
196
222
  /**
197
- * Fetch and return arb opportunities across all HIP-3 dexes.
198
- * Single convenience function for CLI commands.
223
+ * Fetch and return arb opportunities across all HIP-3 dexes, plus the dexes whose read
224
+ * failed (pairs on those dexes are unknown this scan — not gone).
199
225
  */
200
226
  export async function scanDexArb(opts) {
201
- const assets = await fetchAllDexAssets();
202
- return findDexArbPairs(assets, opts);
227
+ const { assets, failedDexes } = await fetchAllDexAssetsDetailed();
228
+ return { pairs: findDexArbPairs(assets, opts), failedDexes };
203
229
  }