perp-cli 0.14.1 → 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.
Files changed (61) 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 +16 -20
  8. package/dist/bot/strategies/funding-arb-utils.d.ts +19 -0
  9. package/dist/bot/strategies/funding-arb-utils.js +44 -0
  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 +28 -20
  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/portfolio.d.ts +5 -0
  30. package/dist/commands/portfolio.js +58 -20
  31. package/dist/commands/rebalance.js +46 -10
  32. package/dist/commands/settings.js +2 -1
  33. package/dist/commands/trade.js +8 -4
  34. package/dist/commands/wallet.js +1 -1
  35. package/dist/cross-chain-margin.d.ts +29 -2
  36. package/dist/cross-chain-margin.js +30 -4
  37. package/dist/dashboard/server.d.ts +45 -8
  38. package/dist/dashboard/server.js +113 -42
  39. package/dist/dashboard/ui.js +46 -27
  40. package/dist/dashboard/ws-feeds.d.ts +25 -3
  41. package/dist/dashboard/ws-feeds.js +219 -82
  42. package/dist/dex-asset-map.d.ts +23 -4
  43. package/dist/dex-asset-map.js +39 -13
  44. package/dist/exchanges/arcus/adapter.js +33 -6
  45. package/dist/exchanges/arcus/public.d.ts +6 -0
  46. package/dist/exchanges/arcus/public.js +12 -4
  47. package/dist/exchanges/aster.d.ts +19 -7
  48. package/dist/exchanges/aster.js +79 -46
  49. package/dist/exchanges/hyperliquid.d.ts +30 -0
  50. package/dist/exchanges/hyperliquid.js +159 -34
  51. package/dist/exchanges/lighter.d.ts +1 -1
  52. package/dist/exchanges/lighter.js +11 -9
  53. package/dist/exchanges/ondo/funds.d.ts +0 -1
  54. package/dist/exchanges/pacifica.d.ts +7 -0
  55. package/dist/exchanges/pacifica.js +52 -26
  56. package/dist/rebalance.d.ts +20 -1
  57. package/dist/rebalance.js +31 -5
  58. package/dist/strategies/funding-arb.js +38 -7
  59. package/package.json +1 -1
  60. package/skills/perp-cli/SKILL.md +2 -2
  61. package/skills/perp-cli/references/commands.md +5 -3
@@ -7,11 +7,13 @@
7
7
  import { createServer } from "http";
8
8
  import { WebSocketServer, WebSocket } from "ws";
9
9
  import { getUI } from "./ui.js";
10
- import { WsFeedManager } from "./ws-feeds.js";
10
+ import { WsFeedManager, isFeedStale } from "./ws-feeds.js";
11
11
  // Cached arb data (polled less frequently)
12
12
  let cachedArb = { opportunities: [], dexArb: [], dexAssets: [], dexNames: [], exchangeStatus: {}, exchangeErrors: {} };
13
13
  // Cached market data (polled with arb cycle — market metadata rarely changes)
14
14
  const cachedMarkets = new Map();
15
+ /** venue → why its last markets refresh failed (cleared on the next good read). */
16
+ const marketsErrors = new Map();
15
17
  /**
16
18
  * Find an available port starting from the given port.
17
19
  */
@@ -112,8 +114,10 @@ async function pollArbData() {
112
114
  return spreadB - spreadA;
113
115
  });
114
116
  }
115
- catch {
116
- // dex arb unavailable
117
+ catch (e) {
118
+ // Reported next to the funding errors — an empty dex table is never shown as "no opportunities".
119
+ exchangeStatus = { ...exchangeStatus, dexArb: "error" };
120
+ exchangeErrors = { ...exchangeErrors, dexArb: e instanceof Error ? e.message : String(e) };
117
121
  }
118
122
  const dexNames = [...dexNamesSet].sort();
119
123
  return { opportunities, dexArb, dexAssets: dexAssets.slice(0, 30), dexNames, exchangeStatus, exchangeErrors };
@@ -127,9 +131,14 @@ async function pollMarkets(exchanges) {
127
131
  try {
128
132
  const markets = await ex.adapter.getMarkets();
129
133
  cachedMarkets.set(ex.name, markets.slice(0, 10));
134
+ marketsErrors.delete(ex.name);
130
135
  }
131
- catch {
132
- // keep previous cached data
136
+ catch (e) {
137
+ // The last good list stays on screen, flagged by marketsError (never shown as fresh).
138
+ const msg = e instanceof Error ? e.message : String(e);
139
+ if (!marketsErrors.has(ex.name))
140
+ process.stderr.write(`[warn] dashboard ${ex.name} markets refresh failed: ${msg}\n`);
141
+ marketsErrors.set(ex.name, msg);
133
142
  }
134
143
  }));
