perp-cli 0.4.8 → 0.4.10

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 (45) hide show
  1. package/README.md +44 -5
  2. package/dist/arb/reconcile.d.ts +28 -0
  3. package/dist/arb/reconcile.js +162 -0
  4. package/dist/commands/account.d.ts +1 -1
  5. package/dist/commands/account.js +366 -62
  6. package/dist/commands/agent.js +9 -5
  7. package/dist/commands/arb/index.js +243 -49
  8. package/dist/commands/arb-auto.js +67 -19
  9. package/dist/commands/arb.d.ts +2 -1
  10. package/dist/commands/arb.js +535 -131
  11. package/dist/commands/bot.js +207 -0
  12. package/dist/commands/funds.d.ts +4 -0
  13. package/dist/commands/funds.js +741 -0
  14. package/dist/commands/history.d.ts +2 -1
  15. package/dist/commands/history.js +508 -5
  16. package/dist/commands/manage.js +5 -3
  17. package/dist/commands/market.js +34 -1
  18. package/dist/commands/pnl.js +1 -1
  19. package/dist/commands/risk.d.ts +2 -0
  20. package/dist/commands/risk.js +56 -0
  21. package/dist/commands/settings.js +147 -0
  22. package/dist/commands/trade.d.ts +1 -1
  23. package/dist/commands/trade.js +241 -259
  24. package/dist/commands/wallet.d.ts +4 -0
  25. package/dist/commands/wallet.js +10 -0
  26. package/dist/config.d.ts +2 -2
  27. package/dist/config.js +11 -3
  28. package/dist/exchanges/hyperliquid-spot.d.ts +1 -0
  29. package/dist/exchanges/hyperliquid-spot.js +13 -21
  30. package/dist/exchanges/lighter-spot.js +13 -15
  31. package/dist/exchanges/lighter.js +82 -21
  32. package/dist/exchanges/pacifica.js +12 -0
  33. package/dist/execution-log.d.ts +1 -1
  34. package/dist/index.js +39 -123
  35. package/dist/mcp-server.js +43 -96
  36. package/dist/strategies/split-order.d.ts +49 -0
  37. package/dist/strategies/split-order.js +138 -0
  38. package/package.json +1 -1
  39. package/skills/perp-cli/SKILL.md +13 -26
  40. package/skills/perp-cli/references/agent-operations.md +19 -9
  41. package/skills/perp-cli/references/commands.md +38 -31
  42. package/skills/perp-cli/references/strategies.md +26 -16
  43. package/skills/perp-cli/scripts/funding-analysis.sh +2 -2
  44. package/skills/perp-cli/scripts/preflight.sh +1 -1
  45. package/skills/perp-cli/scripts/spot-perp-scan.sh +1 -1
@@ -1,7 +1,9 @@
1
- import { makeTable, formatUsd, formatPercent, printJson, jsonOk } from "../utils.js";
1
+ import { makeTable, formatUsd, formatPercent, formatPnl, printJson, jsonOk, jsonError, withJsonErrors } from "../utils.js";
2
2
  import chalk from "chalk";
3
- import { computeAnnualSpread, toHourlyRate } from "../funding.js";
4
- import { fetchPacificaPrices, fetchHyperliquidMeta, fetchLighterOrderBookDetails, fetchLighterFundingRates, } from "../shared-api.js";
3
+ import { annualizeRate, toHourlyRate, estimateHourlyFunding, annualizeHourlyRate } from "../funding.js";
4
+ import { fetchAllFundingRates, fetchSymbolFundingRates, TOP_SYMBOLS, } from "../funding-rates.js";
5
+ import { saveFundingSnapshot, getHistoricalRates, getCompoundedAnnualReturn, getExchangeCompoundingHours, } from "../funding-history.js";
6
+ import { fetchPacificaPrices, fetchHyperliquidMeta, fetchLighterOrderBookDetails, fetchLighterFundingRates, fetchPacificaPricesRaw, parsePacificaRaw, fetchHyperliquidAllMidsRaw, fetchLighterOrderBookDetailsRaw, } from "../shared-api.js";
5
7
  import { scanDexArb } from "../dex-asset-map.js";
6
8
  async function fetchPacificaRates() {
7
9
  const assets = await fetchPacificaPrices();
@@ -49,135 +51,12 @@ async function fetchLighterRates() {
49
51
  }
50
52
  }
51
53
  // annualize moved to ../funding.ts — use annualizeRate() / computeAnnualSpread()
