perp-cli 0.9.3 → 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.
@@ -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 sign = ann >= 0 ? "+" : "";
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} ${sign}${ann.toFixed(1)}% (${posCount}/${hist.length} positive)`;
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
- const fH = ri?.fundingHours ?? getFundingHours(pos.perpExchange);
392
- return ri ? (ri.rate / fH) * 8760 * 100 : 0;
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;
@@ -118,6 +118,24 @@ export class SpotPerpExecutor {
118
118
  }
119
119
  return { success: false, error: `spot buy: ${msg}` };
120
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 */ }
121
139
  // Step 2: Short perp
122
140
  try {
123
141
  await perpAdapter.marketOrder(perpSymbol, "sell", matched.size);
@@ -202,6 +220,24 @@ export class SpotPerpExecutor {
202
220
  }
203
221
  return false;
204
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 */ }
205
241
  // Step 3: Transfer USDC back to perp (always — lighter/HL both need this)
206
242
  try {
207
243
  const spotBals = await spotAdapter.getSpotBalances();
@@ -239,8 +239,8 @@ export class LighterSpotAdapter {
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 === 3)
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
  }
@@ -488,8 +488,8 @@ export class LighterAdapter {
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 === 3) {
492
- // status 3 = executed. Parse event_info for fill details
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
  }
@@ -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 < 5; 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
- throw new Error(`Market ${side} ${symbol}: order ${orderId} not confirmed after 2.5s${errDetail}`);
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(() => process.exit(0), 500);
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(() => process.exit(0), 500);
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
- process.exit(1);
755
+ _origExit(1);
754
756
  });
755
757
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",