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.
- 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/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.js +6 -5
- package/dist/commands/funds.js +18 -5
- package/dist/commands/history.js +135 -53
- 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 +1 -1
- 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 +19 -7
- package/dist/exchanges/aster.js +79 -46
- 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/funds.d.ts +0 -1
- package/dist/exchanges/pacifica.d.ts +7 -0
- package/dist/exchanges/pacifica.js +52 -26
- 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 +2 -2
- package/skills/perp-cli/references/commands.md +5 -3
package/dist/dashboard/server.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
161
|
-
|
|
162
|
-
const [balance, positions, orders] = await Promise.
|
|
163
|
-
withCache(`dash:${ex.name}:balance`, TTL_ACCOUNT, () => ex.adapter.getBalance())
|
|
164
|
-
withCache(`dash:${ex.name}:positions`, TTL_ACCOUNT, () => ex.adapter.getPositions())
|
|
165
|
-
withCache(`dash:${ex.name}:orders`, TTL_ACCOUNT, () => ex.adapter.getOpenOrders())
|
|
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
|
-
|
|
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.
|
|
188
|
-
hlEntry.
|
|
189
|
-
|
|
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 (
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/dashboard/ui.js
CHANGED
|
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"'})[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
|
-
|
|
275
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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 =
|
|
283
|
-
filteredOrders =
|
|
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 + ':
|
|
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"
|
|
302
|
-
<div class="balance-item"><div class="label">Available</div><div class="val"
|
|
303
|
-
<div class="balance-item"><div class="label">Margin Used</div><div class="val"
|
|
304
|
-
<div class="balance-item"><div class="label">Unrealized PnL</div><div class="val \${pnlClass(
|
|
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) :
|
|
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
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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;
|