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.
- package/README.md +2 -0
- package/dist/arb/sizing.d.ts +12 -1
- package/dist/arb/sizing.js +38 -5
- package/dist/arb/state.d.ts +6 -0
- package/dist/arb/utils.d.ts +2 -0
- package/dist/arb/utils.js +1 -1
- package/dist/cli-spec.d.ts +0 -4
- package/dist/cli-spec.js +8 -1
- package/dist/commands/agent.js +12 -9
- package/dist/commands/arb/index.js +18 -3
- package/dist/commands/arb-auto.js +885 -67
- package/dist/commands/dex.js +7 -1
- package/dist/commands/funding.d.ts +2 -1
- package/dist/commands/funding.js +211 -4
- package/dist/commands/manage.js +1 -1
- package/dist/commands/market.js +1 -1
- package/dist/commands/multileg.d.ts +16 -0
- package/dist/commands/multileg.js +174 -0
- package/dist/commands/pnl.d.ts +12 -0
- package/dist/commands/pnl.js +243 -0
- package/dist/commands/settings.d.ts +2 -1
- package/dist/commands/settings.js +133 -2
- package/dist/commands/stream.js +132 -55
- package/dist/commands/trade.js +118 -43
- package/dist/commands/wallet.js +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.js +12 -0
- package/dist/constants.d.ts +14 -0
- package/dist/constants.js +40 -0
- package/dist/dashboard/ws-feeds.js +1 -1
- package/dist/equity-tracker.d.ts +57 -0
- package/dist/equity-tracker.js +209 -0
- package/dist/exchanges/hyperliquid-spot.d.ts +68 -0
- package/dist/exchanges/hyperliquid-spot.js +317 -0
- package/dist/exchanges/hyperliquid.d.ts +26 -5
- package/dist/exchanges/hyperliquid.js +181 -92
- package/dist/exchanges/interface.d.ts +1 -1
- package/dist/exchanges/lighter-spot.d.ts +55 -0
- package/dist/exchanges/lighter-spot.js +417 -0
- package/dist/exchanges/lighter.d.ts +10 -2
- package/dist/exchanges/lighter.js +132 -37
- package/dist/exchanges/pacifica.d.ts +11 -4
- package/dist/exchanges/pacifica.js +52 -37
- package/dist/exchanges/spot-interface.d.ts +45 -0
- package/dist/exchanges/spot-interface.js +22 -0
- package/dist/execution-log.d.ts +2 -2
- package/dist/funding/rates.d.ts +36 -0
- package/dist/funding/rates.js +146 -0
- package/dist/index.js +148 -99
- package/dist/liquidity.d.ts +12 -0
- package/dist/liquidity.js +43 -0
- package/dist/settings.d.ts +6 -0
- package/dist/settings.js +18 -2
- package/dist/signer/evm-local.d.ts +12 -0
- package/dist/signer/evm-local.js +19 -0
- package/dist/signer/index.d.ts +3 -0
- package/dist/signer/index.js +2 -0
- package/dist/signer/interface.d.ts +31 -0
- package/dist/signer/interface.js +7 -0
- package/dist/signer/solana-local.d.ts +13 -0
- package/dist/signer/solana-local.js +19 -0
- package/dist/smart-order.d.ts +44 -0
- package/dist/smart-order.js +145 -0
- package/dist/trade-validator.js +2 -1
- package/dist/utils.js +7 -1
- package/dist/ws/market-feed.d.ts +74 -0
- package/dist/ws/market-feed.js +227 -0
- package/package.json +1 -1
- package/skills/perp-cli/SKILL.md +102 -4
- package/skills/perp-cli/references/commands.md +16 -1
- package/skills/perp-cli/references/strategies.md +1 -1
- package/skills/perp-cli/scripts/arb-monitor.sh +74 -0
- package/skills/perp-cli/scripts/funding-analysis.sh +85 -0
- package/skills/perp-cli/scripts/preflight.sh +98 -0
- package/skills/perp-cli/scripts/spot-perp-scan.sh +83 -0
- package/skills/perp-cli/scripts/validate-arb.sh +112 -0
- package/skills/perp-cli/templates/arb-scan.sh +0 -15
- package/skills/perp-cli/templates/open-position.sh +0 -38
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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 =
|
|
697
|
-
// Estimate funding collected:
|
|
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("
|
|
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
|
|
898
|
-
longAdapter
|
|
899
|
-
|
|
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
|
-
|
|
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 =
|
|
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 },
|