perp-cli 0.9.2 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/arb/sizing.d.ts +1 -1
- package/dist/arb/sizing.js +3 -3
- package/dist/bot/engine.js +20 -0
- package/dist/bot/strategies/spot-perp-arb-strategy.js +9 -4
- package/dist/bot/strategies/spot-perp-executor.js +40 -2
- package/dist/exchanges/lighter-spot.js +6 -5
- package/dist/exchanges/lighter.js +6 -6
- package/dist/exchanges/pacifica.js +3 -2
- package/dist/index.js +5 -3
- package/package.json +1 -1
package/dist/arb/sizing.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export declare function computeMatchedSize(sizeUsd: number, price: number, longE
|
|
|
17
17
|
* Compute matched size for spot-perp arb.
|
|
18
18
|
* Uses spot exchange's size decimals for the spot leg.
|
|
19
19
|
*/
|
|
20
|
-
export declare function computeSpotPerpMatchedSize(sizeUsd: number, price: number, spotExchange: string, perpExchange: string, spotSizeDecimals?: number): {
|
|
20
|
+
export declare function computeSpotPerpMatchedSize(sizeUsd: number, price: number, spotExchange: string, perpExchange: string, spotSizeDecimals?: number, perpSizeDecimals?: number): {
|
|
21
21
|
size: string;
|
|
22
22
|
notional: number;
|
|
23
23
|
} | null;
|
package/dist/arb/sizing.js
CHANGED
|
@@ -77,12 +77,12 @@ function getMinNotional(exchange) {
|
|
|
77
77
|
* Compute matched size for spot-perp arb.
|
|
78
78
|
* Uses spot exchange's size decimals for the spot leg.
|
|
79
79
|
*/
|
|
80
|
-
export function computeSpotPerpMatchedSize(sizeUsd, price, spotExchange, perpExchange, spotSizeDecimals) {
|
|
80
|
+
export function computeSpotPerpMatchedSize(sizeUsd, price, spotExchange, perpExchange, spotSizeDecimals, perpSizeDecimals) {
|
|
81
81
|
if (price <= 0)
|
|
82
82
|
return null;
|
|
83
|
-
// If explicit
|
|
83
|
+
// If explicit decimals provided, use them; otherwise derive from exchange
|
|
84
84
|
const spotDec = spotSizeDecimals ?? getSizeDecimals(`spot:${spotExchange}`);
|
|
85
|
-
const perpDec = getSizeDecimals(perpExchange);
|
|
85
|
+
const perpDec = perpSizeDecimals ?? getSizeDecimals(perpExchange);
|
|
86
86
|
const szDecimals = Math.min(spotDec, perpDec);
|
|
87
87
|
const rawSize = sizeUsd / price;
|
|
88
88
|
const factor = Math.pow(10, szDecimals);
|
package/dist/bot/engine.js
CHANGED
|
@@ -114,6 +114,21 @@ mode = "headless") {
|
|
|
114
114
|
const shutdown = () => { running = false; state.phase = "stopped"; };
|
|
115
115
|
process.on("SIGINT", shutdown);
|
|
116
116
|
process.on("SIGTERM", shutdown);
|
|
117
|
+
// Direct stdin 'q' handler as fallback (ink's useInput may not work in all terminals)
|
|
118
|
+
let stdinHandler;
|
|
119
|
+
if (process.stdin.isTTY) {
|
|
120
|
+
process.stdin.setRawMode?.(true);
|
|
121
|
+
process.stdin.resume();
|
|
122
|
+
stdinHandler = (data) => {
|
|
123
|
+
const ch = data.toString();
|
|
124
|
+
if (ch === "q" || ch === "Q" || ch === "\x03") { // q, Q, or Ctrl+C
|
|
125
|
+
shutdown();
|
|
126
|
+
if (tuiUnmount)
|
|
127
|
+
tuiUnmount();
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
process.stdin.on("data", stdinHandler);
|
|
131
|
+
}
|
|
117
132
|
if (mode !== "tui") {
|
|
118
133
|
// Header (headless / json modes print header to console)
|
|
119
134
|
log(chalk.cyan.bold(`\n ╔═══════════════════════════════════════╗`));
|
|
@@ -308,6 +323,11 @@ mode = "headless") {
|
|
|
308
323
|
finally {
|
|
309
324
|
process.removeListener("SIGINT", shutdown);
|
|
310
325
|
process.removeListener("SIGTERM", shutdown);
|
|
326
|
+
if (stdinHandler) {
|
|
327
|
+
process.stdin.removeListener("data", stdinHandler);
|
|
328
|
+
process.stdin.setRawMode?.(false);
|
|
329
|
+
process.stdin.pause();
|
|
330
|
+
}
|
|
311
331
|
// Cleanup: cancel any remaining orders
|
|
312
332
|
if (state.strategyActive) {
|
|
313
333
|
effectiveLog(` Cleaning up orders...`);
|
|
@@ -174,10 +174,10 @@ export class SpotPerpArbStrategy {
|
|
|
174
174
|
}
|
|
175
175
|
const display = positions.map(pos => {
|
|
176
176
|
const ann = this.calcAnnualized(pos, ratesByExchange);
|
|
177
|
-
const
|
|
177
|
+
const annStr = ann !== null ? `${ann >= 0 ? "+" : ""}${ann.toFixed(1)}%` : "?%";
|
|
178
178
|
const hist = pos.fundingHistory;
|
|
179
179
|
const posCount = hist.filter(h => h.amount > 0).length;
|
|
180
|
-
return `${pos.symbol} ${pos.spotExchange}-spot<>${pos.perpExchange} ${
|
|
180
|
+
return `${pos.symbol} ${pos.spotExchange}-spot<>${pos.perpExchange} ${annStr} (${posCount}/${hist.length} positive)`;
|
|
181
181
|
});
|
|
182
182
|
ctx.state.set("arbPositionDetails", display);
|
|
183
183
|
ctx.state.set("arbPositions", positions.length);
|
|
@@ -388,13 +388,18 @@ export class SpotPerpArbStrategy {
|
|
|
388
388
|
calcAnnualized(pos, ratesByExchange) {
|
|
389
389
|
const ri = findRate(ratesByExchange, pos.perpExchange, getPerpSymbol(pos.symbol, pos.perpExchange))
|
|
390
390
|
?? findRate(ratesByExchange, pos.perpExchange, pos.symbol);
|
|
391
|
-
|
|
392
|
-
|
|
391
|
+
if (!ri)
|
|
392
|
+
return null; // rate unknown — don't assume 0
|
|
393
|
+
const fH = ri.fundingHours ?? getFundingHours(pos.perpExchange);
|
|
394
|
+
return (ri.rate / fH) * 8760 * 100;
|
|
393
395
|
}
|
|
394
396
|
shouldClose(pos, ratesByExchange, p) {
|
|
395
397
|
const minHoldMs = p.min_hold_hours * 60 * 60 * 1000;
|
|
396
398
|
const holdTime = pos.entryTime > 0 ? Date.now() - pos.entryTime : Infinity;
|
|
397
399
|
const ann = this.calcAnnualized(pos, ratesByExchange);
|
|
400
|
+
// Rate unknown — don't close (rate data not yet available)
|
|
401
|
+
if (ann === null)
|
|
402
|
+
return false;
|
|
398
403
|
// Emergency close: instantaneous spread deeply negative
|
|
399
404
|
if (ann < -50)
|
|
400
405
|
return true;
|
|
@@ -68,9 +68,11 @@ export class SpotPerpExecutor {
|
|
|
68
68
|
// Compute matched size
|
|
69
69
|
const spotMarkets = await spotAdapter.getSpotMarkets();
|
|
70
70
|
const spotDec = spotMarkets.find(m => m.baseToken.toUpperCase() === symbol.toUpperCase())?.sizeDecimals;
|
|
71
|
-
const
|
|
71
|
+
const perpMarkets = await perpAdapter.getMarkets();
|
|
72
|
+
const perpDec = perpMarkets.find(m => matchSymbol(m.symbol, perpSymbol) || matchSymbol(m.symbol, symbol))?.sizeDecimals;
|
|
73
|
+
const matched = computeSpotPerpMatchedSize(liq.adjustedSizeUsd, price, spotExchange, perpExchange, spotDec, perpDec);
|
|
72
74
|
if (!matched) {
|
|
73
|
-
log(` [SPA] Skip ${symbol}: matched size fail`);
|
|
75
|
+
log(` [SPA] Skip ${symbol}: matched size fail (spotDec=${spotDec}, perpDec=${perpDec}, price=${price.toFixed(2)}, sizeUsd=${liq.adjustedSizeUsd.toFixed(2)})`);
|
|
74
76
|
return { success: false, error: "matched size fail" };
|
|
75
77
|
}
|
|
76
78
|
log(` [SPA] Opening: ${matched.size} ${symbol} ($${matched.notional.toFixed(0)}) ${spotExchange}-spot<>${perpExchange}`);
|
|
@@ -116,6 +118,24 @@ export class SpotPerpExecutor {
|
|
|
116
118
|
}
|
|
117
119
|
return { success: false, error: `spot buy: ${msg}` };
|
|
118
120
|
}
|
|
121
|
+
// Step 1b: Verify spot actually bought (safety net for "assuming executed" false positive)
|
|
122
|
+
try {
|
|
123
|
+
invalidateCache("acct");
|
|
124
|
+
const postBals = await spotAdapter.getSpotBalances();
|
|
125
|
+
const bought = postBals.find(b => b.token.toUpperCase().replace(/-SPOT$/, "") === symbol.toUpperCase());
|
|
126
|
+
const boughtAmt = bought ? parseFloat(bought.total) : 0;
|
|
127
|
+
if (boughtAmt < parseFloat(matched.size) * 0.5) {
|
|
128
|
+
log(` [SPA] Spot buy returned OK but ${symbol} balance too low (${boughtAmt.toFixed(4)}) — aborting`);
|
|
129
|
+
if (actualTransferred > 0) {
|
|
130
|
+
try {
|
|
131
|
+
await transferUsdcToPerp(spotAdapter, spotExchange, actualTransferred);
|
|
132
|
+
}
|
|
133
|
+
catch { /* best effort */ }
|
|
134
|
+
}
|
|
135
|
+
return { success: false, error: "spot buy not confirmed by balance" };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch { /* balance check failed, proceed optimistically */ }
|
|
119
139
|
// Step 2: Short perp
|
|
120
140
|
try {
|
|
121
141
|
await perpAdapter.marketOrder(perpSymbol, "sell", matched.size);
|
|
@@ -200,6 +220,24 @@ export class SpotPerpExecutor {
|
|
|
200
220
|
}
|
|
201
221
|
return false;
|
|
202
222
|
}
|
|
223
|
+
// Step 2b: Verify spot actually sold (safety net for "assuming executed" false positive)
|
|
224
|
+
try {
|
|
225
|
+
invalidateCache("acct");
|
|
226
|
+
const postBals = await spotAdapter.getSpotBalances();
|
|
227
|
+
const remaining = postBals.find(b => b.token.toUpperCase().replace(/-SPOT$/, "") === symbol.toUpperCase());
|
|
228
|
+
const remainingAmt = remaining ? parseFloat(remaining.total) : 0;
|
|
229
|
+
if (remainingAmt >= parseFloat(size) * 0.5) {
|
|
230
|
+
log(` [SPA] CRITICAL: Spot sell returned OK but ${symbol} balance unchanged (${remainingAmt.toFixed(4)}) — re-hedging perp`);
|
|
231
|
+
try {
|
|
232
|
+
await perpAdapter.marketOrder(perpSym, "sell", size);
|
|
233
|
+
}
|
|
234
|
+
catch (rbErr) {
|
|
235
|
+
log(` [SPA] CRITICAL: Perp re-hedge failed: ${rbErr instanceof Error ? rbErr.message : String(rbErr)}`);
|
|
236
|
+
}
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch { /* balance check failed, proceed optimistically */ }
|
|
203
241
|
// Step 3: Transfer USDC back to perp (always — lighter/HL both need this)
|
|
204
242
|
try {
|
|
205
243
|
const spotBals = await spotAdapter.getSpotBalances();
|
|
@@ -231,16 +231,16 @@ export class LighterSpotAdapter {
|
|
|
231
231
|
orderType: 1, // MARKET
|
|
232
232
|
timeInForce: 0, // IOC
|
|
233
233
|
});
|
|
234
|
-
// Verify fill via tx endpoint
|
|
234
|
+
// Verify fill via tx endpoint (best-effort: tx was accepted by sendTx, so likely executed)
|
|
235
235
|
const txResult = result;
|
|
236
236
|
if (txResult.tx_hash) {
|
|
237
|
-
for (let attempt = 0; attempt <
|
|
237
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
238
238
|
await new Promise(r => setTimeout(r, 500));
|
|
239
239
|
try {
|
|
240
240
|
const tx = await this._restGet("/tx", { by: "hash", value: txResult.tx_hash });
|
|
241
241
|
const status = Number(tx.status ?? 0);
|
|
242
|
-
if (status
|
|
243
|
-
return result; // executed
|
|
242
|
+
if (status >= 2 && status <= 3)
|
|
243
|
+
return result; // status 2=executed, 3=committed
|
|
244
244
|
if (status > 3)
|
|
245
245
|
throw new Error(`Spot ${side} ${symbol}: tx rejected (status=${status})`);
|
|
246
246
|
}
|
|
@@ -249,7 +249,8 @@ export class LighterSpotAdapter {
|
|
|
249
249
|
throw e;
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
|
-
|
|
252
|
+
// tx was accepted (sendTx 200) but verification timed out — warn, don't throw
|
|
253
|
+
console.error(`[lighter-spot] Warning: spot ${side} ${symbol} tx not confirmed after 5s (hash=${txResult.tx_hash}) — assuming executed`);
|
|
253
254
|
}
|
|
254
255
|
return result;
|
|
255
256
|
}
|
|
@@ -483,13 +483,13 @@ export class LighterAdapter {
|
|
|
483
483
|
const txResult = result;
|
|
484
484
|
if (txResult.tx_hash) {
|
|
485
485
|
// Poll tx status (may take a moment to process)
|
|
486
|
-
for (let attempt = 0; attempt <
|
|
486
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
487
487
|
await new Promise(r => setTimeout(r, 500));
|
|
488
488
|
try {
|
|
489
489
|
const tx = await this.restGet("/tx", { by: "hash", value: txResult.tx_hash });
|
|
490
490
|
const status = Number(tx.status ?? 0);
|
|
491
|
-
if (status
|
|
492
|
-
// status 3 =
|
|
491
|
+
if (status >= 2 && status <= 3) {
|
|
492
|
+
// status 2 = executed, 3 = committed. Parse event_info for fill details
|
|
493
493
|
try {
|
|
494
494
|
const eventInfo = JSON.parse(String(tx.event_info ?? "{}"));
|
|
495
495
|
const takerOrder = eventInfo.to;
|
|
@@ -507,7 +507,7 @@ export class LighterAdapter {
|
|
|
507
507
|
// status=3 means tx was executed (even if zero-fill IOC — strategy verifies position)
|
|
508
508
|
return result;
|
|
509
509
|
}
|
|
510
|
-
if (status > 3) {
|
|
510
|
+
if (status > 3) { // status 4+ = rejected/failed
|
|
511
511
|
// status > 3 likely means rejected/cancelled
|
|
512
512
|
throw new Error(`Market ${side} ${symbol}: tx rejected (status=${status}, hash=${txResult.tx_hash})`);
|
|
513
513
|
}
|
|
@@ -519,8 +519,8 @@ export class LighterAdapter {
|
|
|
519
519
|
// tx query failed, retry
|
|
520
520
|
}
|
|
521
521
|
}
|
|
522
|
-
//
|
|
523
|
-
|
|
522
|
+
// tx was accepted (sendTx 200) but verification timed out — warn, don't throw
|
|
523
|
+
console.error(`[lighter] Warning: market ${side} ${symbol} tx not confirmed after 5s (hash=${txResult.tx_hash}) — assuming executed`);
|
|
524
524
|
}
|
|
525
525
|
return result;
|
|
526
526
|
}
|
|
@@ -194,7 +194,7 @@ export class PacificaAdapter {
|
|
|
194
194
|
if (orderId && !(opts?.reduceOnly ?? false)) {
|
|
195
195
|
// Poll order status via history_by_id
|
|
196
196
|
let lastPollError = null;
|
|
197
|
-
for (let attempt = 0; attempt <
|
|
197
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
198
198
|
await new Promise(r => setTimeout(r, 500));
|
|
199
199
|
try {
|
|
200
200
|
const history = await this.client.getOrderHistoryById(Number(orderId));
|
|
@@ -217,8 +217,9 @@ export class PacificaAdapter {
|
|
|
217
217
|
lastPollError = e;
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
|
+
// Order was accepted but verification timed out — warn, don't throw
|
|
220
221
|
const errDetail = lastPollError instanceof Error ? ` (poll error: ${lastPollError.message})` : "";
|
|
221
|
-
|
|
222
|
+
console.error(`[pacifica] Warning: market ${side} ${symbol} order ${orderId} not confirmed after 5s${errDetail} — assuming executed`);
|
|
222
223
|
}
|
|
223
224
|
if (!(opts?.reduceOnly ?? false)) {
|
|
224
225
|
console.error(`[pacifica] Warning: market ${side} ${symbol} accepted but no order_id in response — skipping fill verification`);
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// Save original process.exit before any SDK (Go WASM) can patch it
|
|
3
|
+
const _origExit = process.exit.bind(process);
|
|
2
4
|
import { config } from "dotenv";
|
|
3
5
|
import { resolve } from "path";
|
|
4
6
|
import { createRequire } from "node:module";
|
|
@@ -733,14 +735,14 @@ if (rawArgs.length === 0 || (!hasSubcommand && !rawArgs.includes("-h") && !rawAr
|
|
|
733
735
|
catch {
|
|
734
736
|
program.help();
|
|
735
737
|
}
|
|
736
|
-
setTimeout(() =>
|
|
738
|
+
setTimeout(() => _origExit(0), 500);
|
|
737
739
|
})();
|
|
738
740
|
}
|
|
739
741
|
else {
|
|
740
742
|
program.parseAsync().then(() => {
|
|
741
743
|
// Allow a short delay for any pending output, then exit cleanly.
|
|
742
744
|
// Without this, HL SDK's WebSocket keeps the process alive indefinitely.
|
|
743
|
-
setTimeout(() =>
|
|
745
|
+
setTimeout(() => _origExit(0), 500);
|
|
744
746
|
}).catch(async (err) => {
|
|
745
747
|
const msg = err instanceof Error ? err.message : String(err);
|
|
746
748
|
if (isJson()) {
|
|
@@ -750,6 +752,6 @@ else {
|
|
|
750
752
|
else {
|
|
751
753
|
console.error(chalk.red(msg));
|
|
752
754
|
}
|
|
753
|
-
|
|
755
|
+
_origExit(1);
|
|
754
756
|
});
|
|
755
757
|
}
|