perp-cli 0.4.0 → 0.4.8

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 (79) hide show
  1. package/README.md +2 -0
  2. package/dist/arb/sizing.d.ts +12 -1
  3. package/dist/arb/sizing.js +38 -5
  4. package/dist/arb/state.d.ts +6 -0
  5. package/dist/arb/utils.d.ts +2 -0
  6. package/dist/arb/utils.js +1 -1
  7. package/dist/cli-spec.d.ts +0 -4
  8. package/dist/cli-spec.js +8 -1
  9. package/dist/commands/agent.js +12 -9
  10. package/dist/commands/arb/index.js +18 -3
  11. package/dist/commands/arb-auto.js +885 -67
  12. package/dist/commands/dex.js +7 -1
  13. package/dist/commands/funding.d.ts +2 -1
  14. package/dist/commands/funding.js +211 -4
  15. package/dist/commands/manage.js +1 -1
  16. package/dist/commands/market.js +1 -1
  17. package/dist/commands/multileg.d.ts +16 -0
  18. package/dist/commands/multileg.js +174 -0
  19. package/dist/commands/pnl.d.ts +12 -0
  20. package/dist/commands/pnl.js +243 -0
  21. package/dist/commands/settings.d.ts +2 -1
  22. package/dist/commands/settings.js +133 -2
  23. package/dist/commands/stream.js +132 -55
  24. package/dist/commands/trade.js +118 -43
  25. package/dist/commands/wallet.js +1 -1
  26. package/dist/config.d.ts +5 -0
  27. package/dist/config.js +12 -0
  28. package/dist/constants.d.ts +14 -0
  29. package/dist/constants.js +40 -0
  30. package/dist/dashboard/ws-feeds.js +1 -1
  31. package/dist/equity-tracker.d.ts +57 -0
  32. package/dist/equity-tracker.js +209 -0
  33. package/dist/exchanges/hyperliquid-spot.d.ts +68 -0
  34. package/dist/exchanges/hyperliquid-spot.js +317 -0
  35. package/dist/exchanges/hyperliquid.d.ts +26 -5
  36. package/dist/exchanges/hyperliquid.js +181 -92
  37. package/dist/exchanges/interface.d.ts +1 -1
  38. package/dist/exchanges/lighter-spot.d.ts +55 -0
  39. package/dist/exchanges/lighter-spot.js +417 -0
  40. package/dist/exchanges/lighter.d.ts +10 -2
  41. package/dist/exchanges/lighter.js +132 -37
  42. package/dist/exchanges/pacifica.d.ts +11 -4
  43. package/dist/exchanges/pacifica.js +52 -37
  44. package/dist/exchanges/spot-interface.d.ts +45 -0
  45. package/dist/exchanges/spot-interface.js +22 -0
  46. package/dist/execution-log.d.ts +2 -2
  47. package/dist/funding/rates.d.ts +36 -0
  48. package/dist/funding/rates.js +146 -0
  49. package/dist/index.js +148 -99
  50. package/dist/liquidity.d.ts +12 -0
  51. package/dist/liquidity.js +43 -0
  52. package/dist/settings.d.ts +6 -0
  53. package/dist/settings.js +18 -2
  54. package/dist/signer/evm-local.d.ts +12 -0
  55. package/dist/signer/evm-local.js +19 -0
  56. package/dist/signer/index.d.ts +3 -0
  57. package/dist/signer/index.js +2 -0
  58. package/dist/signer/interface.d.ts +31 -0
  59. package/dist/signer/interface.js +7 -0
  60. package/dist/signer/solana-local.d.ts +13 -0
  61. package/dist/signer/solana-local.js +19 -0
  62. package/dist/smart-order.d.ts +44 -0
  63. package/dist/smart-order.js +145 -0
  64. package/dist/trade-validator.js +2 -1
  65. package/dist/utils.js +7 -1
  66. package/dist/ws/market-feed.d.ts +74 -0
  67. package/dist/ws/market-feed.js +227 -0
  68. package/package.json +1 -1
  69. package/skills/perp-cli/SKILL.md +102 -4
  70. package/skills/perp-cli/references/commands.md +16 -1
  71. package/skills/perp-cli/references/strategies.md +1 -1
  72. package/skills/perp-cli/scripts/arb-monitor.sh +74 -0
  73. package/skills/perp-cli/scripts/funding-analysis.sh +85 -0
  74. package/skills/perp-cli/scripts/preflight.sh +98 -0
  75. package/skills/perp-cli/scripts/spot-perp-scan.sh +83 -0
  76. package/skills/perp-cli/scripts/validate-arb.sh +112 -0
  77. package/skills/perp-cli/templates/arb-scan.sh +0 -15
  78. package/skills/perp-cli/templates/open-position.sh +0 -38
  79. package/skills/perp-cli/templates/wallet-setup.sh +0 -17
@@ -5,19 +5,12 @@ import { computeAnnualSpread, toHourlyRate } from "../funding.js";
5
5
  import { fetchPacificaPricesRaw, parsePacificaRaw, fetchHyperliquidMetaRaw, parseHyperliquidMetaRaw, fetchLighterOrderBookDetailsRaw, fetchLighterFundingRatesRaw, parseLighterRaw, } from "../shared-api.js";
6
6
  import { scanDexArb } from "../dex-asset-map.js";
7
7
  import { logExecution, readExecutionLog } from "../execution-log.js";
8
+ import { smartOrder } from "../smart-order.js";
8
9
  import { aggressiveSettleBoost, estimateFundingUntilSettlement, computeBasisRisk, notifyIfEnabled, } from "../arb-utils.js";
9
10
  import { checkChainMargins, isCriticalMargin, shouldBlockEntries, computeAutoSize, } from "../cross-chain-margin.js";
10
- import { loadArbState, saveArbState, createInitialState, } from "../arb-state.js";
11
+ import { loadArbState, saveArbState, addPosition as persistAddPosition, removePosition as persistRemovePosition, createInitialState, } from "../arb-state.js";
11
12
  // ── Fee-Adjusted Net Spread Calculation ──