135
144
  }
@@ -139,13 +148,20 @@ function buildSnapshotFromFeeds(feedMgr, exchanges) {
139
148
  let exchangeData = [];
140
149
  for (const ex of exchanges) {
141
150
  const state = states.get(ex.name);
142
- const emptyBal = { equity: "0", available: "0", marginUsed: "0", unrealizedPnl: "0" };
151
+ if (!state) {
152
+ exchangeData.push({ name: ex.name, balance: null, positions: null, orders: null, topMarkets: cachedMarkets.get(ex.name) ?? [], marketsError: marketsErrors.get(ex.name) ?? null, error: "no feed" });
153
+ continue;
154
+ }
155
+ const stale = isFeedStale(state);
143
156
  exchangeData.push({
144
157
  name: ex.name,
145
- balance: state?.balance ?? emptyBal,
146
- positions: [...(state?.positions ?? [])], // copy — mergeAndTotal mutates
147
- orders: [...(state?.orders ?? [])],
148
- topMarkets: cachedMarkets.get(ex.name) ?? [],
158
+ // Not read yet = null (unavailable), never a $0 / empty placeholder.
159
+ balance: state.balance,
160
+ positions: state.positions ? [...state.positions] : null, // copy — mergeAndTotal mutates
161
+ orders: state.orders ? [...state.orders] : null,
162
+ topMarkets: cachedMarkets.get(ex.name) ?? [], marketsError: marketsErrors.get(ex.name) ?? null,
163
+ error: state.lastError ?? (state.lastOkAt === null ? "no data received yet" : null),
164
+ feed: { mode: state.mode, lastOkAt: state.lastOkAt, lastError: state.lastError, stale },
149
165
  });
150
166
  }
151
167
  return mergeAndTotal(exchangeData);
@@ -156,23 +172,53 @@ async function pollSnapshot(exchanges, feedMgr) {
156
172
  if (feedMgr) {
157
173
  return buildSnapshotFromFeeds(feedMgr, exchanges);
158
174
  }
175
+ return mergeAndTotal(await readExchangesRest(exchanges));
176
+ }
177
+ /**
178
+ * REST read of every venue. A failed read is null + its reason in `error` — never a $0
179
+ * balance or an empty position / order list (those would read as "flat, no money").
180
+ */
181
+ export async function readExchangesRest(exchanges) {
159
182
  const { withCache, TTL_ACCOUNT } = await import("../cache.js");
160
- const emptyBalance = { equity: "0", available: "0", marginUsed: "0", unrealizedPnl: "0" };
161
- const results = await Promise.allSettled(exchanges.map(async (ex) => {
162
- const [balance, positions, orders] = await Promise.all([
163
- withCache(`dash:${ex.name}:balance`, TTL_ACCOUNT, () => ex.adapter.getBalance()).catch(() => emptyBalance),
164
- withCache(`dash:${ex.name}:positions`, TTL_ACCOUNT, () => ex.adapter.getPositions()).catch(() => []),
165
- withCache(`dash:${ex.name}:orders`, TTL_ACCOUNT, () => ex.adapter.getOpenOrders()).catch(() => []),
183
+ const errText = (e) => (e instanceof Error ? e.message : String(e));
184
+ return Promise.all(exchanges.map(async (ex) => {
185
+ const [balance, positions, orders] = await Promise.allSettled([
186
+ withCache(`dash:${ex.name}:balance`, TTL_ACCOUNT, () => ex.adapter.getBalance()),
187
+ withCache(`dash:${ex.name}:positions`, TTL_ACCOUNT, () => ex.adapter.getPositions()),
188
+ withCache(`dash:${ex.name}:orders`, TTL_ACCOUNT, () => ex.adapter.getOpenOrders()),
166
189
  ]);
167
- return { name: ex.name, balance, positions, orders, topMarkets: cachedMarkets.get(ex.name) ?? [] };
190
+ const errs = [];
191
+ if (balance.status === "rejected")
192
+ errs.push(`balance: ${errText(balance.reason)}`);
193
+ if (positions.status === "rejected")
194
+ errs.push(`positions: ${errText(positions.reason)}`);
195
+ if (orders.status === "rejected")
196
+ errs.push(`orders: ${errText(orders.reason)}`);
197
+ return {
198
+ name: ex.name,
199
+ balance: balance.status === "fulfilled" ? balance.value : null,
200
+ positions: positions.status === "fulfilled" ? positions.value : null,
201
+ orders: orders.status === "fulfilled" ? orders.value : null,
202
+ topMarkets: cachedMarkets.get(ex.name) ?? [], marketsError: marketsErrors.get(ex.name) ?? null,
203
+ error: errs.length > 0 ? errs.join("; ") : null,
204
+ };
168
205
  }));
169
- let exchangeData = results
170
- .filter((r) => r.status === "fulfilled")
171
- .map((r) => r.value);
172
- return mergeAndTotal(exchangeData);
173
206
  }
174
- /** Merge HL dex entries + compute totals */
175
- function mergeAndTotal(exchangeData) {
207
+ /** Merge HL dex entries + compute totals. Unknown (null) parts are skipped and listed in totals.unavailable. */
208
+ export function mergeAndTotal(exchangeData) {
209
+ const unavailable = [];
210
+ for (const ex of exchangeData) {
211
+ if (ex.balance === null)
212
+ unavailable.push(`${ex.name}.balance`);
213
+ else if (ex.balance.unrealizedPnl === null)
214
+ unavailable.push(`${ex.name}.unrealizedPnl`);
215
+ if (ex.positions === null)
216
+ unavailable.push(`${ex.name}.positions`);
217
+ if (ex.orders === null)
218
+ unavailable.push(`${ex.name}.orders`);
219
+ if (ex.feed?.stale)
220
+ unavailable.push(`${ex.name}.stale`);
221
+ }
176
222
  // Merge hl:* dex entries into main hyperliquid (same wallet, dex pools are subsets of main balance)
177
223
  const hlEntry = exchangeData.find(e => e.name === "hyperliquid");
178
224
  if (hlEntry) {
@@ -181,21 +227,31 @@ function mergeAndTotal(exchangeData) {
181
227
  // Only merge positions/orders, and attach dex breakdown for UI display.
182
228
  const dexBalances = [];
183
229
  let dexPnlSum = 0;
230
+ let dexPnlUnknown = false;
184
231
  for (const dex of dexEntries) {
185
232
  const dexName = dex.name.replace("hl:", "");
186
- dexBalances.push({ name: dexName, balance: { ...dex.balance } });
187
- hlEntry.positions.push(...dex.positions);
188
- hlEntry.orders.push(...dex.orders);
189
- dexPnlSum += Number(dex.balance.unrealizedPnl) || 0;
233
+ dexBalances.push({ name: dexName, balance: dex.balance ? { ...dex.balance } : null });
234
+ // An unread dex list is not merged; it is listed in totals.unavailable and in hlEntry.error below.
235
+ if (hlEntry.positions && dex.positions)
236
+ hlEntry.positions.push(...dex.positions);
237
+ if (hlEntry.orders && dex.orders)
238
+ hlEntry.orders.push(...dex.orders);
239
+ if (dex.balance?.unrealizedPnl == null)
240
+ dexPnlUnknown = true;
241
+ else
242
+ dexPnlSum += Number(dex.balance.unrealizedPnl);
243
+ if (dex.error)
244
+ hlEntry.error = hlEntry.error ? `${hlEntry.error}; ${dex.name}: ${dex.error}` : `${dex.name}: ${dex.error}`;
190
245
  }
191
246
  // Add dex unrealizedPnl to main HL balance (equity already includes dex funds, but PnL doesn't)
192
- if (dexPnlSum !== 0) {
193
- const hlPnl = Number(hlEntry.balance.unrealizedPnl) || 0;
194
- hlEntry.balance = { ...hlEntry.balance, unrealizedPnl: String(hlPnl + dexPnlSum) };
247
+ if (hlEntry.balance && hlEntry.balance.unrealizedPnl !== null) {
248
+ hlEntry.balance = {
249
+ ...hlEntry.balance,
250
+ unrealizedPnl: dexPnlUnknown ? null : String(Number(hlEntry.balance.unrealizedPnl) + dexPnlSum),
251
+ };
195
252
  }
196
- if (dexEntries.length > 0) {
253
+ if (dexEntries.length > 0)
197
254
  hlEntry.dexBalances = dexBalances;
198
- }
199
255
  exchangeData = exchangeData.filter(e => !e.name.startsWith("hl:") || e.name === "hyperliquid");
200
256
  }
201
257
  const totals = {
@@ -205,14 +261,21 @@ function mergeAndTotal(exchangeData) {
205
261
  unrealizedPnl: 0,
206
262
  positionCount: 0,
207
263
  orderCount: 0,
264
+ partial: unavailable.length > 0,
265
+ unavailable,
208
266
  };
209
267
  for (const ex of exchangeData) {
210
- totals.equity += Number(ex.balance.equity) || 0;
211
- totals.available += Number(ex.balance.available) || 0;
212
- totals.marginUsed += Number(ex.balance.marginUsed) || 0;
213
- totals.unrealizedPnl += Number(ex.balance.unrealizedPnl) || 0;
214
- totals.positionCount += ex.positions.length;
215
- totals.orderCount += ex.orders.length;
268
+ if (ex.balance) {
269
+ totals.equity += Number(ex.balance.equity);
270
+ totals.available += Number(ex.balance.available);
271
+ totals.marginUsed += Number(ex.balance.marginUsed);
272
+ if (ex.balance.unrealizedPnl !== null)
273
+ totals.unrealizedPnl += Number(ex.balance.unrealizedPnl);
274
+ }
275
+ if (ex.positions)
276
+ totals.positionCount += ex.positions.length;
277
+ if (ex.orders)
278
+ totals.orderCount += ex.orders.length;
216
279
  }
217
280
  return { timestamp: new Date().toISOString(), exchanges: exchangeData, totals, arb: cachedArb };
218
281
  }
@@ -281,8 +344,10 @@ export async function startDashboard(exchanges, opts = {}) {
281
344
  const snap = await pollSnapshot(exchanges, feedMgr);
282
345
  ws.send(JSON.stringify({ type: "snapshot", data: snap }));
283
346
  }
284
- catch {
285
- // ignore
347
+ catch (e) {
348
+ const msg = e instanceof Error ? e.message : String(e);
349
+ process.stderr.write(`[warn] dashboard initial snapshot failed: ${msg}\n`);
350
+ ws.send(JSON.stringify({ type: "error", data: { message: `initial snapshot failed: ${msg}` } }));
286
351
  }
287
352
  });
288
353
  // Arb + market data: stays on REST polling (cross-exchange aggregation, no single WS covers it)
@@ -297,12 +362,18 @@ export async function startDashboard(exchanges, opts = {}) {
297
362
  if (arbResult.status === "fulfilled") {
298
363
  cachedArb = arbResult.value;
299
364
  }
365
+ else {
366
+ // Keep the last good opportunities on screen but mark them as not refreshed.
367
+ const msg = arbResult.reason instanceof Error ? arbResult.reason.message : String(arbResult.reason);
368
+ process.stderr.write(`[warn] dashboard arb refresh failed: ${msg}\n`);
369
+ cachedArb = { ...cachedArb, exchangeStatus: { ...cachedArb.exchangeStatus, arb: "error" }, exchangeErrors: { ...cachedArb.exchangeErrors, arb: msg } };
370
+ }
300
371
  if (hasClients()) {
301
372
  broadcast(wss, { type: "arb", data: cachedArb });
302
373
  }
303
374
  }
304
- catch {
305
- // ignore
375
+ catch (e) {
376
+ process.stderr.write(`[warn] dashboard arb broadcast failed: ${e instanceof Error ? e.message : String(e)}\n`);
306
377
  }
307
378
  };
308
379
  pollArbAndMarkets(); // initial fetch
@@ -239,6 +239,11 @@ function pnlClass(v) { return Number(v)>=0?'pnl-pos':'pnl-neg'; }
239
239
  function pnlSign(v) { const n=Number(v)||0; return n>=0?'+$'+fmt(Math.abs(n)):'-$'+fmt(Math.abs(n)); }
240
240
  function rateClass(v) { const n=Number(v)||0; return n>0?'rate-positive':n<0?'rate-negative':'rate-neutral'; }
241
241
  function spreadClass(v) { return v>=50?'spread-high':v>=20?'spread-mid':'spread-low'; }
242
+ // null = the venue value could not be read / is unknown → shown as such, never as $0.
243
+ const NA = '<span style="color:var(--yellow,#e5c07b)">unavailable</span>';
244
+ function usdOrNA(v, d=2) { return v==null ? NA : '$'+fmt(v,d); }
245
+ function pnlOrNA(v) { return v==null ? NA : pnlSign(v); }
246
+ function escHtml(v) { return String(v).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[c]); }
242
247
 
243
248
  // ── Portfolio Rendering ──
244
249
  function renderTotals(t) {
@@ -250,12 +255,13 @@ function renderTotals(t) {
250
255
  {label:'Unrealized PnL',value:pnlSign(t.unrealizedPnl),cls:pnlCls},
251
256
  {label:'Positions',value:t.positionCount,cls:''},
252
257
  {label:'Open Orders',value:t.orderCount,cls:''},
253
- ].map(c=>\`<div class="total-card"><div class="label">\${c.label}</div><div class="value \${c.cls}">\${c.value}</div></div>\`).join('');
258
+ ].map(c=>\`<div class="total-card"><div class="label">\${c.label}\${t.partial?' (partial)':''}</div><div class="value \${c.cls}">\${c.value}</div></div>\`).join('')
259
+ + (t.partial ? \`<div class="total-card" style="grid-column:1/-1"><div class="label">\${NA} — left out of the totals</div><div style="font-size:12px">\${escHtml((t.unavailable||[]).join(', '))}</div></div>\` : '');
254
260
  }
255
261
 
256
262
  function renderTabs(exchanges) {
257
263
  if (!activeExchange && exchanges.length) activeExchange = exchanges[0].name;
258
- $('#tabs').innerHTML = exchanges.map(ex=>\`<div class="tab \${ex.name===activeExchange?'active':''}" data-ex="\${ex.name}">\${ex.name}</div>\`).join('');
264
+ $('#tabs').innerHTML = exchanges.map(ex=>\`<div class="tab \${ex.name===activeExchange?'active':''}" data-ex="\${ex.name}">\${ex.name}\${(ex.feed&&ex.feed.stale)||ex.error?' ⚠':''}</div>\`).join('');
259
265
  $$('.tab').forEach(tab => { tab.onclick=()=>{ activeExchange=tab.dataset.ex; render(); }; });
260
266
  }
261
267
 
@@ -271,16 +277,18 @@ function renderPanels(exchanges) {
271
277
 
272
278
  // For HL: detect unique dexes from positions/orders
273
279
  let dexes = [];
274
- let filteredPositions = ex.positions;
275
- let filteredOrders = ex.orders;
280
+ // null = list could not be read (shown as unavailable, never as "no positions")
281
+ const posKnown = ex.positions != null, ordKnown = ex.orders != null;
282
+ let filteredPositions = ex.positions || [];
283
+ let filteredOrders = ex.orders || [];
276
284
  if (isHL) {
277
285
  const dexSet = new Set();
278
- ex.positions.forEach(p => dexSet.add(getDex(p.symbol)));
279
- ex.orders.forEach(o => dexSet.add(getDex(o.symbol)));
286
+ filteredPositions.forEach(p => dexSet.add(getDex(p.symbol)));
287
+ filteredOrders.forEach(o => dexSet.add(getDex(o.symbol)));
280
288
  dexes = ['all', ...Array.from(dexSet).sort()];
281
289
  if (activeDexFilters.size > 0) {
282
- filteredPositions = ex.positions.filter(p => activeDexFilters.has(getDex(p.symbol)));
283
- filteredOrders = ex.orders.filter(o => activeDexFilters.has(getDex(o.symbol)));
290
+ filteredPositions = filteredPositions.filter(p => activeDexFilters.has(getDex(p.symbol)));
291
+ filteredOrders = filteredOrders.filter(o => activeDexFilters.has(getDex(o.symbol)));
284
292
  }
285
293
  }
286
294
 
@@ -293,32 +301,38 @@ function renderPanels(exchanges) {
293
301
  const dexBreakdown = (field) => {
294
302
  if (!dexBal || dexBal.length < 1) return '';
295
303
  return '<div style="font-size:10px;color:var(--muted);margin-top:2px">' +
296
- dexBal.map(d => d.name + ': $' + fmt(Number(d.balance[field]))).join(' · ') + '</div>';
304
+ dexBal.map(d => d.name + ': ' + (d.balance ? usdOrNA(d.balance[field]) : NA)).join(' · ') + '</div>';
297
305
  };
298
306
 
307
+ const bal = ex.balance || {};
308
+ const stale = ex.feed && ex.feed.stale;
309
+ const banner = (stale || ex.error)
310
+ ? \`<div class="empty-msg" style="color:var(--yellow,#e5c07b)">⚠ \${stale ? 'STALE data' : 'Partial data'}\${ex.feed && ex.feed.lastOkAt ? ' (last update ' + new Date(ex.feed.lastOkAt).toLocaleTimeString() + ')' : ''}\${ex.error ? ': ' + escHtml(ex.error) : ''}</div>\`
311
+ : '';
299
312
  return \`<div class="exchange-panel \${isActive?'active':''}" id="panel-\${ex.name}">
313
+ \${banner}
300
314
  <div class="balance-bar">
301
- <div class="balance-item"><div class="label">Equity</div><div class="val">$\${fmt(ex.balance.equity)}</div>\${dexBreakdown('equity')}</div>
302
- <div class="balance-item"><div class="label">Available</div><div class="val">$\${fmt(ex.balance.available)}</div>\${dexBreakdown('available')}</div>
303
- <div class="balance-item"><div class="label">Margin Used</div><div class="val">$\${fmt(ex.balance.marginUsed)}</div>\${dexBreakdown('marginUsed')}</div>
304
- <div class="balance-item"><div class="label">Unrealized PnL</div><div class="val \${pnlClass(ex.balance.unrealizedPnl)}">\${pnlSign(ex.balance.unrealizedPnl)}</div>\${dexBreakdown('unrealizedPnl')}</div>
315
+ <div class="balance-item"><div class="label">Equity</div><div class="val">\${usdOrNA(bal.equity)}</div>\${dexBreakdown('equity')}</div>
316
+ <div class="balance-item"><div class="label">Available</div><div class="val">\${usdOrNA(bal.available)}</div>\${dexBreakdown('available')}</div>
317
+ <div class="balance-item"><div class="label">Margin Used</div><div class="val">\${usdOrNA(bal.marginUsed)}</div>\${dexBreakdown('marginUsed')}</div>
318
+ <div class="balance-item"><div class="label">Unrealized PnL</div><div class="val \${pnlClass(bal.unrealizedPnl)}">\${pnlOrNA(bal.unrealizedPnl)}</div>\${dexBreakdown('unrealizedPnl')}</div>
305
319
  </div>
306
- <div class="section-title">Positions (\${filteredPositions.length}\${isHL && activeDexFilters.size>0 ? '/'+ex.positions.length : ''})</div>
320
+ <div class="section-title">Positions (\${posKnown ? filteredPositions.length : '?'}\${isHL && posKnown && activeDexFilters.size>0 ? '/'+ex.positions.length : ''})</div>
307
321
  \${dexFilterHtml}
308
322
  \${filteredPositions.length?\`<table><thead><tr>\${isHL?'<th>DEX</th>':''}<th>Symbol</th><th>Side</th><th>Size</th><th>Entry</th><th>Mark</th><th>Liq</th><th>Value</th><th>PnL</th><th>ROE%</th><th>Lev</th></tr></thead><tbody>\${filteredPositions.map(p=>{
309
323
  const dex = getDex(p.symbol);
310
324
  const sym = p.symbol.includes(':') ? p.symbol.split(':').slice(1).join(':') : p.symbol;
311
- const notional = Math.abs(Number(p.size)) * Number(p.markPrice);
312
- const margin = p.leverage > 0 ? notional / p.leverage : notional;
313
- const roe = margin > 0 ? (Number(p.unrealizedPnl) / margin * 100) : 0;
314
- return \`<tr>\${isHL?\`<td style="color:var(--purple);font-size:11px">\${dex}</td>\`:''}<td>\${sym}</td><td class="side-\${p.side}">\${p.side.toUpperCase()}</td><td>\${p.size}</td><td>$\${fmt(p.entryPrice)}</td><td>$\${fmt(p.markPrice)}</td><td>\${(p.liquidationPrice==null||p.liquidationPrice==='N/A')?'-':'$'+fmt(p.liquidationPrice)}</td><td style="color:var(--muted)">$\${fmt(notional)}</td><td class="\${pnlClass(p.unrealizedPnl)}">\${pnlSign(p.unrealizedPnl)}</td><td class="\${pnlClass(p.unrealizedPnl)}">\${roe>=0?'+':''}\${fmt(roe,1)}%</td><td>\${p.leverage}x</td></tr>\`;
315
- }).join('')}</tbody></table>\`:'<div class="empty-msg">No open positions</div>'}
316
- <div class="section-title">Open Orders (\${filteredOrders.length})</div>
325
+ const notional = p.markPrice == null ? null : Math.abs(Number(p.size)) * Number(p.markPrice);
326
+ const margin = notional == null ? null : p.leverage > 0 ? notional / p.leverage : notional;
327
+ const roe = margin != null && margin > 0 && p.unrealizedPnl != null ? (Number(p.unrealizedPnl) / margin * 100) : null;
328
+ return \`<tr>\${isHL?\`<td style="color:var(--purple);font-size:11px">\${dex}</td>\`:''}<td>\${sym}</td><td class="side-\${p.side}">\${p.side.toUpperCase()}</td><td>\${p.size}</td><td>\${usdOrNA(p.entryPrice)}</td><td>\${usdOrNA(p.markPrice)}</td><td>\${(p.liquidationPrice==null||p.liquidationPrice==='N/A')?'-':'$'+fmt(p.liquidationPrice)}</td><td style="color:var(--muted)">\${usdOrNA(notional)}</td><td class="\${pnlClass(p.unrealizedPnl)}">\${pnlOrNA(p.unrealizedPnl)}</td><td class="\${pnlClass(p.unrealizedPnl)}">\${roe==null?NA:(roe>=0?'+':'')+fmt(roe,1)+'%'}</td><td>\${p.leverage}x</td></tr>\`;
329
+ }).join('')}</tbody></table>\`:(posKnown?'<div class="empty-msg">No open positions</div>':'<div class="empty-msg">Positions '+NA+'</div>')}
330
+ <div class="section-title">Open Orders (\${ordKnown ? filteredOrders.length : '?'})</div>
317
331
  \${filteredOrders.length?\`<table><thead><tr>\${isHL?'<th>DEX</th>':''}<th>Symbol</th><th>Side</th><th>Type</th><th>Price</th><th>Size</th><th>Filled</th><th>Status</th></tr></thead><tbody>\${filteredOrders.map(o=>{
318
332
  const dex = getDex(o.symbol);
319
333
  const sym = o.symbol.includes(':') ? o.symbol.split(':').slice(1).join(':') : o.symbol;
320
334
  return \`<tr>\${isHL?\`<td style="color:var(--purple);font-size:11px">\${dex}</td>\`:''}<td>\${sym}</td><td class="side-\${o.side}">\${o.side.toUpperCase()}</td><td>\${o.type}</td><td>$\${fmt(o.price)}</td><td>\${o.size}</td><td>\${o.filled}</td><td>\${o.status}</td></tr>\`;
321
- }).join('')}</tbody></table>\`:'<div class="empty-msg">No open orders</div>'}
335
+ }).join('')}</tbody></table>\`:(ordKnown?'<div class="empty-msg">No open orders</div>':'<div class="empty-msg">Open orders '+NA+'</div>')}
322
336
  <div class="section-title">Markets (Top 10)</div>
323
337
  \${ex.topMarkets.length?\`<table><thead><tr><th>Symbol</th><th>Mark</th><th>Index</th><th>Funding</th><th>24h Vol</th><th>OI</th><th>Max Lev</th></tr></thead><tbody>\${ex.topMarkets.map(m=>{const fr=Number(m.fundingRate);return\`<tr><td>\${m.symbol}</td><td>$\${fmt(m.markPrice)}</td><td>\${usdOrDash(m.indexPrice)}</td><td class="\${rateClass(fr)}">\${(fr*100).toFixed(4)}%</td><td>\${usdOrDash(m.volume24h,0)}</td><td>\${usdOrDash(m.openInterest,0)}</td><td>\${m.maxLeverage}x</td></tr>\`;}).join('')}</tbody></table>\`:'<div class="empty-msg">No market data</div>'}
324
338
  </div>\`;
@@ -510,12 +524,17 @@ function detectEvents(snap) {
510
524
  for (const ex of snap.exchanges) {
511
525
  const prev = prevSnapshot.exchanges.find(e => e.name === ex.name);
512
526
  if (!prev) continue;
513
- const prevSyms = new Set(prev.positions.map(p => p.symbol));
514
- const currSyms = new Set(ex.positions.map(p => p.symbol));
515
- for (const p of ex.positions) { if (!prevSyms.has(p.symbol)) addEvent('position_opened', ex.name, {symbol:p.symbol,side:p.side,size:p.size}); }
516
- for (const p of prev.positions) { if (!currSyms.has(p.symbol)) addEvent('position_closed', ex.name, {symbol:p.symbol,side:p.side}); }
517
- const eqDelta = Math.abs(Number(ex.balance.equity) - Number(prev.balance.equity));
518
- if (eqDelta > 0.01) addEvent('balance_update', ex.name, {equity:ex.balance.equity,delta:eqDelta.toFixed(2)});
527
+ // An unread (null) side is not a change — no opened/closed events from a failed read.
528
+ if (prev.positions && ex.positions) {
529
+ const prevSyms = new Set(prev.positions.map(p => p.symbol));
530
+ const currSyms = new Set(ex.positions.map(p => p.symbol));
531
+ for (const p of ex.positions) { if (!prevSyms.has(p.symbol)) addEvent('position_opened', ex.name, {symbol:p.symbol,side:p.side,size:p.size}); }
532
+ for (const p of prev.positions) { if (!currSyms.has(p.symbol)) addEvent('position_closed', ex.name, {symbol:p.symbol,side:p.side}); }
533
+ }
534
+ if (prev.balance && ex.balance) {
535
+ const eqDelta = Math.abs(Number(ex.balance.equity) - Number(prev.balance.equity));
536
+ if (eqDelta > 0.01) addEvent('balance_update', ex.name, {equity:ex.balance.equity,delta:eqDelta.toFixed(2)});
537
+ }
519
538
  }
520
539
  prevSnapshot = snap;
521
540
  }
@@ -7,13 +7,35 @@
7
7
  */
8
8
  import type { ExchangeBalance, ExchangePosition, ExchangeOrder } from "../exchanges/index.js";
9
9
  import type { DashboardExchange } from "./server.js";
10
+ /** Balance as the dashboard knows it: unrealizedPnl null = the venue feed does not report it / unknown. */
11
+ export type DashboardBalance = Omit<ExchangeBalance, "unrealizedPnl"> & {
12
+ unrealizedPnl: string | null;
13
+ };
14
+ /** Position as the dashboard knows it: a price / PnL the WS message did not carry is null (unknown), never "0". */
15
+ export type DashboardPosition = Omit<ExchangePosition, "entryPrice" | "markPrice" | "unrealizedPnl"> & {
16
+ entryPrice: string | null;
17
+ markPrice: string | null;
18
+ unrealizedPnl: string | null;
19
+ };
10
20
  export interface WsFeedState {
11
- balance: ExchangeBalance;
12
- positions: ExchangePosition[];
13
- orders: ExchangeOrder[];
21
+ /** null = not read yet (never a zero placeholder). */
22
+ balance: DashboardBalance | null;
23
+ /** null = not read yet. */
24
+ positions: DashboardPosition[] | null;
25
+ /** null = not read yet. */
26
+ orders: ExchangeOrder[] | null;
14
27
  lastUpdate: number;
15
28
  mode: "ws" | "rest" | "connecting";
29
+ /** Last time a venue read / WS message was applied successfully (ms), null = never. */
30
+ lastOkAt: number | null;
31
+ /** Last read / parse failure ("<source>: <message>"), null = none since the feed started. */
32
+ lastError: string | null;
33
+ lastErrorAt: number | null;
16
34
  }
35
+ /** A feed is stale when it has no data yet, its last failure is newer than its last success,
36
+ * or (polling mode) its last success is older than this. */
37
+ export declare const FEED_STALE_MS = 30000;
38
+ export declare function isFeedStale(state: WsFeedState, now?: number): boolean;
17
39
  export interface WsFeedManagerOpts {
18
40
  onUpdate: (exchange: string, state: WsFeedState) => void;
19
41
  signal?: AbortSignal;