52
- export function registerArbCommands(program, isJson) {
54
+ export function registerArbCommands(program, isJson, getAdapterForExchange) {
53
55
  const arb = program.command("arb").description("Funding rate arbitrage & basis trading");
54
- arb
55
- .command("funding")
56
- .description("Compare funding rates across exchanges")
57
- .option("-s, --symbol <symbol>", "Filter by symbol")
58
- .option("--min-spread <pct>", "Minimum annual spread % to show", "5")
59
- .action(async (opts) => {
60
- if (!isJson())
61
- console.log(chalk.cyan(" Fetching funding rates from all exchanges...\n"));
62
- const [pacRates, hlRates, ltRates] = await Promise.all([
63
- fetchPacificaRates(),
64
- fetchHyperliquidRates(),
65
- fetchLighterRates(),
66
- ]);
67
- // Build rate map by symbol
68
- const rateMap = new Map();
69
- for (const r of [...pacRates, ...hlRates, ...ltRates]) {
70
- if (opts.symbol && r.symbol.toUpperCase() !== opts.symbol.toUpperCase())
71
- continue;
72
- if (!rateMap.has(r.symbol))
73
- rateMap.set(r.symbol, []);
74
- rateMap.get(r.symbol).push(r);
75
- }
76
- const opportunities = [];
77
- for (const [symbol, rates] of rateMap.entries()) {
78
- if (rates.length < 2)
79
- continue;
80
- // Find min and max funding rate exchange (normalize to hourly for comparison)
81
- rates.sort((a, b) => toHourlyRate(a.fundingRate, a.exchange) - toHourlyRate(b.fundingRate, b.exchange));
82
- const lowest = rates[0];
83
- const highest = rates[rates.length - 1];
84
- if (lowest.exchange === highest.exchange)
85
- continue;
86
- const annualSpread = computeAnnualSpread(highest.fundingRate, highest.exchange, lowest.fundingRate, lowest.exchange);
87
- if (annualSpread >= parseFloat(opts.minSpread)) {
88
- opportunities.push({
89
- symbol,
90
- longExchange: lowest.exchange, // long where funding is lowest (you pay less / receive more)
91
- shortExchange: highest.exchange, // short where funding is highest (you receive more / pay less)
92
- longRate: lowest.fundingRate,
93
- shortRate: highest.fundingRate,
94
- spread: toHourlyRate(highest.fundingRate, highest.exchange) - toHourlyRate(lowest.fundingRate, lowest.exchange),
95
- annualSpread,
96
- markPrice: highest.markPrice || lowest.markPrice,
97
- });
98
- }
99
- }
100
- opportunities.sort((a, b) => b.annualSpread - a.annualSpread);
101
- if (isJson())
102
- return printJson(jsonOk(opportunities));
103
- if (opportunities.length === 0) {
104
- console.log(chalk.gray(` No funding arb opportunities above ${opts.minSpread}% annual spread.\n`));
105
- return;
106
- }
107
- console.log(chalk.cyan.bold(" Funding Rate Arbitrage Opportunities\n"));
108
- console.log(chalk.gray(` Strategy: Long on low-funding exchange, Short on high-funding exchange\n`));
109
- const rows = opportunities.map((o) => [
110
- chalk.white.bold(o.symbol),
111
- `$${formatUsd(o.markPrice)}`,
112
- `${o.longExchange}`,
113
- formatPercent(o.longRate),
114
- `${o.shortExchange}`,
115
- formatPercent(o.shortRate),
116
- chalk.yellow(`${o.annualSpread.toFixed(1)}%`),
117
- ]);
118
- console.log(makeTable(["Symbol", "Price", "Long On", "L.Fund", "Short On", "S.Fund", "Ann. Spread"], rows));
119
- console.log(chalk.gray("\n Note: All rates are hourly. Normalized before annualizing.\n"));
120
- });
121
- arb
122
- .command("rates")
123
- .description("[Deprecated] Use 'perp funding rates' instead. Shows all funding rates.")
124
- .option("-s, --symbol <symbol>", "Filter by symbol")
125
- .action(async (opts) => {
126
- if (!isJson()) {
127
- console.log(chalk.yellow("\n [Deprecated] 'perp arb rates' is deprecated. Use 'perp funding rates' instead.\n"));
128
- }
129
- // Delegate to the same data source as funding rates
130
- const [pacRates, hlRates, ltRates] = await Promise.all([
131
- fetchPacificaRates(),
132
- fetchHyperliquidRates(),
133
- fetchLighterRates(),
134
- ]);
135
- const pacMap = new Map(pacRates.map((r) => [r.symbol, r]));
136
- const hlMap = new Map(hlRates.map((r) => [r.symbol, r]));
137
- const ltMap = new Map(ltRates.map((r) => [r.symbol, r]));
138
- const allSymbols = new Set([...pacMap.keys(), ...hlMap.keys(), ...ltMap.keys()]);
139
- const symbols = opts.symbol
140
- ? [opts.symbol.toUpperCase()]
141
- : [...allSymbols].sort();
142
- if (isJson()) {
143
- const data = symbols.map((s) => ({
144
- symbol: s,
145
- pacifica: pacMap.get(s)?.fundingRate ?? null,
146
- hyperliquid: hlMap.get(s)?.fundingRate ?? null,
147
- lighter: ltMap.get(s)?.fundingRate ?? null,
148
- _deprecated: "Use 'perp funding rates' instead",
149
- }));
150
- return printJson(jsonOk(data));
151
- }
152
- const rows = symbols
153
- .filter((s) => pacMap.has(s) || hlMap.has(s) || ltMap.has(s))
154
- .map((s) => {
155
- const pac = pacMap.get(s);
156
- const hl = hlMap.get(s);
157
- const lt = ltMap.get(s);
158
- const pacRate = pac ? formatPercent(pac.fundingRate) : chalk.gray("-");
159
- const hlRate = hl ? formatPercent(hl.fundingRate) : chalk.gray("-");
160
- const ltRate = lt ? formatPercent(lt.fundingRate) : chalk.gray("-");
161
- const withEx = [
162
- pac ? { rate: pac.fundingRate, ex: "pacifica" } : null,
163
- hl ? { rate: hl.fundingRate, ex: "hyperliquid" } : null,
164
- lt ? { rate: lt.fundingRate, ex: "lighter" } : null,
165
- ].filter(Boolean);
166
- let annDiff = 0;
167
- if (withEx.length >= 2) {
168
- withEx.sort((a, b) => a.rate - b.rate);
169
- annDiff = computeAnnualSpread(withEx[0].rate, withEx[0].ex, withEx[withEx.length - 1].rate, withEx[withEx.length - 1].ex);
170
- }
171
- return [
172
- chalk.white.bold(s),
173
- pacRate,
174
- hlRate,
175
- ltRate,
176
- annDiff > 5 ? chalk.yellow(`${annDiff.toFixed(1)}%`) : `${annDiff.toFixed(1)}%`,
177
- ];
178
- });
179
- console.log(makeTable(["Symbol", "Pacifica", "Hyperliquid", "Lighter", "Ann. Spread"], rows));
180
- });
56
+ // Deprecated: arb funding → use 'perp arb rates'
57
+ const fundingCmd = arb.command("funding").description("Use 'perp arb rates'");
58
+ fundingCmd._hidden = true;
59
+ fundingCmd.action(() => console.log("Use 'perp arb rates --min-spread 5' instead."));
181
60
  arb
182
61
  .command("basis")
183
62
  .description("Show basis (spot vs perp price) opportunities across exchanges")
@@ -344,4 +223,529 @@ export function registerArbCommands(program, isJson) {
344
223
  console.log(chalk.gray(` A: OI>$1M B: OI>$100K C: OI>$10K D: OI<$10K (thin — high risk)`));
345
224
  console.log(chalk.gray(` Note: HIP-3 dexes charge 2x fees. Factor into net profitability.\n`));
346
225
  });
226
+ // ── arb watch/track/alert ── (promoted from arb gap)
227
+ registerGapDirectCommands(arb, isJson);
228
+ // ── Funding subcommands (merged from funding.ts) ──
229
+ registerFundingSubcommands(arb, isJson, getAdapterForExchange);
230
+ // Deprecated aliases removed — use arb prices/watch/track/alert/rates directly
231
+ }
232
+ async function fetchAllPrices() {
233
+ const [pacRes, hlRes, ltRes] = await Promise.all([
234
+ fetchPacificaPricesRaw(),
235
+ fetchHyperliquidAllMidsRaw(),
236
+ fetchLighterOrderBookDetailsRaw(),
237
+ ]);
238
+ const { prices: pacPrices } = parsePacificaRaw(pacRes);
239
+ const hlPrices = new Map();
240
+ if (hlRes && typeof hlRes === "object" && !Array.isArray(hlRes)) {
241
+ for (const [symbol, price] of Object.entries(hlRes)) {
242
+ const p = Number(price);
243
+ if (p > 0)
244
+ hlPrices.set(symbol, p);
245
+ }
246
+ }
247
+ const ltPrices = new Map();
248
+ if (ltRes) {
249
+ const details = (ltRes.order_book_details ?? []);
250
+ for (const m of details) {
251
+ const sym = String(m.symbol ?? "").replace(/_USDC$/, "");
252
+ const price = Number(m.last_trade_price ?? m.mark_price ?? 0);
253
+ if (sym && price > 0)
254
+ ltPrices.set(sym, price);
255
+ }
256
+ }
257
+ const allSymbols = new Set([...pacPrices.keys(), ...hlPrices.keys(), ...ltPrices.keys()]);
258
+ const snapshots = [];
259
+ for (const sym of allSymbols) {
260
+ const pac = pacPrices.get(sym) ?? null;
261
+ const hl = hlPrices.get(sym) ?? null;
262
+ const lt = ltPrices.get(sym) ?? null;
263
+ // Need at least 2 exchanges
264
+ const available = [];
265
+ if (pac !== null)
266
+ available.push({ ex: "PAC", price: pac });
267
+ if (hl !== null)
268
+ available.push({ ex: "HL", price: hl });
269
+ if (lt !== null)
270
+ available.push({ ex: "LT", price: lt });
271
+ if (available.length < 2)
272
+ continue;
273
+ available.sort((a, b) => a.price - b.price);
274
+ const cheapest = available[0];
275
+ const expensive = available[available.length - 1];
276
+ const maxGap = expensive.price - cheapest.price;
277
+ const mid = (cheapest.price + expensive.price) / 2;
278
+ const maxGapPct = mid > 0 ? (maxGap / mid) * 100 : 0;
279
+ snapshots.push({
280
+ symbol: sym,
281
+ pacPrice: pac,
282
+ hlPrice: hl,
283
+ ltPrice: lt,
284
+ maxGap,
285
+ maxGapPct,
286
+ cheapest: cheapest.ex,
287
+ expensive: expensive.ex,
288
+ });
289
+ }
290
+ return snapshots.sort((a, b) => b.maxGapPct - a.maxGapPct);
291
+ }
292
+ function formatGapPrice(p) {
293
+ if (p >= 1000)
294
+ return p.toFixed(2);
295
+ if (p >= 1)
296
+ return p.toFixed(4);
297
+ return p.toFixed(6);
298
+ }
299
+ function printGapTable(snapshots, minGap) {
300
+ const filtered = snapshots.filter((s) => s.maxGapPct >= minGap);
301
+ if (filtered.length === 0) {
302
+ console.log(chalk.gray(`\n No gaps above ${minGap}%\n`));
303
+ return;
304
+ }
305
+ console.log(chalk.cyan.bold("\n Symbol Pacifica Hyperliquid Lighter Gap($) Gap(%) Buy/Sell\n"));
306
+ for (const s of filtered) {
307
+ const gapColor = s.maxGapPct >= 0.5
308
+ ? chalk.red.bold
309
+ : s.maxGapPct >= 0.1
310
+ ? chalk.yellow
311
+ : chalk.gray;
312
+ const fmtP = (p) => p !== null ? `$${formatGapPrice(p).padEnd(16)}` : chalk.gray("-".padEnd(17));
313
+ console.log(` ${chalk.white.bold(s.symbol.padEnd(10))} ` +
314
+ `${fmtP(s.pacPrice)} ` +
315
+ `${fmtP(s.hlPrice)} ` +
316
+ `${fmtP(s.ltPrice)} ` +
317
+ `${gapColor("$" + s.maxGap.toFixed(4).padEnd(10))} ` +
318
+ `${gapColor(s.maxGapPct.toFixed(4).padEnd(8) + "%")} ` +
319
+ `${chalk.green(s.cheapest)}→${chalk.red(s.expensive)}`);
320
+ }
321
+ const avgGap = filtered.reduce((sum, s) => sum + s.maxGapPct, 0) / filtered.length;
322
+ const top = filtered[0];
323
+ console.log(chalk.gray(`\n ${filtered.length} pairs | Avg gap: ${avgGap.toFixed(4)}% | Max: ${top.symbol} ${top.maxGapPct.toFixed(4)}%\n`));
324
+ }
325
+ function printTrackSummary(symbol, samples) {
326
+ if (samples.length === 0) {
327
+ console.log(chalk.gray("\n No samples collected.\n"));
328
+ return;
329
+ }
330
+ const gaps = samples.map((s) => s.gapPct);
331
+ const avg = gaps.reduce((a, b) => a + b, 0) / gaps.length;
332
+ const max = Math.max(...gaps);
333
+ const min = Math.min(...gaps);
334
+ const maxSample = samples[gaps.indexOf(max)];
335
+ // Count direction frequencies
336
+ const dirCounts = new Map();
337
+ for (const s of samples) {
338
+ dirCounts.set(s.direction, (dirCounts.get(s.direction) ?? 0) + 1);
339
+ }
340
+ console.log(chalk.cyan.bold(`\n ${symbol} Gap Summary (${samples.length} samples)\n`));
341
+ console.log(` Avg gap: ${avg.toFixed(4)}%`);
342
+ console.log(` Max gap: ${max.toFixed(4)}% at ${maxSample.time}`);
343
+ console.log(` Min gap: ${min.toFixed(4)}%`);
344
+ for (const [dir, count] of [...dirCounts.entries()].sort((a, b) => b[1] - a[1])) {
345
+ console.log(` ${dir.padEnd(10)} ${count} times (${((count / samples.length) * 100).toFixed(0)}%)`);
346
+ }
347
+ console.log();
348
+ }
349
+ /**
350
+ * Register gap-related commands directly on the arb parent (promoted from arb gap).
351
+ * - arb prices: snapshot of cross-exchange prices (was arb gap show)
352
+ * - arb watch: live monitoring (was arb gap watch)
353
+ * - arb track: track gap over time (was arb gap track)
354
+ * - arb alert: threshold alert (was arb gap alert)
355
+ */
356
+ function registerGapDirectCommands(arb, isJson) {
357
+ // ── arb prices (was arb gap show) ──
358
+ arb
359
+ .command("prices")
360
+ .description("Show current prices across all exchanges with gap analysis")
361
+ .option("--min <pct>", "Minimum gap % to display", "0.01")
362
+ .option("--symbol <sym>", "Filter by symbol (e.g. BTC, ETH)")
363
+ .option("--top <n>", "Show top N gaps only")
364
+ .action(async (opts) => {
365
+ const minGap = parseFloat(opts.min);
366
+ if (!isJson())
367
+ console.log(chalk.cyan("\n Fetching prices from Pacifica, Hyperliquid & Lighter...\n"));
368
+ let snapshots = await fetchAllPrices();
369
+ if (opts.symbol) {
370
+ const sym = opts.symbol.toUpperCase();
371
+ snapshots = snapshots.filter((s) => s.symbol.includes(sym));
372
+ }
373
+ if (opts.top) {
374
+ snapshots = snapshots.slice(0, parseInt(opts.top));
375
+ }
376
+ if (isJson())
377
+ return printJson(jsonOk(snapshots.filter((s) => s.maxGapPct >= minGap)));
378
+ printGapTable(snapshots, minGap);
379
+ });
380
+ // ── arb watch (was arb gap watch) ──
381
+ arb
382
+ .command("watch")
383
+ .description("Live-monitor price gaps across exchanges (auto-refresh)")
384
+ .option("--min <pct>", "Minimum gap % to display", "0.05")
385
+ .option("--interval <seconds>", "Refresh interval", "5")
386
+ .option("--symbol <sym>", "Filter by symbol")
387
+ .option("--beep", "Beep on gaps above 0.5%")
388
+ .action(async (opts) => {
389
+ const minGap = parseFloat(opts.min);
390
+ const intervalMs = parseInt(opts.interval) * 1000;
391
+ const beep = !!opts.beep;
392
+ if (!isJson()) {
393
+ console.log(chalk.cyan.bold("\n Price Gap Monitor"));
394
+ console.log(` Min gap: ${minGap}%`);
395
+ console.log(` Interval: ${opts.interval}s`);
396
+ if (opts.symbol)
397
+ console.log(` Symbol: ${opts.symbol.toUpperCase()}`);
398
+ console.log(chalk.gray(" Ctrl+C to stop\n"));
399
+ }
400
+ const cycle = async () => {
401
+ try {
402
+ let snapshots = await fetchAllPrices();
403
+ if (opts.symbol) {
404
+ const sym = opts.symbol.toUpperCase();
405
+ snapshots = snapshots.filter((s) => s.symbol.includes(sym));
406
+ }
407
+ process.stdout.write("\x1B[2J\x1B[0f");
408
+ const now = new Date().toLocaleTimeString();
409
+ console.log(chalk.cyan.bold(` Price Gap Monitor`) +
410
+ chalk.gray(` ${now} (refresh: ${opts.interval}s)\n`));
411
+ printGapTable(snapshots, minGap);
412
+ const bigGaps = snapshots.filter((s) => s.maxGapPct >= 0.5);
413
+ if (bigGaps.length > 0) {
414
+ console.log(chalk.red.bold(` !! ${bigGaps.length} large gap(s): ` +
415
+ bigGaps
416
+ .map((s) => `${s.symbol}(${s.maxGapPct.toFixed(3)}%)`)
417
+ .join(", ")));
418
+ if (beep)
419
+ process.stdout.write("\x07");
420
+ }
421
+ }
422
+ catch (err) {
423
+ console.error(chalk.gray(` Error: ${err instanceof Error ? err.message : String(err)}`));
424
+ }
425
+ };
426
+ await cycle();
427
+ setInterval(cycle, intervalMs);
428
+ await new Promise(() => { }); // keep alive
429
+ });
430
+ // ── arb track (was arb gap track) ──
431
+ arb
432
+ .command("track")
433
+ .description("Track a symbol's price gap over time and print stats")
434
+ .requiredOption("-s, --symbol <sym>", "Symbol to track (e.g. BTC)")
435
+ .option("--duration <minutes>", "How long to track (minutes)", "10")
436
+ .option("--interval <seconds>", "Sample interval", "10")
437
+ .action(async (opts) => {
438
+ const symbol = opts.symbol.toUpperCase();
439
+ const durationMs = parseInt(opts.duration) * 60 * 1000;
440
+ const intervalMs = parseInt(opts.interval) * 1000;
441
+ const endTime = Date.now() + durationMs;
442
+ const samples = [];
443
+ if (!isJson()) {
444
+ console.log(chalk.cyan.bold(`\n Tracking ${symbol} price gap\n`));
445
+ console.log(` Duration: ${opts.duration} min`);
446
+ console.log(` Interval: ${opts.interval}s`);
447
+ console.log(chalk.gray(" Collecting samples...\n"));
448
+ }
449
+ const sample = async () => {
450
+ const snapshots = await fetchAllPrices();
451
+ const s = snapshots.find((x) => x.symbol === symbol);
452
+ if (!s) {
453
+ console.log(chalk.gray(` ${new Date().toLocaleTimeString()} — ${symbol} not found on both exchanges`));
454
+ return;
455
+ }
456
+ samples.push({
457
+ time: new Date().toLocaleTimeString(),
458
+ gap: s.maxGap,
459
+ gapPct: s.maxGapPct,
460
+ direction: `${s.cheapest}→${s.expensive}`,
461
+ });
462
+ const gapColor = s.maxGapPct >= 0.1 ? chalk.yellow : chalk.gray;
463
+ const parts = [`PAC: ${s.pacPrice !== null ? "$" + formatGapPrice(s.pacPrice) : "-"}`];
464
+ parts.push(`HL: ${s.hlPrice !== null ? "$" + formatGapPrice(s.hlPrice) : "-"}`);
465
+ parts.push(`LT: ${s.ltPrice !== null ? "$" + formatGapPrice(s.ltPrice) : "-"}`);
466
+ console.log(` ${chalk.white(s.symbol.padEnd(6))} ` +
467
+ `${parts.join(" ")} ` +
468
+ `${gapColor(`${s.maxGapPct.toFixed(4)}%`)} ${s.cheapest}→${s.expensive}`);
469
+ };
470
+ await sample();
471
+ const timer = setInterval(async () => {
472
+ if (Date.now() >= endTime) {
473
+ clearInterval(timer);
474
+ printTrackSummary(symbol, samples);
475
+ return;
476
+ }
477
+ await sample();
478
+ }, intervalMs);
479
+ await new Promise((resolve) => {
480
+ setTimeout(resolve, durationMs + 1000);
481
+ });
482
+ });
483
+ // ── arb alert (was arb gap alert) ──
484
+ arb
485
+ .command("alert")
486
+ .description("Wait until a symbol's price gap exceeds a threshold, then exit")
487
+ .requiredOption("-s, --symbol <sym>", "Symbol to watch")
488
+ .requiredOption("--above <pct>", "Gap % threshold to trigger")
489
+ .option("--interval <seconds>", "Check interval", "5")
490
+ .action(async (opts) => {
491
+ const symbol = opts.symbol.toUpperCase();
492
+ const threshold = parseFloat(opts.above);
493
+ const intervalMs = parseInt(opts.interval) * 1000;
494
+ if (!isJson())
495
+ console.log(chalk.cyan(`\n Waiting for ${symbol} gap > ${threshold}%...\n`));
496
+ const check = async () => {
497
+ const snapshots = await fetchAllPrices();
498
+ const s = snapshots.find((x) => x.symbol === symbol);
499
+ if (!s)
500
+ return false;
501
+ const now = new Date().toLocaleTimeString();
502
+ if (!isJson())
503
+ console.log(chalk.gray(` ${now} ${symbol} gap: ${s.maxGapPct.toFixed(4)}%`));
504
+ if (s.maxGapPct >= threshold) {
505
+ if (isJson()) {
506
+ printJson(jsonOk(s));
507
+ }
508
+ else {
509
+ console.log(chalk.green.bold(`\n TRIGGERED! ${symbol} gap: ${s.maxGapPct.toFixed(4)}% (> ${threshold}%)`));
510
+ const lines = [];
511
+ if (s.pacPrice !== null)
512
+ lines.push(` Pacifica: $${formatGapPrice(s.pacPrice)}`);
513
+ if (s.hlPrice !== null)
514
+ lines.push(` Hyperliquid: $${formatGapPrice(s.hlPrice)}`);
515
+ if (s.ltPrice !== null)
516
+ lines.push(` Lighter: $${formatGapPrice(s.ltPrice)}`);
517
+ lines.push(` Gap: $${s.maxGap.toFixed(4)} (${s.cheapest}→${s.expensive})`);
518
+ console.log(lines.join("\n") + "\n");
519
+ }
520
+ return true;
521
+ }
522
+ return false;
523
+ };
524
+ if (await check())
525
+ return;
526
+ await new Promise((resolve) => {
527
+ const timer = setInterval(async () => {
528
+ if (await check()) {
529
+ clearInterval(timer);
530
+ resolve();
531
+ }
532
+ }, intervalMs);
533
+ });
534
+ });
535
+ }
536
+ // registerGapSubcommands removed — use arb prices/watch/track/alert directly
537
+ // ────────────────────────────────────────────────────────
538
+ // Funding subcommands (merged from funding.ts)
539
+ // ────────────────────────────────────────────────────────
540
+ const ALL_EXCHANGES = ["hyperliquid", "pacifica", "lighter"];
541
+ function formatAvgRate(rate) {
542
+ if (rate == null)
543
+ return chalk.gray("-");
544
+ const annualPct = annualizeHourlyRate(rate);
545
+ const color = annualPct > 0 ? chalk.red : annualPct < 0 ? chalk.green : chalk.white;
546
+ return color(`${annualPct.toFixed(1)}%`);
547
+ }
548
+ function printSnapshotTable(snapshot) {
549
+ const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
550
+ if (snapshot.symbols.length === 0) {
551
+ console.log(chalk.gray(" No funding rate data available.\n"));
552
+ return;
553
+ }
554
+ const rows = snapshot.symbols.map(s => {
555
+ const pacRate = s.rates.find(r => r.exchange === "pacifica");
556
+ const hlRate = s.rates.find(r => r.exchange === "hyperliquid");
557
+ const ltRate = s.rates.find(r => r.exchange === "lighter");
558
+ const spreadColor = s.maxSpreadAnnual >= 30 ? chalk.green : s.maxSpreadAnnual >= 10 ? chalk.yellow : chalk.white;
559
+ const bestRate = hlRate ?? pacRate ?? ltRate;
560
+ return [
561
+ chalk.white.bold(s.symbol), pacRate ? formatPercent(pacRate.fundingRate) : chalk.gray("-"),
562
+ hlRate ? formatPercent(hlRate.fundingRate) : chalk.gray("-"), ltRate ? formatPercent(ltRate.fundingRate) : chalk.gray("-"),
563
+ spreadColor(`${s.maxSpreadAnnual.toFixed(1)}%`),
564
+ s.maxSpreadAnnual >= 5 ? `${exAbbr(s.shortExchange)}>${exAbbr(s.longExchange)}` : chalk.gray("-"),
565
+ formatAvgRate(bestRate?.historicalAvg?.avg8h), formatAvgRate(bestRate?.historicalAvg?.avg24h), formatAvgRate(bestRate?.historicalAvg?.avg7d),
566
+ ];
567
+ });
568
+ console.log(makeTable(["Symbol", "Pacifica", "Hyperliquid", "Lighter", "Ann. Spread", "Direction", "Avg 8h", "Avg 24h", "Avg 7d"], rows));
569
+ console.log(chalk.gray(`\n ${snapshot.symbols.length} symbols compared across exchanges.\n`));
570
+ }
571
+ function printFundingExchangeStatus(snapshot) {
572
+ const statuses = Object.entries(snapshot.exchangeStatus).map(([ex, status]) => {
573
+ const indicator = status === "ok" ? chalk.green("OK") : chalk.red("ERR");
574
+ return `${ex}: ${indicator}`;
575
+ });
576
+ console.log(chalk.gray(` Exchange status: ${statuses.join(" ")}\n`));
577
+ }
578
+ function printDetailedComparison(comparison) {
579
+ const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
580
+ console.log(chalk.cyan.bold(` ${comparison.symbol} Funding Rate Comparison\n`));
581
+ console.log(` Mark Price: $${formatUsd(comparison.bestMarkPrice)}\n`);
582
+ for (const r of comparison.rates) {
583
+ const compHours = getExchangeCompoundingHours(r.exchange);
584
+ const compoundedReturn = getCompoundedAnnualReturn(r.hourlyRate, compHours);
585
+ const color = r.fundingRate > 0 ? chalk.red : r.fundingRate < 0 ? chalk.green : chalk.white;
586
+ console.log(` ${chalk.white.bold(exAbbr(r.exchange).padEnd(4))} Raw: ${color(formatPercent(r.fundingRate).padEnd(14))} Hourly: ${(r.hourlyRate * 100).toFixed(6)}% Annual: ${r.annualizedPct.toFixed(2)}% APY: ${(compoundedReturn * 100).toFixed(2)}%`);
587
+ }
588
+ const spreadColor = comparison.maxSpreadAnnual >= 30 ? chalk.green.bold : comparison.maxSpreadAnnual >= 10 ? chalk.yellow : chalk.white;
589
+ console.log(`\n Max Spread: ${spreadColor(`${comparison.maxSpreadAnnual.toFixed(1)}%`)} annual`);
590
+ console.log(` Direction: Long ${exAbbr(comparison.longExchange)} / Short ${exAbbr(comparison.shortExchange)}`);
591
+ console.log(` Est. Income: $${comparison.estHourlyIncomeUsd.toFixed(4)}/hr per $1K notional\n`);
592
+ }
593
+ function registerFundingSubcommands(parent, isJson, getAdapterForExchange) {
594
+ parent.command("rates").description("Show funding rates across all 3 DEXs for top symbols")
595
+ .option("-s, --symbol <symbol>", "Filter to a specific symbol").option("--symbols <list>", "Comma-separated list of symbols")
596
+ .option("--all", "Show all available symbols").option("--min-spread <pct>", "Minimum annual spread % to show", "0")
597
+ .action(async (opts) => {
598
+ const minSpread = parseFloat(opts.minSpread);
599
+ let filterSymbols;
600
+ if (opts.symbol)
601
+ filterSymbols = [opts.symbol.toUpperCase()];
602
+ else if (opts.symbols)
603
+ filterSymbols = opts.symbols.split(",").map(s => s.trim().toUpperCase());
604
+ else if (!opts.all)
605
+ filterSymbols = TOP_SYMBOLS;
606
+ if (!isJson())
607
+ console.log(chalk.cyan(" Fetching funding rates from all exchanges...\n"));
608
+ const snapshot = await fetchAllFundingRates({ symbols: filterSymbols, minSpread });
609
+ try {
610
+ const allRates = snapshot.symbols.flatMap(s => s.rates);
611
+ if (allRates.length > 0)
612
+ saveFundingSnapshot(allRates);
613
+ }
614
+ catch { /* non-critical */ }
615
+ if (isJson()) {
616
+ if (process.argv.includes("--ndjson") || process.argv.includes("--fields"))
617
+ return printJson(jsonOk(snapshot.symbols));
618
+ return printJson(jsonOk(snapshot));
619
+ }
620
+ printSnapshotTable(snapshot);
621
+ printFundingExchangeStatus(snapshot);
622
+ });
623
+ parent.command("compare <symbol>").description("Detailed funding rate comparison for a single symbol")
624
+ .action(async (symbol) => {
625
+ if (!isJson())
626
+ console.log(chalk.cyan(` Fetching funding rates for ${symbol.toUpperCase()}...\n`));
627
+ const comparison = await fetchSymbolFundingRates(symbol);
628
+ if (!comparison) {
629
+ if (isJson())
630
+ return printJson(jsonOk({ symbol: symbol.toUpperCase(), available: false }));
631
+ console.log(chalk.gray(` ${symbol.toUpperCase()} not found on at least 2 exchanges.\n`));
632
+ return;
633
+ }
634
+ if (isJson())
635
+ return printJson(jsonOk(comparison));
636
+ printDetailedComparison(comparison);
637
+ });
638
+ parent.command("funding-history").description("Show funding rate trend over time for a symbol")
639
+ .requiredOption("-s, --symbol <symbol>", "Symbol").option("--hours <n>", "Hours to look back", "24").option("--exchange <ex>", "Filter exchange")
640
+ .action(async (opts) => {
641
+ const symbol = opts.symbol.toUpperCase();
642
+ const hours = parseInt(opts.hours);
643
+ const exchanges = opts.exchange ? [opts.exchange.toLowerCase()] : ["hyperliquid", "pacifica", "lighter"];
644
+ const endTime = new Date();
645
+ const startTime = new Date(endTime.getTime() - hours * 3600000);
646
+ if (isJson()) {
647
+ const data = {};
648
+ for (const ex of exchanges) {
649
+ const rates = getHistoricalRates(symbol, ex, startTime, endTime);
650
+ if (rates.length > 0)
651
+ data[ex] = rates;
652
+ }
653
+ return printJson(jsonOk({ symbol, hours, startTime: startTime.toISOString(), endTime: endTime.toISOString(), rates: data }));
654
+ }
655
+ console.log(chalk.cyan.bold(` ${symbol} Funding Rate History (last ${hours}h)\n`));
656
+ const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
657
+ let hasData = false;
658
+ for (const ex of exchanges) {
659
+ const rates = getHistoricalRates(symbol, ex, startTime, endTime);
660
+ if (rates.length === 0)
661
+ continue;
662
+ hasData = true;
663
+ console.log(chalk.white.bold(` ${exAbbr(ex)} (${rates.length} snapshots):`));
664
+ const rows = rates.map(r => { const color = r.rate > 0 ? chalk.red : r.rate < 0 ? chalk.green : chalk.white; return [chalk.gray(new Date(r.ts).toLocaleString()), color(formatPercent(r.rate)), `${(r.hourlyRate * 100).toFixed(6)}%/h`, `${annualizeHourlyRate(r.hourlyRate).toFixed(2)}%/yr`]; });
665
+ console.log(makeTable(["Time", "Raw Rate", "Hourly", "Annualized"], rows));
666
+ console.log();
667
+ }
668
+ if (!hasData)
669
+ console.log(chalk.gray(` No historical data. Run 'perp arb rates' to start collecting.\n`));
670
+ });
671
+ // funding-monitor removed — use 'perp arb monitor' (more features, includes liquidity data)
672
+ parent.command("funding-positions").description("Show funding rate impact on your current positions")
673
+ .option("--exchanges <list>", "Comma-separated exchanges")
674
+ .action(async (opts) => {
675
+ if (!getAdapterForExchange) {
676
+ if (isJson())
677
+ return printJson(jsonError("INTERNAL", "requires adapter"));
678
+ console.log(chalk.red(" requires adapter access\n"));
679
+ return;
680
+ }
681
+ await withJsonErrors(isJson(), async () => {
682
+ const targetExchanges = opts.exchanges ? opts.exchanges.split(",").map(s => s.trim().toLowerCase()) : [...ALL_EXCHANGES];
683
+ if (!isJson())
684
+ console.log(chalk.cyan(" Fetching positions and funding rates...\n"));
685
+ const results = [];
686
+ const exchangeErrors = {};
687
+ await Promise.all(targetExchanges.map(async (exchange) => {
688
+ try {
689
+ const adapter = await getAdapterForExchange(exchange);
690
+ const [positions, markets, fp] = await Promise.all([adapter.getPositions(), adapter.getMarkets(), adapter.getFundingPayments(100).catch(() => [])]);
691
+ if (positions.length === 0)
692
+ return;
693
+ const fm = new Map();
694
+ for (const m of markets) {
695
+ const sym = m.symbol.toUpperCase().replace(/-PERP$/, "");
696
+ fm.set(sym, { rate: Number(m.fundingRate) || 0, markPrice: Number(m.markPrice) || 0 });
697
+ }
698
+ const dayAgo = Date.now() - 86400000;
699
+ const af = new Map();
700
+ for (const f of fp) {
701
+ if (f.time < dayAgo)
702
+ continue;
703
+ const sym = f.symbol.toUpperCase().replace(/-PERP$/, "");
704
+ const amt = Number(f.payment) || 0;
705
+ if (!af.has(sym))
706
+ af.set(sym, { received: 0, paid: 0 });
707
+ const e = af.get(sym);
708
+ if (amt > 0)
709
+ e.received += amt;
710
+ else
711
+ e.paid += Math.abs(amt);
712
+ }
713
+ for (const pos of positions) {
714
+ const sym = pos.symbol.toUpperCase().replace(/-PERP$/, "");
715
+ const fd = fm.get(sym);
716
+ const fr = fd?.rate ?? 0;
717
+ const mk = Number(pos.markPrice) || fd?.markPrice || 0;
718
+ const sz = Math.abs(Number(pos.size));
719
+ const nu = sz * mk;
720
+ const hr = toHourlyRate(fr, exchange);
721
+ const ap = annualizeRate(fr, exchange);
722
+ const hp = estimateHourlyFunding(fr, exchange, nu, pos.side);
723
+ const a = af.get(sym);
724
+ results.push({ exchange, symbol: sym, side: pos.side, size: pos.size, entryPrice: pos.entryPrice, markPrice: pos.markPrice, unrealizedPnl: pos.unrealizedPnl, leverage: pos.leverage, notionalUsd: nu, fundingRate: fr, hourlyRate: hr, annualPct: ap, hourlyPayment: hp, dailyPayment: hp * 24, actualReceived24h: a?.received ?? 0, actualPaid24h: a?.paid ?? 0, actualNet24h: (a?.received ?? 0) - (a?.paid ?? 0) });
725
+ }
726
+ }
727
+ catch (err) {
728
+ exchangeErrors[exchange] = err instanceof Error ? err.message : String(err);
729
+ }
730
+ }));
731
+ if (results.length === 0) {
732
+ if (isJson())
733
+ return printJson(jsonOk({ positions: [], totals: { predicted: { hourly: 0, daily: 0 }, actual24h: { net: 0 }, notionalUsd: 0 }, errors: exchangeErrors }));
734
+ console.log(chalk.gray(" No open positions.\n"));
735
+ return;
736
+ }
737
+ results.sort((a, b) => Math.abs(b.dailyPayment) - Math.abs(a.dailyPayment));
738
+ const tH = results.reduce((s, r) => s + r.hourlyPayment, 0);
739
+ const tD = results.reduce((s, r) => s + r.dailyPayment, 0);
740
+ const tN = results.reduce((s, r) => s + r.notionalUsd, 0);
741
+ const tA = results.reduce((s, r) => s + r.actualNet24h, 0);
742
+ if (isJson())
743
+ return printJson(jsonOk({ positions: results.map(r => ({ exchange: r.exchange, symbol: r.symbol, side: r.side, size: r.size, notionalUsd: +r.notionalUsd.toFixed(2), fundingRate: r.fundingRate, annualPct: +r.annualPct.toFixed(2), predicted: { hourly: +r.hourlyPayment.toFixed(6), daily: +r.dailyPayment.toFixed(4) }, actual24h: { received: +r.actualReceived24h.toFixed(6), paid: +r.actualPaid24h.toFixed(6), net: +r.actualNet24h.toFixed(6) }, unrealizedPnl: r.unrealizedPnl })), totals: { predicted: { hourly: +tH.toFixed(6), daily: +tD.toFixed(4) }, actual24h: { net: +tA.toFixed(6) }, notionalUsd: +tN.toFixed(2) }, errors: Object.keys(exchangeErrors).length > 0 ? exchangeErrors : undefined }));
744
+ const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : e === "lighter" ? "LT" : e.toUpperCase();
745
+ console.log(chalk.cyan.bold(" Position Funding Impact\n"));
746
+ const rows = results.map(r => { const sc = r.side === "long" ? chalk.green : chalk.red; const rc = r.fundingRate > 0 ? chalk.red : r.fundingRate < 0 ? chalk.green : chalk.white; const as = r.actualNet24h !== 0 ? (r.actualNet24h > 0 ? chalk.green : chalk.red)(`${r.actualNet24h > 0 ? "+" : ""}$${r.actualNet24h.toFixed(4)}`) : chalk.gray("-"); return [chalk.white.bold(exAbbr(r.exchange)), chalk.white.bold(r.symbol), sc(r.side.toUpperCase()), `$${formatUsd(r.notionalUsd)}`, rc(formatPercent(r.fundingRate)), rc(`${r.annualPct.toFixed(1)}%`), as, formatPnl(r.unrealizedPnl)]; });
747
+ console.log(makeTable(["Ex", "Symbol", "Side", "Notional", "Rate(now)", "Annual(now)", "Actual 24h", "uPnL"], rows));
748
+ console.log(`\n Total: $${formatUsd(tN)} | Actual 24h: ${tA >= 0 ? "+" : ""}$${tA.toFixed(4)} | Predicted: ${tD >= 0 ? "-" : "+"}$${Math.abs(tD).toFixed(4)}/d\n`);
749
+ });
750
+ });
347
751
  }