12
- /** Default taker fee per exchange (as fraction, e.g. 0.00035 = 0.035%) */
13
- const TAKER_FEES = {
14
- hyperliquid: 0.00035,
15
- pacifica: 0.00035,
16
- lighter: 0.00035,
17
- };
18
- function getTakerFee(exchange) {
19
- return TAKER_FEES[exchange.toLowerCase()] ?? 0.00035;
20
- }
13
+ import { getTakerFee } from "../constants.js";
21
14
  /**
22
15
  * Compute the estimated round-trip cost as a percentage of notional.
23
16
  * Round-trip = 4 × taker fee + 2 × slippage (entry + exit for both legs).
@@ -50,12 +43,7 @@ export function computeNetSpread(grossAnnualPct, holdDays, roundTripCostPct, bri
50
43
  return grossAnnualPct - annualizedCostPct - bridgeCostAnnualPct;
51
44
  }
52
45
  // ── Funding Settlement Timing ──
53
- /** Settlement schedules per exchange (UTC hours when settlement occurs) */
54
- const SETTLEMENT_SCHEDULES = {
55
- hyperliquid: Array.from({ length: 24 }, (_, i) => i), // every hour
56
- pacifica: Array.from({ length: 24 }, (_, i) => i), // every hour
57
- lighter: Array.from({ length: 24 }, (_, i) => i), // every hour
58
- };
46
+ import { SETTLEMENT_SCHEDULES } from "../arb-utils.js";
59
47
  /**
60
48
  * Get the next settlement time for an exchange.
61
49
  * @returns Date of next settlement
@@ -188,6 +176,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
188
176
  .option("--max-basis <pct>", "Max basis risk (mark price divergence %)", "3")
189
177
  .option("--notify <url>", "Webhook URL for notifications (Discord/Telegram/generic)")
190
178
  .option("--notify-events <events>", "Comma-separated events: entry,exit,reversal,margin,basis", "entry,exit,reversal,margin,basis")
179
+ .option("--cooldown <minutes>", "Minutes to wait before re-entering a closed symbol", "30")
191
180
  .option("--dry-run", "Simulate without executing trades")
192
181
  .option("--background", "Run in background (tmux)")
193
182
  .action(async (opts) => {
@@ -202,6 +191,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
202
191
  `--hold-days`, opts.holdDays,
203
192
  `--bridge-cost`, opts.bridgeCost,
204
193
  `--min-margin`, opts.minMargin,
194
+ `--cooldown`, opts.cooldown,
205
195
  ...(opts.symbols ? [`--symbols`, opts.symbols] : []),
206
196
  ...(opts.dryRun ? [`--dry-run`] : []),
207
197
  ...(opts.reversalExit === false ? [`--no-reversal-exit`] : []),
@@ -233,7 +223,8 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
233
223
  const bridgeCostUsd = parseFloat(opts.bridgeCost);
234
224
  const reversalExitEnabled = opts.reversalExit !== false;
235
225
  const settleAwareEnabled = opts.settleAware !== false;
236
- const settleStrategy = (opts.settleStrategy || "block");
226
+ // --no-settle-aware overrides --settle-strategy to "off"
227
+ const settleStrategy = !settleAwareEnabled ? "off" : (opts.settleStrategy || "block");
237
228
  const maxBasisPct = parseFloat(opts.maxBasis);
238
229
  const webhookUrl = opts.notify;
239
230
  const notifyEvents = opts.notifyEvents
@@ -242,9 +233,12 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
242
233
  const minSizeUsd = parseFloat(opts.minSize);
243
234
  const filterSymbols = opts.symbols?.split(",").map(s => s.trim().toUpperCase());
244
235
  const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
236
+ const cooldownMinutes = parseFloat(opts.cooldown);
245
237
  const openPositions = [];
246
238
  // Track which exchanges have low margin (block entries)
247
239
  const blockedExchanges = new Set();
240
+ // Track close times per symbol to prevent close→re-entry loops
241
+ const closeCooldowns = new Map(); // symbol → close timestamp ms
248
242
  // -- State Persistence: Initialize or recover --
249
243
  const daemonConfig = {
250
244
  minSpread,
@@ -263,7 +257,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
263
257
  symbol: persisted.symbol,
264
258
  longExchange: persisted.longExchange,
265
259
  shortExchange: persisted.shortExchange,
266
- size: String(persisted.longSize),
260
+ size: persisted.longSize,
267
261
  entrySpread: persisted.entrySpread,
268
262
  entryTime: persisted.entryTime,
269
263
  entryMarkPrice: persisted.entryLongPrice,
@@ -281,8 +275,11 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
281
275
  daemonState = createInitialState(daemonConfig);
282
276
  saveArbState(daemonState);
283
277
  }
284
- // SIGINT handler: save final state before exit
278
+ // SIGINT handler: clean up interval and save final state before exit
279
+ let cycleInterval = null;
285
280
  const handleSigint = () => {
281
+ if (cycleInterval)
282
+ clearInterval(cycleInterval);
286
283
  const finalState = loadArbState();
287
284
  if (finalState) {
288
285
  finalState.lastScanTime = new Date().toISOString();
@@ -308,12 +305,42 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
308
305
  console.log(` Max basis: ${maxBasisPct}% (warn on price divergence)`);
309
306
  console.log(` Reversal exit: ${reversalExitEnabled ? chalk.green("ON") : chalk.yellow("OFF")}`);
310
307
  console.log(` Settle strat: ${settleStrategy === "aggressive" ? chalk.cyan("AGGRESSIVE") : settleStrategy === "off" ? chalk.yellow("OFF") : chalk.green("BLOCK")}`);
308
+ console.log(` Cooldown: ${cooldownMinutes}m (re-entry delay after close)`);
311
309
  console.log(` Notifications: ${webhookUrl ? chalk.green("ON") : chalk.gray("OFF")}${webhookUrl ? ` (${notifyEvents.join(",")})` : ""}`);
312
310
  console.log(` Symbols: ${filterSymbols?.join(", ") || "all"}`);
313
311
  console.log(` Interval: ${opts.interval}s`);
314
312
  console.log(chalk.gray("\n Monitoring... (Ctrl+C to stop)\n"));
315
313
  }
316
314
  const cycle = async () => {
315
+ // Sync in-memory positions with persisted state (handles CLI manual closes)
316
+ const syncState = loadArbState();
317
+ if (syncState) {
318
+ const persistedSymbols = new Set(syncState.positions.map(p => p.symbol));
319
+ // Remove positions that were closed externally (e.g. via `perp arb close`)
320
+ for (let i = openPositions.length - 1; i >= 0; i--) {
321
+ if (!persistedSymbols.has(openPositions[i].symbol)) {
322
+ console.log(chalk.gray(` ${new Date().toLocaleTimeString()} SYNC: ${openPositions[i].symbol} removed (closed externally)`));
323
+ openPositions.splice(i, 1);
324
+ }
325
+ }
326
+ // Recover positions that exist in state but not in memory (e.g. added by another process)
327
+ for (const persisted of syncState.positions) {
328
+ if (!openPositions.some(p => p.symbol === persisted.symbol)) {
329
+ console.log(chalk.gray(` ${new Date().toLocaleTimeString()} SYNC: recovering ${persisted.symbol} from state`));
330
+ openPositions.push({
331
+ symbol: persisted.symbol,
332
+ longExchange: persisted.longExchange,
333
+ shortExchange: persisted.shortExchange,
334
+ size: persisted.longSize,
335
+ entrySpread: persisted.entrySpread,
336
+ entryTime: persisted.entryTime,
337
+ entryMarkPrice: persisted.entryLongPrice,
338
+ accumulatedFundingUsd: persisted.accumulatedFunding,
339
+ lastCheckTime: new Date(persisted.lastCheckTime).getTime(),
340
+ });
341
+ }
342
+ }
343
+ }
317
344
  // Heartbeat check
318
345
  const heartbeatState = loadArbState();
319
346
  if (heartbeatState?.lastSuccessfulScanTime) {
@@ -375,45 +402,120 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
375
402
  }
376
403
  if (shouldClose) {
377
404
  console.log(chalk.yellow(` ${now} CLOSE ${pos.symbol} — ${closeReason}`));
405
+ const exitReason = closeReason.includes("REVERSAL") ? "reversal"
406
+ : closeReason.includes("spread") ? "spread"
407
+ : "manual";
378
408
  if (!dryRun) {
379
409
  try {
380
- // Close both legs
381
410
  const longAdapter = await getAdapterForExchange(pos.longExchange);
382
411
  const shortAdapter = await getAdapterForExchange(pos.shortExchange);
383
- await longAdapter.marketOrder(pos.symbol, "sell", pos.size);
384
- await shortAdapter.marketOrder(pos.symbol, "buy", pos.size);
385
- // Determine exit reason tag
386
- const exitReason = closeReason.includes("REVERSAL") ? "reversal"
387
- : closeReason.includes("spread") ? "spread"
388
- : "manual";
389
- logExecution({
390
- type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
391
- symbol: pos.symbol, side: "close", size: pos.size,
392
- status: "success", dryRun: false,
393
- meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, currentSpread, reason: closeReason, exitReason },
394
- });
395
- console.log(chalk.green(` ${now} CLOSED ${pos.symbol} — both legs`));
396
- await notifyIfEnabled(webhookUrl, notifyEvents, "exit", {
397
- symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
398
- pnl: pos.accumulatedFundingUsd,
399
- duration: `${Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 3600000)}h`,
400
- });
412
+ // Close both legs simultaneously with verification
413
+ const [longResult, shortResult] = await Promise.allSettled([
414
+ longAdapter.marketOrder(pos.symbol, "sell", String(pos.size)),
415
+ shortAdapter.marketOrder(pos.symbol, "buy", String(pos.size)),
416
+ ]);
417
+ const longOk = longResult.status === "fulfilled";
418
+ const shortOk = shortResult.status === "fulfilled";
419
+ if (longOk && shortOk) {
420
+ logExecution({
421
+ type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
422
+ symbol: pos.symbol, side: "close", size: String(pos.size),
423
+ status: "success", dryRun: false,
424
+ meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, currentSpread, reason: closeReason, exitReason },
425
+ });
426
+ console.log(chalk.green(` ${now} CLOSED ${pos.symbol} — both legs`));
427
+ persistRemovePosition(pos.symbol);
428
+ closeCooldowns.set(pos.symbol, Date.now());
429
+ openPositions.splice(i, 1);
430
+ await notifyIfEnabled(webhookUrl, notifyEvents, "exit", {
431
+ symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
432
+ pnl: pos.accumulatedFundingUsd,
433
+ duration: `${Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 3600000)}h`,
434
+ });
435
+ }
436
+ else if (longOk !== shortOk) {
437
+ // One leg closed, one failed — retry the failed leg
438
+ const failedSide = longOk ? "short" : "long";
439
+ const failedAdapter = longOk ? shortAdapter : longAdapter;
440
+ const retryAction = longOk ? "buy" : "sell";
441
+ const failedErr = longOk
442
+ ? shortResult.reason
443
+ : longResult.reason;
444
+ console.log(chalk.yellow(` ${now} PARTIAL CLOSE ${pos.symbol}: ${failedSide} leg failed — retrying...`));
445
+ let retryOk = false;
446
+ for (let attempt = 1; attempt <= 2; attempt++) {
447
+ try {
448
+ await failedAdapter.marketOrder(pos.symbol, retryAction, String(pos.size));
449
+ retryOk = true;
450
+ console.log(chalk.green(` ${now} RETRY OK ${pos.symbol}: ${failedSide} leg closed (attempt ${attempt})`));
451
+ break;
452
+ }
453
+ catch (retryErr) {
454
+ console.log(chalk.red(` ${now} RETRY ${attempt}/2 FAILED ${pos.symbol}: ${retryErr instanceof Error ? retryErr.message : retryErr}`));
455
+ }
456
+ }
457
+ if (retryOk) {
458
+ logExecution({
459
+ type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
460
+ symbol: pos.symbol, side: "close", size: String(pos.size),
461
+ status: "success", dryRun: false,
462
+ meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, currentSpread, reason: closeReason, exitReason, retried: failedSide },
463
+ });
464
+ persistRemovePosition(pos.symbol);
465
+ closeCooldowns.set(pos.symbol, Date.now());
466
+ openPositions.splice(i, 1);
467
+ }
468
+ else {
469
+ // One leg is closed, other still open — log but DON'T remove from tracking
470
+ logExecution({
471
+ type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
472
+ symbol: pos.symbol, side: "close", size: String(pos.size),
473
+ status: "failed", dryRun: false,
474
+ error: `Partial close: ${failedSide} failed (${failedErr instanceof Error ? failedErr.message : String(failedErr)}). Retry failed.`,
475
+ meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, reason: closeReason, exitReason, partialClose: true },
476
+ });
477
+ console.log(chalk.red.bold(` ${now} PARTIAL CLOSE ${pos.symbol}: ${failedSide} still open — MANUAL CLOSE REQUIRED`));
478
+ await notifyIfEnabled(webhookUrl, notifyEvents, "margin", {
479
+ exchange: longOk ? pos.shortExchange : pos.longExchange,
480
+ marginPct: 0, threshold: 0, symbol: pos.symbol,
481
+ message: `PARTIAL CLOSE: ${failedSide} leg still open after retry. Manual close required.`,
482
+ });
483
+ // Do NOT splice — keep tracking so next cycle retries
484
+ }
485
+ }
486
+ else {
487
+ // Both legs failed — keep position, retry next cycle
488
+ const longErr = longResult.reason;
489
+ const shortErr = shortResult.reason;
490
+ logExecution({
491
+ type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
492
+ symbol: pos.symbol, side: "close", size: String(pos.size),
493
+ status: "failed", dryRun: false,
494
+ error: `Both legs failed. Long: ${longErr instanceof Error ? longErr.message : String(longErr)}, Short: ${shortErr instanceof Error ? shortErr.message : String(shortErr)}`,
495
+ meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, reason: closeReason, exitReason },
496
+ });
497
+ console.error(chalk.red(` ${now} CLOSE FAILED ${pos.symbol}: both legs — will retry next cycle`));
498
+ // Do NOT splice — retry next cycle
499
+ }
401
500
  }
402
501
  catch (err) {
403
- const exitReason = closeReason.includes("REVERSAL") ? "reversal"
404
- : closeReason.includes("spread") ? "spread"
405
- : "manual";
406
502
  logExecution({
407
503
  type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
408
- symbol: pos.symbol, side: "close", size: pos.size,
504
+ symbol: pos.symbol, side: "close", size: String(pos.size),
409
505
  status: "failed", dryRun: false,
410
506
  error: err instanceof Error ? err.message : String(err),
411
507
  meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, reason: closeReason, exitReason },
412
508
  });
413
509
  console.error(chalk.red(` ${now} CLOSE FAILED ${pos.symbol}: ${err instanceof Error ? err.message : err}`));
510
+ // Do NOT splice — retry next cycle
414
511
  }
415
512
  }
416
- openPositions.splice(i, 1);
513
+ else {
514
+ // Dry run — remove from tracking
515
+ persistRemovePosition(pos.symbol);
516
+ closeCooldowns.set(pos.symbol, Date.now());
517
+ openPositions.splice(i, 1);
518
+ }
417
519
  }
418
520
  }
419
521
  // Log next settlement times and funding estimation
@@ -434,7 +536,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
434
536
  const fRateFor = (e) => e === "pacifica" ? fSnap.pacRate : e === "hyperliquid" ? fSnap.hlRate : fSnap.ltRate;
435
537
  const fHlHourly = toHourlyRate(fRateFor("hyperliquid"), "hyperliquid");
436
538
  const fPacHourly = toHourlyRate(fRateFor("pacifica"), "pacifica");
437
- const fNotional = parseFloat(fPos.size) * fSnap.markPrice;
539
+ const fNotional = fPos.size * fSnap.markPrice;
438
540
  const fEst = estimateFundingUntilSettlement(fHlHourly, fPacHourly, fNotional, hoursUntilPAC);
439
541
  console.log(chalk.gray(` ${now} ${fPos.symbol} Next PAC: ${hUTC}:00 UTC (${hoursUntilPAC.toFixed(1)}h) | ` +
440
542
  `HL cum: ~$${fEst.hlCumulative.toFixed(4)} | PAC pmt: ~$${fEst.pacPayment.toFixed(4)} | ` +
@@ -511,6 +613,17 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
511
613
  continue;
512
614
  if (openPositions.length >= maxPositions)
513
615
  break;
616
+ // Cooldown: skip if recently closed to prevent close→re-entry loops
617
+ const lastCloseMs = closeCooldowns.get(snap.symbol);
618
+ if (lastCloseMs && cooldownMinutes > 0) {
619
+ const elapsedMin = (Date.now() - lastCloseMs) / 60000;
620
+ if (elapsedMin < cooldownMinutes) {
621
+ console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: cooldown ${(cooldownMinutes - elapsedMin).toFixed(0)}m remaining`));
622
+ continue;
623
+ }
624
+ // Cooldown expired, clean up
625
+ closeCooldowns.delete(snap.symbol);
626
+ }
514
627
  const grossSpread = Math.abs(snap.spread);
515
628
  // Determine direction using all 3 exchanges: short the high-funding, long the low-funding
516
629
  const longExchange = snap.longExch;
@@ -526,9 +639,6 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
526
639
  const roundTripCost = computeRoundTripCostPct(longExchange, shortExchange);
527
640
  const effectiveSizeUsd = sizeIsAuto ? 100 : sizeUsd; // Use $100 for net spread calc when auto
528
641
  const netSpread = computeNetSpread(grossSpread, holdDays, roundTripCost, bridgeCostUsd, effectiveSizeUsd);
529
- // --min-spread now compares against NET spread
530
- if (netSpread < minSpread)
531
- continue;
532
642
  // Settlement timing check with strategy
533
643
  if (settleStrategy === "block") {
534
644
  const settlCheck = isNearSettlement(longExchange, shortExchange);
@@ -545,6 +655,10 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
545
655
  console.log(chalk.cyan(` ${now} BOOST ${snap.symbol}: post-settlement ${((settleBoostMultiplier - 1) * 100).toFixed(0)}% score boost`));
546
656
  }
547
657
  }
658
+ // --min-spread compares against NET spread (with settle boost applied)
659
+ const effectiveNetSpread = netSpread * settleBoostMultiplier;
660
+ if (effectiveNetSpread < minSpread)
661
+ continue;
548
662
  const rateForExch = (e) => e === "pacifica" ? snap.pacRate : e === "hyperliquid" ? snap.hlRate : snap.ltRate;
549
663
  console.log(chalk.green(` ${now} ENTER ${snap.symbol} — gross ${grossSpread.toFixed(1)}% net ${netSpread.toFixed(1)}%` +
550
664
  ` | Long ${longExchange} (${(rateForExch(longExchange) * 100).toFixed(4)}%)` +
@@ -597,6 +711,44 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
597
711
  meta: { longExchange, shortExchange, grossSpread, netSpread, roundTripCost, markPrice: snap.markPrice },
598
712
  });
599
713
  console.log(chalk.green(` ${now} FILLED ${snap.symbol} — both legs @ ${sizeInAsset} units ($${actualSizeUsd} / $${snap.markPrice.toFixed(2)})`));
714
+ // Verify actual fill sizes by querying positions
715
+ let verifiedLongSize = sizeInAsset;
716
+ let verifiedShortSize = sizeInAsset;
717
+ try {
718
+ const [longPositions, shortPositions] = await Promise.all([
719
+ longAdapter.getPositions(),
720
+ shortAdapter.getPositions(),
721
+ ]);
722
+ const longPos = longPositions.find(p => p.symbol.toUpperCase().includes(snap.symbol));
723
+ const shortPos = shortPositions.find(p => p.symbol.toUpperCase().includes(snap.symbol));
724
+ if (longPos)
725
+ verifiedLongSize = longPos.size;
726
+ if (shortPos)
727
+ verifiedShortSize = shortPos.size;
728
+ if (verifiedLongSize !== sizeInAsset || verifiedShortSize !== sizeInAsset) {
729
+ console.log(chalk.yellow(` ${now} SIZE MISMATCH ${snap.symbol}: requested ${sizeInAsset}, actual long=${verifiedLongSize} short=${verifiedShortSize}`));
730
+ }
731
+ }
732
+ catch {
733
+ // Position query failed — use requested size as fallback
734
+ console.log(chalk.gray(` ${now} Could not verify fill sizes for ${snap.symbol}, using requested size`));
735
+ }
736
+ // Persist to state file immediately (crash-safe)
737
+ const entryTime = new Date().toISOString();
738
+ persistAddPosition({
739
+ id: `${snap.symbol}-${Date.now()}`,
740
+ symbol: snap.symbol,
741
+ longExchange,
742
+ shortExchange,
743
+ longSize: parseFloat(verifiedLongSize),
744
+ shortSize: parseFloat(verifiedShortSize),
745
+ entryTime,
746
+ entrySpread: grossSpread,
747
+ entryLongPrice: snap.markPrice,
748
+ entryShortPrice: snap.markPrice,
749
+ accumulatedFunding: 0,
750
+ lastCheckTime: entryTime,
751
+ });
600
752
  await notifyIfEnabled(webhookUrl, notifyEvents, "entry", {
601
753
  symbol: snap.symbol, longExchange, shortExchange,
602
754
  size: actualSizeUsd, netSpread,
@@ -672,17 +824,35 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
672
824
  continue;
673
825
  }
674
826
  }
827
+ const posEntryTime = new Date().toISOString();
675
828
  openPositions.push({
676
829
  symbol: snap.symbol,
677
830
  longExchange,
678
831
  shortExchange,
679
- size: sizeInAsset,
832
+ size: parseFloat(sizeInAsset),
680
833
  entrySpread: grossSpread,
681
- entryTime: new Date().toISOString(),
834
+ entryTime: posEntryTime,
682
835
  entryMarkPrice: snap.markPrice,
683
836
  accumulatedFundingUsd: 0,
684
837
  lastCheckTime: Date.now(),
685
838
  });
839
+ // Persist for dry-run mode too (tracks what would have been entered)
840
+ if (dryRun) {
841
+ persistAddPosition({
842
+ id: `${snap.symbol}-${Date.now()}`,
843
+ symbol: snap.symbol,
844
+ longExchange,
845
+ shortExchange,
846
+ longSize: parseFloat(sizeInAsset),
847
+ shortSize: parseFloat(sizeInAsset),
848
+ entryTime: posEntryTime,
849
+ entrySpread: grossSpread,
850
+ entryLongPrice: snap.markPrice,
851
+ entryShortPrice: snap.markPrice,
852
+ accumulatedFunding: 0,
853
+ lastCheckTime: posEntryTime,
854
+ });
855
+ }
686
856
  }
687
857
  }
688
858
  // Accumulate estimated funding income & show status
@@ -693,11 +863,11 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
693
863
  if (!current)
694
864
  continue;
695
865
  const elapsedHours = (nowMs - pos.lastCheckTime) / (1000 * 60 * 60);
696
- const notional = parseFloat(pos.size) * current.markPrice;
697
- // Estimate funding collected: long collects from low-rate side, short from high-rate side
866
+ const notional = pos.size * current.markPrice;
867
+ // Estimate funding collected: normalize all rates to hourly before comparing
698
868
  const rateFor = (e) => e === "pacifica" ? current.pacRate : e === "hyperliquid" ? current.hlRate : current.ltRate;
699
- const longHourly = rateFor(pos.longExchange);
700
- const shortHourly = rateFor(pos.shortExchange);
869
+ const longHourly = toHourlyRate(rateFor(pos.longExchange), pos.longExchange);
870
+ const shortHourly = toHourlyRate(rateFor(pos.shortExchange), pos.shortExchange);
701
871
  // Income = short gets paid positive funding, long pays; net = (shortRate - longRate) * notional * hours
702
872
  const hourlyIncome = (shortHourly - longHourly) * notional;
703
873
  pos.accumulatedFundingUsd += hourlyIncome * elapsedHours;
@@ -722,13 +892,14 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
722
892
  }
723
893
  };
724
894
  await cycle();
725
- setInterval(cycle, intervalMs);
895
+ cycleInterval = setInterval(cycle, intervalMs);
726
896
  await new Promise(() => { }); // keep alive
727
897
  });
728
898
  // ── arb scan ── (one-shot spread scan)
729
899
  arb
730
900
  .command("scan")
731
- .description("Scan current funding rate spreads")
901
+ .description("Scan current funding rate spreads (perp-perp or spot-perp)")
902
+ .option("--mode <mode>", "Scan mode: perp-perp, spot-perp, all", "perp-perp")
732
903
  .option("--min <pct>", "Min annual spread to show", "10")
733
904
  .option("--top <n>", "Return only top N results (for JSON output)")
734
905
  .option("--hold-days <days>", "Expected hold period for cost calc", "7")
@@ -739,8 +910,50 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
739
910
  const holdDays = parseFloat(opts.holdDays);
740
911
  const bridgeCostUsd = parseFloat(opts.bridgeCost);
741
912
  const sizeUsd = parseFloat(opts.size);
913
+ // ── spot-perp mode ──
914
+ if (opts.mode === "spot-perp" || opts.mode === "all") {
915
+ if (!isJson())
916
+ console.log(chalk.cyan("\n Scanning spot+perp funding opportunities...\n"));
917
+ const { fetchSpotPerpSpreads } = await import("../funding/rates.js");
918
+ const { spreads: spotSpreads } = await fetchSpotPerpSpreads({ minSpread });
919
+ if (isJson() && opts.mode === "spot-perp") {
920
+ let result = spotSpreads.map(s => ({
921
+ mode: "spot-perp", symbol: s.symbol, perpExchange: s.perpExchange,
922
+ spotExchanges: s.spotExchanges, annualSpreadPct: s.annualSpreadPct,
923
+ perpFundingRate: s.perpFundingRate, direction: s.direction,
924
+ markPrice: s.bestMarkPrice, estHourlyIncomeUsd: s.estHourlyIncomeUsd,
925
+ }));
926
+ if (opts.top)
927
+ result = result.slice(0, parseInt(opts.top));
928
+ return printJson(jsonOk(result));
929
+ }
930
+ if (!isJson()) {
931
+ if (spotSpreads.length === 0) {
932
+ console.log(chalk.gray(` No spot+perp opportunities above ${minSpread}%\n`));
933
+ }
934
+ else {
935
+ console.log(chalk.gray(` ${"SYMBOL".padEnd(8)} ${"SPREAD".padEnd(8)} ${"PERP EX".padEnd(9)} ${"SPOT EX".padEnd(12)} ${"FUND".padEnd(12)} DIRECTION`));
936
+ for (const s of spotSpreads.slice(0, parseInt(opts.top ?? "30"))) {
937
+ const spreadColor = s.annualSpreadPct >= 30 ? chalk.green : chalk.yellow;
938
+ const dir = s.direction === "long-spot-short-perp" ? "Spot+Short" : "Sell+Long";
939
+ console.log(` ${chalk.white.bold(s.symbol.padEnd(8))} ` +
940
+ `${spreadColor(`${s.annualSpreadPct.toFixed(1)}%`.padEnd(8))} ` +
941
+ `${s.perpExchange.slice(0, 8).padEnd(9)} ` +
942
+ `${s.spotExchanges.join(",").slice(0, 11).padEnd(12)} ` +
943
+ `${(s.perpFundingRate * 100).toFixed(4).padEnd(12)}% ` +
944
+ dir);
945
+ }
946
+ console.log(chalk.gray(`\n ${spotSpreads.length} spot+perp opportunities (spot funding = 0%)`));
947
+ }
948
+ }
949
+ if (opts.mode === "spot-perp")
950
+ return;
951
+ if (!isJson())
952
+ console.log(""); // separator
953
+ }
954
+ // ── perp-perp mode (default) ──
742
955
  if (!isJson())
743
- console.log(chalk.cyan("\n Scanning funding rate spreads...\n"));
956
+ console.log(chalk.cyan(" Scanning perp-perp funding rate spreads...\n"));
744
957
  const spreads = await fetchFundingSpreads();
745
958
  const filtered = spreads.filter(s => Math.abs(s.spread) >= minSpread);
746
959
  if (isJson()) {
@@ -748,14 +961,14 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
748
961
  const grossSpread = Math.abs(s.spread);
749
962
  const rtCost = computeRoundTripCostPct(s.longExch, s.shortExch);
750
963
  const net = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
751
- return { symbol: s.symbol, longExch: s.longExch, shortExch: s.shortExch, markPrice: s.markPrice, grossSpread, netSpread: net, estFeesPct: rtCost };
964
+ return { mode: "perp-perp", symbol: s.symbol, longExch: s.longExch, shortExch: s.shortExch, markPrice: s.markPrice, grossSpread, netSpread: net, estFeesPct: rtCost };
752
965
  }).sort((a, b) => b.netSpread - a.netSpread);
753
966
  if (opts.top)
754
967
  enriched = enriched.slice(0, parseInt(opts.top));
755
968
  return printJson(jsonOk(enriched));
756
969
  }
757
970
  if (filtered.length === 0) {
758
- console.log(chalk.gray(` No spreads above ${minSpread}%\n`));
971
+ console.log(chalk.gray(` No perp-perp spreads above ${minSpread}%\n`));
759
972
  return;
760
973
  }
761
974
  // Header
@@ -798,6 +1011,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
798
1011
  .option("--max-slippage <pct>", "Max slippage % per leg", "0.5")
799
1012
  .option("--leverage <n>", "Set leverage before entry (both exchanges)")
800
1013
  .option("--isolated", "Use isolated margin mode")
1014
+ .option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick (reduces slippage)")
801
1015
  .action(async (symbol, longExch, shortExch, sizeUsdStr, opts) => {
802
1016
  const sym = symbol.toUpperCase();
803
1017
  const sizeUsd = parseFloat(sizeUsdStr);
@@ -888,16 +1102,19 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
888
1102
  const avgPrice = (longCheck.avgFillPrice + shortCheck.avgFillPrice) / 2;
889
1103
  const matchedUsd = matchedBase * avgPrice;
890
1104
  if (!isJson()) {
891
- console.log(chalk.cyan(`\n Arb Exec: ${sym}`));
1105
+ console.log(chalk.cyan(`\n Arb Exec: ${sym}${opts.smart ? " (smart order)" : ""}`));
892
1106
  console.log(chalk.gray(` LONG ${longExch}: buy ${matchedSize} (~$${(matchedBase * longCheck.avgFillPrice).toFixed(2)}, slippage ${longCheck.slippagePct.toFixed(3)}%)`));
893
1107
  console.log(chalk.gray(` SHORT ${shortExch}: sell ${matchedSize} (~$${(matchedBase * shortCheck.avgFillPrice).toFixed(2)}, slippage ${shortCheck.slippagePct.toFixed(3)}%)`));
894
1108
  console.log(chalk.gray(` Executing both legs simultaneously...\n`));
895
1109
  }
896
1110
  // 5. Execute both legs simultaneously
897
- const [longResult, shortResult] = await Promise.allSettled([
898
- longAdapter.marketOrder(sym, "buy", matchedSize),
899
- shortAdapter.marketOrder(sym, "sell", matchedSize),
900
- ]);
1111
+ const execLong = opts.smart
1112
+ ? smartOrder(longAdapter, sym, "buy", matchedSize).then(r => r.result)
1113
+ : longAdapter.marketOrder(sym, "buy", matchedSize);
1114
+ const execShort = opts.smart
1115
+ ? smartOrder(shortAdapter, sym, "sell", matchedSize).then(r => r.result)
1116
+ : shortAdapter.marketOrder(sym, "sell", matchedSize);
1117
+ const [longResult, shortResult] = await Promise.allSettled([execLong, execShort]);
901
1118
  const longOk = longResult.status === "fulfilled";
902
1119
  const shortOk = shortResult.status === "fulfilled";
903
1120
  if (longOk && shortOk) {
@@ -963,7 +1180,12 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
963
1180
  let rollbackOk = false;
964
1181
  for (let attempt = 1; attempt <= 2; attempt++) {
965
1182
  try {
966
- await filledAdapter.marketOrder(sym, rollbackAction, matchedSize);
1183
+ if (opts.smart) {
1184
+ await smartOrder(filledAdapter, sym, rollbackAction, matchedSize, { reduceOnly: true });
1185
+ }
1186
+ else {
1187
+ await filledAdapter.marketOrder(sym, rollbackAction, matchedSize);
1188
+ }
967
1189
  rollbackOk = true;
968
1190
  break;
969
1191
  }
@@ -1002,6 +1224,602 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1002
1224
  console.log(chalk.red(` SHORT: ${result.shortError}\n`));
1003
1225
  }
1004
1226
  });
1227
+ // ── arb spot-exec ── (spot+perp arb execution)
1228
+ arb
1229
+ .command("spot-exec")
1230
+ .description("Execute spot+perp arb: buy spot + short perp simultaneously")
1231
+ .argument("<symbol>", "Symbol (e.g. ETH, BTC)")
1232
+ .argument("<spotExch>", "Spot exchange: hl (Hyperliquid) or lt (Lighter)")
1233
+ .argument("<perpExch>", "Perp exchange: hl, lt, pac")
1234
+ .argument("<sizeUsd>", "Position size per leg in USD")
1235
+ .option("--max-slippage <pct>", "Max slippage % per leg", "0.5")
1236
+ .option("--leverage <n>", "Set leverage on perp side before entry")
1237
+ .option("--isolated", "Use isolated margin mode on perp side")
1238
+ .option("--dry-run", "Simulate without executing trades")
1239
+ .action(async (symbol, spotExch, perpExch, sizeUsdStr, opts) => {
1240
+ const sym = symbol.toUpperCase();
1241
+ const sizeUsd = parseFloat(sizeUsdStr);
1242
+ const maxSlippage = parseFloat(opts.maxSlippage);
1243
+ const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
1244
+ // Resolve exchange aliases
1245
+ const aliasMap = { hl: "hyperliquid", pac: "pacifica", lt: "lighter" };
1246
+ spotExch = aliasMap[spotExch.toLowerCase()] || spotExch.toLowerCase();
1247
+ perpExch = aliasMap[perpExch.toLowerCase()] || perpExch.toLowerCase();
1248
+ // Validate spot exchange
1249
+ if (!["hyperliquid", "lighter"].includes(spotExch)) {
1250
+ const msg = `Spot exchange must be hl or lt (got ${spotExch}). Pacifica is perp-only.`;
1251
+ if (isJson())
1252
+ return printJson(jsonOk({ error: msg }));
1253
+ console.log(chalk.red(` ${msg}`));
1254
+ return;
1255
+ }
1256
+ // Load spot adapter
1257
+ const { HyperliquidSpotAdapter } = await import("../exchanges/hyperliquid-spot.js");
1258
+ const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
1259
+ let spotAdapter;
1260
+ if (spotExch === "hyperliquid") {
1261
+ const hlAdapter = await getAdapterForExchange("hyperliquid");
1262
+ const hlSpot = new HyperliquidSpotAdapter(hlAdapter);
1263
+ await hlSpot.init();
1264
+ spotAdapter = hlSpot;
1265
+ }
1266
+ else {
1267
+ const ltAdapter = await getAdapterForExchange("lighter");
1268
+ const ltSpot = new LighterSpotAdapter(ltAdapter);
1269
+ await ltSpot.init();
1270
+ spotAdapter = ltSpot;
1271
+ }
1272
+ const perpAdapter = await getAdapterForExchange(perpExch);
1273
+ // 1. Set perp leverage if requested
1274
+ if (opts.leverage) {
1275
+ const lev = parseInt(opts.leverage);
1276
+ const mode = opts.isolated ? "isolated" : "cross";
1277
+ if (!isJson())
1278
+ console.log(chalk.gray(` Setting perp leverage ${lev}x ${mode} on ${perpExch}...`));
1279
+ try {
1280
+ await perpAdapter.setLeverage(sym, lev, mode);
1281
+ }
1282
+ catch (e) {
1283
+ const msg = `Failed to set leverage: ${e instanceof Error ? e.message : e}`;
1284
+ if (isJson())
1285
+ return printJson(jsonOk({ error: msg }));
1286
+ console.log(chalk.red(` ${msg}`));
1287
+ return;
1288
+ }
1289
+ }
1290
+ // 2. Auto-transfer USDC to spot account if needed
1291
+ if (spotExch === "hyperliquid") {
1292
+ const hlSpot = spotAdapter;
1293
+ const transferAmt = Math.ceil(sizeUsd * 1.1);
1294
+ try {
1295
+ const xferResult = await hlSpot.transferUsdcToSpot(transferAmt);
1296
+ if (xferResult?.status === "err" && xferResult.response?.includes("unified")) {
1297
+ if (!isJson())
1298
+ console.log(chalk.gray(` Unified account — no USDC transfer needed`));
1299
+ }
1300
+ else if (xferResult?.status !== "err") {
1301
+ if (!isJson())
1302
+ console.log(chalk.gray(` Transferred $${transferAmt} USDC perp→spot`));
1303
+ await new Promise(r => setTimeout(r, 500));
1304
+ }
1305
+ }
1306
+ catch {
1307
+ // Non-critical — may be unified account or already have balance
1308
+ }
1309
+ }
1310
+ else if (spotExch === "lighter") {
1311
+ // Lighter requires explicit USDC transfer from perp → spot account
1312
+ const ltSpot = spotAdapter;
1313
+ const transferAmt = Math.ceil(sizeUsd * 1.1);
1314
+ try {
1315
+ const xferResult = await ltSpot.transferUsdcToSpot(transferAmt);
1316
+ if (!isJson())
1317
+ console.log(chalk.gray(` Transferred $${transferAmt} USDC perp→spot on Lighter`));
1318
+ await new Promise(r => setTimeout(r, 300));
1319
+ }
1320
+ catch (e) {
1321
+ const msg = e instanceof Error ? e.message : String(e);
1322
+ if (!isJson())
1323
+ console.log(chalk.yellow(` USDC transfer warning: ${msg}`));
1324
+ // Continue — spot order may still work if spot balance already exists
1325
+ }
1326
+ }
1327
+ // 2b. Verify spot USDC balance is sufficient after transfer
1328
+ try {
1329
+ const spotBalances = await spotAdapter.getSpotBalances();
1330
+ const spotUsdc = spotBalances.find(b => b.token === "USDC_SPOT" || b.token === "USDC");
1331
+ const spotUsdcAvail = Number(spotUsdc?.available ?? 0);
1332
+ // Also check perp balance for the perp leg
1333
+ const perpBalance = await perpAdapter.getBalance();
1334
+ const perpAvail = Number(perpBalance?.available ?? 0);
1335
+ if (spotUsdcAvail < sizeUsd * 0.9) {
1336
+ const msg = `Insufficient spot USDC: have $${spotUsdcAvail.toFixed(2)}, need ~$${sizeUsd}. Transfer may have failed.`;
1337
+ if (isJson())
1338
+ return printJson(jsonOk({ error: msg, spotUsdcAvail, required: sizeUsd }));
1339
+ console.log(chalk.red(` ${msg}`));
1340
+ return;
1341
+ }
1342
+ if (perpAvail < sizeUsd * 0.5) {
1343
+ const msg = `Insufficient perp balance: have $${perpAvail.toFixed(2)} available on ${perpExch}, need margin for ~$${sizeUsd}`;
1344
+ if (isJson())
1345
+ return printJson(jsonOk({ error: msg, perpAvail, required: sizeUsd }));
1346
+ console.log(chalk.red(` ${msg}`));
1347
+ return;
1348
+ }
1349
+ }
1350
+ catch {
1351
+ // Balance check failed — continue with execution, let order placement catch errors
1352
+ }
1353
+ // 3. Fetch orderbooks simultaneously
1354
+ const spotSymbol = `${sym}/USDC`;
1355
+ if (!isJson())
1356
+ console.log(chalk.gray(` Checking orderbook depth (spot ${spotExch} + perp ${perpExch})...`));
1357
+ const [spotBook, perpBook] = await Promise.all([
1358
+ spotAdapter.getSpotOrderbook(spotSymbol),
1359
+ perpAdapter.getOrderbook(sym),
1360
+ ]);
1361
+ // 3a. Price cross-validation: verify spot and perp are the same underlying
1362
+ // Prevents wrong-token trades if index mapping is incorrect
1363
+ const spotMid = spotBook.bids.length > 0 && spotBook.asks.length > 0
1364
+ ? (Number(spotBook.bids[0][0]) + Number(spotBook.asks[0][0])) / 2 : 0;
1365
+ const perpMid = perpBook.bids.length > 0 && perpBook.asks.length > 0
1366
+ ? (Number(perpBook.bids[0][0]) + Number(perpBook.asks[0][0])) / 2 : 0;
1367
+ if (spotMid > 0 && perpMid > 0) {
1368
+ const priceDeviation = Math.abs(spotMid - perpMid) / perpMid * 100;
1369
+ if (priceDeviation > 5) {
1370
+ const msg = `Price mismatch: spot mid $${spotMid.toFixed(4)} vs perp mid $${perpMid.toFixed(4)} (${priceDeviation.toFixed(1)}% deviation). Possible wrong token index — aborting.`;
1371
+ if (isJson())
1372
+ return printJson(jsonOk({ error: msg, spotMid, perpMid, deviationPct: priceDeviation }));
1373
+ console.log(chalk.red(`\n ${msg}`));
1374
+ return;
1375
+ }
1376
+ }
1377
+ else if (spotMid === 0) {
1378
+ const msg = `Empty spot orderbook for ${sym} — cannot verify token identity`;
1379
+ if (isJson())
1380
+ return printJson(jsonOk({ error: msg }));
1381
+ console.log(chalk.red(`\n ${msg}`));
1382
+ return;
1383
+ }
1384
+ // 3b. Validate depth
1385
+ const spotCheck = computeExecutableSize(spotBook.asks, sizeUsd, maxSlippage);
1386
+ const perpCheck = computeExecutableSize(perpBook.bids, sizeUsd, maxSlippage);
1387
+ if (!spotCheck.canFillFull || !perpCheck.canFillFull) {
1388
+ const fillableUsd = Math.min(spotCheck.maxSize * spotCheck.avgFillPrice, perpCheck.maxSize * perpCheck.avgFillPrice);
1389
+ if (isJson())
1390
+ return printJson(jsonOk({
1391
+ error: "Insufficient orderbook depth",
1392
+ requestedUsd: sizeUsd, fillableUsd: Math.round(fillableUsd * 100) / 100,
1393
+ spot: { exchange: spotExch, depthUsd: spotCheck.depthUsd, canFill: spotCheck.canFillFull },
1394
+ perp: { exchange: perpExch, depthUsd: perpCheck.depthUsd, canFill: perpCheck.canFillFull },
1395
+ }));
1396
+ console.log(chalk.red(`\n Insufficient depth for $${sizeUsd}:`));
1397
+ console.log(chalk.gray(` ${spotExch} spot asks: $${spotCheck.depthUsd.toFixed(0)}`));
1398
+ console.log(chalk.gray(` ${perpExch} perp bids: $${perpCheck.depthUsd.toFixed(0)}`));
1399
+ return;
1400
+ }
1401
+ // 4. Compute matched size
1402
+ const inferDecimals = (levels) => {
1403
+ for (const [, s] of levels) {
1404
+ const dot = s.indexOf(".");
1405
+ if (dot >= 0)
1406
+ return s.length - dot - 1;
1407
+ }
1408
+ return 0;
1409
+ };
1410
+ const spotDecimals = inferDecimals(spotBook.asks);
1411
+ const perpDecimals = inferDecimals(perpBook.bids);
1412
+ const decimals = Math.min(spotDecimals, perpDecimals);
1413
+ const matchedBase = Math.min(spotCheck.recommendedSize, perpCheck.recommendedSize);
1414
+ const matchedSize = (Math.floor(matchedBase * 10 ** decimals) / 10 ** decimals).toFixed(decimals);
1415
+ const avgPrice = (spotCheck.avgFillPrice + perpCheck.avgFillPrice) / 2;
1416
+ const matchedUsd = matchedBase * avgPrice;
1417
+ if (!isJson()) {
1418
+ console.log(chalk.cyan(`\n Spot+Perp Arb: ${sym}`));
1419
+ console.log(chalk.gray(` SPOT ${spotExch}: buy ${matchedSize} (~$${(matchedBase * spotCheck.avgFillPrice).toFixed(2)})`));
1420
+ console.log(chalk.gray(` PERP ${perpExch}: sell ${matchedSize} (~$${(matchedBase * perpCheck.avgFillPrice).toFixed(2)})`));
1421
+ console.log(chalk.gray(` Executing both legs simultaneously...\n`));
1422
+ }
1423
+ // 5. Execute both legs (or return dry-run result)
1424
+ if (dryRun) {
1425
+ const result = {
1426
+ status: "filled", mode: "spot-perp", symbol: sym, dryRun: true,
1427
+ size: matchedSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
1428
+ spot: { exchange: spotExch, side: "buy", size: matchedSize },
1429
+ perp: { exchange: perpExch, side: "sell", size: matchedSize },
1430
+ };
1431
+ if (isJson())
1432
+ return printJson(jsonOk(result));
1433
+ console.log(chalk.cyan(` [DRY-RUN] Would execute: spot buy ${matchedSize} on ${spotExch} + perp sell ${matchedSize} on ${perpExch}`));
1434
+ return;
1435
+ }
1436
+ // Same exchange (e.g. Lighter) shares nonce → must execute sequentially
1437
+ // Different exchanges → execute simultaneously for speed
1438
+ const validatePerpFill = (r) => {
1439
+ const res = r;
1440
+ const statuses = res?.response?.data?.statuses;
1441
+ if (statuses && statuses.length > 0) {
1442
+ const st = statuses[0];
1443
+ if (st.error)
1444
+ throw new Error(`Perp sell ${sym}: ${st.error}`);
1445
+ const filled = st.filled;
1446
+ if (filled && Number(filled.totalSz ?? 0) === 0) {
1447
+ throw new Error(`Perp sell ${sym}: 0 fill`);
1448
+ }
1449
+ }
1450
+ return r;
1451
+ };
1452
+ let spotResult;
1453
+ let perpResult;
1454
+ if (spotExch === perpExch) {
1455
+ // Sequential: spot first, then perp (avoids nonce collision on same exchange)
1456
+ spotResult = await spotAdapter.spotMarketOrder(spotSymbol, "buy", matchedSize)
1457
+ .then(r => ({ status: "fulfilled", value: r }))
1458
+ .catch(e => ({ status: "rejected", reason: e }));
1459
+ if (spotResult.status === "fulfilled") {
1460
+ perpResult = await perpAdapter.marketOrder(sym, "sell", matchedSize)
1461
+ .then(validatePerpFill)
1462
+ .then(r => ({ status: "fulfilled", value: r }))
1463
+ .catch(e => ({ status: "rejected", reason: e }));
1464
+ }
1465
+ else {
1466
+ perpResult = { status: "rejected", reason: new Error("Skipped: spot failed") };
1467
+ }
1468
+ }
1469
+ else {
1470
+ // Parallel: different exchanges, no nonce conflict
1471
+ [spotResult, perpResult] = await Promise.allSettled([
1472
+ spotAdapter.spotMarketOrder(spotSymbol, "buy", matchedSize),
1473
+ perpAdapter.marketOrder(sym, "sell", matchedSize).then(validatePerpFill),
1474
+ ]);
1475
+ }
1476
+ const spotOk = spotResult.status === "fulfilled";
1477
+ const perpOk = perpResult.status === "fulfilled";
1478
+ if (spotOk && perpOk) {
1479
+ // ── Post-execution fill verification ──
1480
+ // Wait for exchange state to settle, then verify both legs actually filled
1481
+ let fillVerified = true;
1482
+ let verifyWarnings = [];
1483
+ try {
1484
+ await new Promise(r => setTimeout(r, 800));
1485
+ // Verify spot: check base token balance appeared
1486
+ const spotBals = await spotAdapter.getSpotBalances();
1487
+ const baseToken = sym; // e.g. "ETH"
1488
+ const spotTokenBal = spotBals.find(b => b.token.toUpperCase().startsWith(baseToken));
1489
+ const spotTokenAmt = Number(spotTokenBal?.total ?? 0);
1490
+ const expectedSize = parseFloat(matchedSize);
1491
+ if (spotTokenAmt < expectedSize * 0.5) {
1492
+ fillVerified = false;
1493
+ verifyWarnings.push(`Spot buy may not have filled: expected ~${matchedSize} ${baseToken}, found ${spotTokenAmt.toFixed(6)}`);
1494
+ }
1495
+ // Verify perp: check short position exists
1496
+ const perpPositions = await perpAdapter.getPositions();
1497
+ const perpPos = perpPositions.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym && p.side === "short");
1498
+ if (!perpPos) {
1499
+ fillVerified = false;
1500
+ verifyWarnings.push(`Perp short position not found for ${sym} on ${perpExch} — fill may not have executed`);
1501
+ }
1502
+ else if (Number(perpPos.size) < expectedSize * 0.5) {
1503
+ verifyWarnings.push(`Perp short size ${perpPos.size} < expected ${matchedSize} — partial fill?`);
1504
+ }
1505
+ }
1506
+ catch (verifyErr) {
1507
+ // Verification check itself failed — log but don't block
1508
+ verifyWarnings.push(`Fill verification check failed: ${verifyErr instanceof Error ? verifyErr.message : String(verifyErr)}`);
1509
+ }
1510
+ if (verifyWarnings.length > 0 && !isJson()) {
1511
+ for (const w of verifyWarnings) {
1512
+ console.log(chalk.yellow(` ⚠ ${w}`));
1513
+ }
1514
+ }
1515
+ if (!fillVerified) {
1516
+ // Fills couldn't be confirmed — log as unverified but still persist
1517
+ // (rolling back based on uncertain data is worse than persisting)
1518
+ logExecution({
1519
+ type: "arb_entry", exchange: `spot:${spotExch}+${perpExch}`,
1520
+ symbol: sym, side: "entry", size: matchedSize,
1521
+ status: "unverified", dryRun: false,
1522
+ meta: { mode: "spot-perp", spotExch, perpExch, matchedUsd, warnings: verifyWarnings },
1523
+ });
1524
+ }
1525
+ else {
1526
+ // Log execution — verified
1527
+ logExecution({
1528
+ type: "arb_entry", exchange: `spot:${spotExch}+${perpExch}`,
1529
+ symbol: sym, side: "entry", size: matchedSize,
1530
+ status: "success", dryRun: false,
1531
+ meta: { mode: "spot-perp", spotExch, perpExch, matchedUsd },
1532
+ });
1533
+ }
1534
+ // Persist position state
1535
+ persistAddPosition({
1536
+ id: `${sym}-spot-${Date.now()}`,
1537
+ symbol: sym,
1538
+ longExchange: spotExch,
1539
+ shortExchange: perpExch,
1540
+ longSize: parseFloat(matchedSize),
1541
+ shortSize: parseFloat(matchedSize),
1542
+ entryTime: new Date().toISOString(),
1543
+ entrySpread: 0, // Will be updated on next scan
1544
+ entryLongPrice: spotCheck.avgFillPrice,
1545
+ entryShortPrice: perpCheck.avgFillPrice,
1546
+ accumulatedFunding: 0,
1547
+ lastCheckTime: new Date().toISOString(),
1548
+ mode: "spot-perp",
1549
+ spotExchange: spotExch,
1550
+ spotSymbol: spotSymbol,
1551
+ });
1552
+ // Return leftover USDC from spot back to perp (non-unified HL accounts)
1553
+ if (spotExch === "hyperliquid") {
1554
+ try {
1555
+ const hlSpot = spotAdapter;
1556
+ const bals = await hlSpot.getSpotBalances();
1557
+ const usdcBal = bals.find(b => b.token.startsWith("USDC"));
1558
+ const leftover = Number(usdcBal?.available ?? 0);
1559
+ if (leftover > 1) {
1560
+ const xfer = await hlSpot.transferUsdcToPerp(Math.floor(leftover));
1561
+ if (xfer?.status !== "err") {
1562
+ if (!isJson())
1563
+ console.log(chalk.gray(` Returned ~$${Math.floor(leftover)} USDC to perp account`));
1564
+ }
1565
+ }
1566
+ }
1567
+ catch { /* non-critical — unified accounts don't need transfer */ }
1568
+ }
1569
+ const result = {
1570
+ status: fillVerified ? "filled" : "filled_unverified",
1571
+ mode: "spot-perp", symbol: sym,
1572
+ size: matchedSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
1573
+ spot: { exchange: spotExch, side: "buy", size: matchedSize },
1574
+ perp: { exchange: perpExch, side: "sell", size: matchedSize },
1575
+ ...(verifyWarnings.length > 0 && { warnings: verifyWarnings }),
1576
+ };
1577
+ if (isJson())
1578
+ return printJson(jsonOk(result));
1579
+ if (fillVerified) {
1580
+ console.log(chalk.green(` FILLED (verified): ${sym} ${matchedSize} units (~$${matchedUsd.toFixed(2)})`));
1581
+ }
1582
+ else {
1583
+ console.log(chalk.yellow(` FILLED (unverified): ${sym} ${matchedSize} units (~$${matchedUsd.toFixed(2)})`));
1584
+ }
1585
+ console.log(chalk.green(` SPOT ${spotExch} ✓ (bought ${matchedSize})`));
1586
+ console.log(chalk.green(` PERP ${perpExch} ✓ (shorted ${matchedSize})\n`));
1587
+ }
1588
+ else if (spotOk !== perpOk) {
1589
+ // One leg failed — rollback
1590
+ const filledSide = spotOk ? "spot" : "perp";
1591
+ const failedSide = spotOk ? "perp" : "spot";
1592
+ const failedErr = spotOk
1593
+ ? perpResult.reason
1594
+ : spotResult.reason;
1595
+ const failedErrMsg = failedErr instanceof Error ? failedErr.message : String(failedErr);
1596
+ if (!isJson()) {
1597
+ console.log(chalk.yellow(` PARTIAL: ${filledSide} OK, ${failedSide} FAILED — rolling back...`));
1598
+ console.log(chalk.red(` Reason: ${failedErrMsg}`));
1599
+ }
1600
+ let rollbackOk = false;
1601
+ for (let attempt = 1; attempt <= 2; attempt++) {
1602
+ try {
1603
+ if (spotOk) {
1604
+ await spotAdapter.spotMarketOrder(spotSymbol, "sell", matchedSize);
1605
+ }
1606
+ else {
1607
+ await perpAdapter.marketOrder(sym, "buy", matchedSize);
1608
+ }
1609
+ rollbackOk = true;
1610
+ break;
1611
+ }
1612
+ catch { /* retry */ }
1613
+ }
1614
+ logExecution({
1615
+ type: "arb_entry", exchange: `spot:${spotExch}+${perpExch}`,
1616
+ symbol: sym, side: "entry", size: matchedSize,
1617
+ status: "failed", dryRun: false,
1618
+ error: `Partial: ${failedSide} failed (${failedErrMsg}). Rollback: ${rollbackOk ? "ok" : "FAILED"}`,
1619
+ });
1620
+ const result = {
1621
+ status: "partial_fill", mode: "spot-perp", symbol: sym,
1622
+ filledSide, failedSide,
1623
+ failedError: failedErrMsg,
1624
+ rollback: rollbackOk ? "success" : "FAILED — MANUAL CLOSE REQUIRED",
1625
+ };
1626
+ if (isJson())
1627
+ return printJson(jsonOk(result));
1628
+ console.log(rollbackOk
1629
+ ? chalk.yellow(` Rollback OK — no open exposure.\n`)
1630
+ : chalk.red.bold(` ROLLBACK FAILED — ${filledSide} leg still open. Close manually!\n`));
1631
+ }
1632
+ else {
1633
+ // Both failed
1634
+ const spotErr = spotResult.reason;
1635
+ const perpErr = perpResult.reason;
1636
+ const result = {
1637
+ status: "both_failed", mode: "spot-perp", symbol: sym,
1638
+ spotError: spotErr instanceof Error ? spotErr.message : String(spotErr),
1639
+ perpError: perpErr instanceof Error ? perpErr.message : String(perpErr),
1640
+ };
1641
+ if (isJson())
1642
+ return printJson(jsonOk(result));
1643
+ console.log(chalk.red(` Both legs failed:`));
1644
+ console.log(chalk.red(` SPOT: ${result.spotError}`));
1645
+ console.log(chalk.red(` PERP: ${result.perpError}\n`));
1646
+ }
1647
+ });
1648
+ // ── arb spot-close ── (close spot+perp arb position)
1649
+ arb
1650
+ .command("spot-close")
1651
+ .description("Close a spot+perp arb position: sell spot + buy back perp")
1652
+ .argument("<symbol>", "Symbol (e.g. ETH)")
1653
+ .option("--spot-exch <exchange>", "Spot exchange (hl or lt)")
1654
+ .option("--perp-exch <exchange>", "Perp exchange (hl, lt, pac)")
1655
+ .action(async (symbol, opts) => {
1656
+ const sym = symbol.toUpperCase();
1657
+ // Try to find the position from state
1658
+ const state = loadArbState();
1659
+ const pos = state?.positions.find(p => p.symbol.toUpperCase() === sym && p.mode === "spot-perp");
1660
+ const aliasMap = { hl: "hyperliquid", pac: "pacifica", lt: "lighter" };
1661
+ const spotExch = aliasMap[opts.spotExch?.toLowerCase() ?? ""] || opts.spotExch?.toLowerCase() || pos?.spotExchange || pos?.longExchange;
1662
+ const perpExch = aliasMap[opts.perpExch?.toLowerCase() ?? ""] || opts.perpExch?.toLowerCase() || pos?.shortExchange;
1663
+ if (!spotExch || !perpExch) {
1664
+ const msg = "Cannot determine exchanges. Specify --spot-exch and --perp-exch, or ensure the position is tracked in state.";
1665
+ if (isJson())
1666
+ return printJson(jsonOk({ error: msg }));
1667
+ console.log(chalk.red(` ${msg}`));
1668
+ return;
1669
+ }
1670
+ // Load adapters
1671
+ const { HyperliquidSpotAdapter } = await import("../exchanges/hyperliquid-spot.js");
1672
+ const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
1673
+ let spotAdapter;
1674
+ if (spotExch === "hyperliquid") {
1675
+ const hlAdapter = await getAdapterForExchange("hyperliquid");
1676
+ const hlSpot = new HyperliquidSpotAdapter(hlAdapter);
1677
+ await hlSpot.init();
1678
+ spotAdapter = hlSpot;
1679
+ }
1680
+ else {
1681
+ const ltAdapter = await getAdapterForExchange("lighter");
1682
+ const ltSpot = new LighterSpotAdapter(ltAdapter);
1683
+ await ltSpot.init();
1684
+ spotAdapter = ltSpot;
1685
+ }
1686
+ const perpAdapter = await getAdapterForExchange(perpExch);
1687
+ const spotSymbol = `${sym}/USDC`;
1688
+ // Find how much to close
1689
+ const perpPositions = await perpAdapter.getPositions();
1690
+ const perpPos = perpPositions.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym && p.side === "short");
1691
+ if (!perpPos) {
1692
+ const msg = `No short perp position found for ${sym} on ${perpExch}`;
1693
+ if (isJson())
1694
+ return printJson(jsonOk({ error: msg }));
1695
+ console.log(chalk.red(` ${msg}`));
1696
+ return;
1697
+ }
1698
+ const closeSize = perpPos.size;
1699
+ if (!isJson()) {
1700
+ console.log(chalk.cyan(`\n Closing Spot+Perp Arb: ${sym}`));
1701
+ console.log(chalk.gray(` SPOT ${spotExch}: sell ${closeSize}`));
1702
+ console.log(chalk.gray(` PERP ${perpExch}: buy ${closeSize} (close short)`));
1703
+ console.log(chalk.gray(` Executing...\n`));
1704
+ }
1705
+ // Same exchange → sequential (nonce collision), different → parallel
1706
+ let spotResult;
1707
+ let perpResult;
1708
+ if (spotExch === perpExch) {
1709
+ // Sequential: sell spot first, then close perp
1710
+ spotResult = await spotAdapter.spotMarketOrder(spotSymbol, "sell", closeSize)
1711
+ .then(r => ({ status: "fulfilled", value: r }))
1712
+ .catch(e => ({ status: "rejected", reason: e }));
1713
+ perpResult = await perpAdapter.marketOrder(sym, "buy", closeSize)
1714
+ .then(r => ({ status: "fulfilled", value: r }))
1715
+ .catch(e => ({ status: "rejected", reason: e }));
1716
+ }
1717
+ else {
1718
+ [spotResult, perpResult] = await Promise.allSettled([
1719
+ spotAdapter.spotMarketOrder(spotSymbol, "sell", closeSize),
1720
+ perpAdapter.marketOrder(sym, "buy", closeSize),
1721
+ ]);
1722
+ }
1723
+ const spotOk = spotResult.status === "fulfilled";
1724
+ const perpOk = perpResult.status === "fulfilled";
1725
+ if (spotOk && perpOk) {
1726
+ // ── Post-close fill verification ──
1727
+ let closeVerified = true;
1728
+ let closeWarnings = [];
1729
+ try {
1730
+ await new Promise(r => setTimeout(r, 800));
1731
+ // Verify spot token was sold (balance should be near zero or gone)
1732
+ const spotBals = await spotAdapter.getSpotBalances();
1733
+ const baseToken = sym;
1734
+ const spotTokenBal = spotBals.find(b => b.token.toUpperCase().startsWith(baseToken));
1735
+ const remainingSpotAmt = Number(spotTokenBal?.total ?? 0);
1736
+ const closedSize = parseFloat(closeSize);
1737
+ if (remainingSpotAmt > closedSize * 0.5) {
1738
+ closeVerified = false;
1739
+ closeWarnings.push(`Spot sell may not have filled: still have ${remainingSpotAmt.toFixed(6)} ${baseToken} (expected ~0)`);
1740
+ }
1741
+ // Verify perp short was closed (position should be gone or reduced)
1742
+ const perpPositionsAfter = await perpAdapter.getPositions();
1743
+ const remainingPerpPos = perpPositionsAfter.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym && p.side === "short");
1744
+ if (remainingPerpPos && Number(remainingPerpPos.size) > closedSize * 0.5) {
1745
+ closeVerified = false;
1746
+ closeWarnings.push(`Perp short still open: ${remainingPerpPos.size} ${sym} on ${perpExch} (expected closed)`);
1747
+ }
1748
+ }
1749
+ catch (verifyErr) {
1750
+ closeWarnings.push(`Close verification failed: ${verifyErr instanceof Error ? verifyErr.message : String(verifyErr)}`);
1751
+ }
1752
+ if (closeWarnings.length > 0 && !isJson()) {
1753
+ for (const w of closeWarnings) {
1754
+ console.log(chalk.yellow(` ⚠ ${w}`));
1755
+ }
1756
+ }
1757
+ // Remove from state
1758
+ persistRemovePosition(sym);
1759
+ logExecution({
1760
+ type: "arb_close", exchange: `spot:${spotExch}+${perpExch}`,
1761
+ symbol: sym, side: "exit", size: closeSize,
1762
+ status: closeVerified ? "success" : "unverified", dryRun: false,
1763
+ meta: { mode: "spot-perp", ...(closeWarnings.length > 0 && { warnings: closeWarnings }) },
1764
+ });
1765
+ // Return spot USDC proceeds back to perp account
1766
+ if (spotExch === "lighter") {
1767
+ try {
1768
+ const ltSpot = spotAdapter;
1769
+ const bals = await ltSpot.getSpotBalances();
1770
+ const usdcBal = bals.find(b => b.token === "USDC");
1771
+ const leftover = Number(usdcBal?.available ?? 0);
1772
+ if (leftover > 0.01) {
1773
+ await ltSpot.transferUsdcToPerp(leftover);
1774
+ if (!isJson())
1775
+ console.log(chalk.gray(` Returned ~$${leftover.toFixed(2)} USDC spot→perp on Lighter`));
1776
+ }
1777
+ }
1778
+ catch { /* non-critical */ }
1779
+ }
1780
+ else if (spotExch === "hyperliquid") {
1781
+ try {
1782
+ const hlSpot = spotAdapter;
1783
+ const bals = await hlSpot.getSpotBalances();
1784
+ const usdcBal = bals.find(b => b.token.startsWith("USDC"));
1785
+ const leftover = Number(usdcBal?.available ?? 0);
1786
+ if (leftover > 1) {
1787
+ await hlSpot.transferUsdcToPerp(Math.floor(leftover));
1788
+ }
1789
+ }
1790
+ catch { /* non-critical */ }
1791
+ }
1792
+ const closeResult = {
1793
+ status: closeVerified ? "closed" : "closed_unverified",
1794
+ mode: "spot-perp", symbol: sym, size: closeSize,
1795
+ ...(closeWarnings.length > 0 && { warnings: closeWarnings }),
1796
+ };
1797
+ if (isJson())
1798
+ return printJson(jsonOk(closeResult));
1799
+ if (closeVerified) {
1800
+ console.log(chalk.green(` CLOSED (verified): ${sym} ${closeSize} units`));
1801
+ }
1802
+ else {
1803
+ console.log(chalk.yellow(` CLOSED (unverified): ${sym} ${closeSize} units — check positions manually`));
1804
+ }
1805
+ console.log(chalk.green(` SPOT ${spotExch}: sold ✓`));
1806
+ console.log(chalk.green(` PERP ${perpExch}: bought (closed short) ✓\n`));
1807
+ }
1808
+ else {
1809
+ const errors = [];
1810
+ if (!spotOk)
1811
+ errors.push(`Spot sell failed: ${spotResult.reason}`);
1812
+ if (!perpOk)
1813
+ errors.push(`Perp buy failed: ${perpResult.reason}`);
1814
+ const result = { status: "partial_close", symbol: sym, spotOk, perpOk, errors };
1815
+ if (isJson())
1816
+ return printJson(jsonOk(result));
1817
+ console.log(chalk.red(` Close partially failed:`));
1818
+ for (const e of errors)
1819
+ console.log(chalk.red(` ${e}`));
1820
+ console.log(chalk.yellow(` Manual intervention may be needed.\n`));
1821
+ }
1822
+ });
1005
1823
  // ── arb pnl ── (check arb position PnL)
