perp-cli 0.14.0 → 0.14.2
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.
- package/dist/arb/sizing.js +29 -7
- package/dist/bot/conditions.d.ts +5 -1
- package/dist/bot/conditions.js +16 -11
- package/dist/bot/engine.d.ts +12 -0
- package/dist/bot/engine.js +55 -44
- package/dist/bot/strategies/claude-agent.js +2 -2
- package/dist/bot/strategies/funding-arb-strategy.js +16 -20
- package/dist/bot/strategies/funding-arb-utils.d.ts +19 -0
- package/dist/bot/strategies/funding-arb-utils.js +44 -0
- package/dist/bot/strategies/funding-auto.js +61 -37
- package/dist/bot/strategies/grid-strategy.d.ts +2 -0
- package/dist/bot/strategies/grid-strategy.js +21 -2
- package/dist/bot/strategies/spot-perp-arb-strategy.js +28 -20
- package/dist/bot/tui/BotDashboard.d.ts +13 -2
- package/dist/bot/tui/BotDashboard.js +8 -3
- package/dist/bridge-engine.d.ts +2 -0
- package/dist/bridge-engine.js +68 -31
- package/dist/cli-program.js +19 -2
- package/dist/commands/account.js +2 -2
- package/dist/commands/arb/index.d.ts +20 -0
- package/dist/commands/arb/index.js +121 -36
- package/dist/commands/arb-auto.d.ts +36 -0
- package/dist/commands/arb-auto.js +202 -76
- package/dist/commands/arb.js +51 -35
- package/dist/commands/bot.d.ts +6 -0
- package/dist/commands/bot.js +55 -45
- package/dist/commands/funds-onchain.d.ts +1 -1
- package/dist/commands/funds-onchain.js +12 -49
- package/dist/commands/funds.js +51 -52
- package/dist/commands/history.js +165 -62
- package/dist/commands/portfolio.d.ts +5 -0
- package/dist/commands/portfolio.js +58 -20
- package/dist/commands/rebalance.js +46 -10
- package/dist/commands/settings.js +2 -1
- package/dist/commands/trade.js +8 -4
- package/dist/commands/wallet.js +74 -155
- package/dist/cross-chain-margin.d.ts +29 -2
- package/dist/cross-chain-margin.js +30 -4
- package/dist/dashboard/server.d.ts +45 -8
- package/dist/dashboard/server.js +113 -42
- package/dist/dashboard/ui.js +46 -27
- package/dist/dashboard/ws-feeds.d.ts +25 -3
- package/dist/dashboard/ws-feeds.js +219 -82
- package/dist/dex-asset-map.d.ts +23 -4
- package/dist/dex-asset-map.js +39 -13
- package/dist/exchanges/arcus/adapter.js +33 -6
- package/dist/exchanges/arcus/public.d.ts +6 -0
- package/dist/exchanges/arcus/public.js +12 -4
- package/dist/exchanges/aster.d.ts +33 -0
- package/dist/exchanges/aster.js +162 -55
- package/dist/exchanges/hyperliquid.d.ts +30 -0
- package/dist/exchanges/hyperliquid.js +159 -34
- package/dist/exchanges/lighter.d.ts +1 -1
- package/dist/exchanges/lighter.js +11 -9
- package/dist/exchanges/ondo/adapter.d.ts +4 -18
- package/dist/exchanges/ondo/adapter.js +5 -29
- package/dist/exchanges/ondo/funds.d.ts +1 -29
- package/dist/exchanges/ondo/funds.js +2 -94
- package/dist/exchanges/pacifica.d.ts +12 -0
- package/dist/exchanges/pacifica.js +78 -35
- package/dist/exchanges/qfex/adapter.js +4 -1
- package/dist/exchanges/venues/ondo.d.ts +0 -5
- package/dist/exchanges/venues/ondo.js +2 -6
- package/dist/exchanges/venues/qfex.d.ts +11 -0
- package/dist/exchanges/venues/qfex.js +10 -2
- package/dist/mcp-server.js +2 -2
- package/dist/onchain-balances.d.ts +40 -0
- package/dist/onchain-balances.js +173 -0
- package/dist/rebalance.d.ts +20 -1
- package/dist/rebalance.js +31 -5
- package/dist/strategies/funding-arb.js +38 -7
- package/package.json +1 -1
- package/skills/perp-cli/SKILL.md +20 -3
- package/skills/perp-cli/references/agent-operations.md +2 -2
- package/skills/perp-cli/references/commands.md +12 -9
|
@@ -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
|
-
|
|
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 = {
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
|
421
|
+
if (mark && Number(mark) > 0 && pos.markPrice !== mark) {
|
|
351
422
|
pos.markPrice = mark;
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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 {
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
const
|
|
382
|
-
|
|
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
|
-
|
|
390
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
|
399
|
-
const
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
const pnl = markNum > 0
|
|
403
|
-
? (side === "long" ? (markNum -
|
|
404
|
-
:
|
|
405
|
-
|
|
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:
|
|
410
|
-
markPrice: mark
|
|
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
|
|
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
|
-
|
|
422
|
-
|
|
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:
|
|
428
|
-
size:
|
|
429
|
-
filled:
|
|
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 {
|
|
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
|
-
|
|
544
|
-
const
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
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 =>
|
|
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
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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:
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
price
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
}
|
package/dist/dex-asset-map.d.ts
CHANGED
|
@@ -52,7 +52,23 @@ export interface DexArbPair {
|
|
|
52
52
|
viability: "A" | "B" | "C" | "D";
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
|
-
*
|
|
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
|
-
*
|
|
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<
|
|
96
|
+
}): Promise<{
|
|
97
|
+
pairs: DexArbPair[];
|
|
98
|
+
failedDexes: Record<string, string>;
|
|
99
|
+
}>;
|
package/dist/dex-asset-map.js
CHANGED
|
@@ -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
|
|
62
|
-
const {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
*
|
|
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
|
|
202
|
-
return findDexArbPairs(assets, opts);
|
|
227
|
+
const { assets, failedDexes } = await fetchAllDexAssetsDetailed();
|
|
228
|
+
return { pairs: findDexArbPairs(assets, opts), failedDexes };
|
|
203
229
|
}
|