1006
1824
  arb
1007
1825
  .command("pnl")
@@ -1048,7 +1866,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1048
1866
  const spreads = await fetchFundingSpreads();
1049
1867
  const spreadMap = new Map(spreads.map(s => [s.symbol.toUpperCase(), s]));
1050
1868
  // Get fee rates (approximate)
1051
- const TAKER_FEE = 0.00035; // ~0.035% typical
1869
+ const TAKER_FEE = getTakerFee("default");
1052
1870
  // Fetch actual settled funding payments from each exchange
1053
1871
  const actualFundingByExSymbol = new Map(); // "exchange:SYMBOL" → total settled USD
1054
1872
  for (const exName of exchangeNames) {
@@ -1400,7 +2218,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1400
2218
  ]);
1401
2219
  logExecution({
1402
2220
  type: "arb_close", exchange: `${pos.longDex}+${pos.shortDex}`,
1403
- symbol: pos.underlying, side: "close", size: pos.size,
2221
+ symbol: pos.underlying, side: "close", size: String(pos.size),
1404
2222
  status: "success", dryRun: false,
1405
2223
  meta: { longDex: pos.longDex, shortDex: pos.shortDex, reason, longSymbol: pos.longSymbol, shortSymbol: pos.shortSymbol },
1406
2224
  });
@@ -1410,7 +2228,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1410
2228
  catch (err) {
1411
2229
  logExecution({
1412
2230
  type: "arb_close", exchange: `${pos.longDex}+${pos.shortDex}`,
1413
- symbol: pos.underlying, side: "close", size: pos.size,
2231
+ symbol: pos.underlying, side: "close", size: String(pos.size),
1414
2232
  status: "failed", dryRun: false,
1415
2233
  error: err instanceof Error ? err.message : String(err),
1416
2234
  meta: { longDex: pos.longDex, shortDex: pos.shortDex },