perp-cli 0.9.3 → 0.9.6
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/bot/engine.js +2 -1
- package/dist/bot/strategies/funding-arb-v2-strategy.js +8 -2
- package/dist/bot/strategies/spot-perp-arb-strategy.js +35 -8
- package/dist/bot/strategies/spot-perp-executor.js +36 -0
- package/dist/exchanges/lighter-spot.js +2 -2
- package/dist/exchanges/lighter.js +3 -3
- package/dist/exchanges/pacifica.js +3 -2
- package/dist/funding/normalize.js +1 -1
- package/dist/index.js +5 -3
- package/package.json +1 -1
package/dist/bot/engine.js
CHANGED
|
@@ -76,10 +76,11 @@ mode = "headless") {
|
|
|
76
76
|
tick: 0,
|
|
77
77
|
log,
|
|
78
78
|
};
|
|
79
|
-
// Pass extra adapters to strategy context
|
|
79
|
+
// Pass extra adapters and risk config to strategy context
|
|
80
80
|
if (extraAdapters) {
|
|
81
81
|
strategyCtx.state.set("extraAdapters", extraAdapters);
|
|
82
82
|
}
|
|
83
|
+
strategyCtx.state.set("riskConfig", config.risk);
|
|
83
84
|
const isDryRun = false; // TODO: wire up from config when dry-run mode is added
|
|
84
85
|
let running = true;
|
|
85
86
|
// ── TUI / JSON mode setup ──
|
|
@@ -380,10 +380,16 @@ export class FundingArbV2Strategy {
|
|
|
380
380
|
return cached.score;
|
|
381
381
|
try {
|
|
382
382
|
const history = await adapter.getFundingHistory(symbol, periods);
|
|
383
|
-
if (history.length
|
|
383
|
+
if (history.length < 2)
|
|
384
384
|
return null;
|
|
385
385
|
const rates = history.map(h => parseFloat(h.rate));
|
|
386
|
-
|
|
386
|
+
// Compute actual funding period from timestamps (no static map dependency)
|
|
387
|
+
const times = history.map(h => h.time).sort((a, b) => a - b);
|
|
388
|
+
let totalInterval = 0;
|
|
389
|
+
for (let i = 1; i < times.length; i++)
|
|
390
|
+
totalInterval += times[i] - times[i - 1];
|
|
391
|
+
const avgIntervalH = totalInterval / (times.length - 1) / 3600000;
|
|
392
|
+
const fH = avgIntervalH < 1.5 ? 1 : avgIntervalH < 5 ? 4 : 8;
|
|
387
393
|
const avgRate = rates.reduce((s, r) => s + r, 0) / rates.length;
|
|
388
394
|
const consistency = rates.filter(r => r > 0).length / rates.length;
|
|
389
395
|
const annualized = (avgRate / fH) * 8760 * 100;
|
|
@@ -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);
|
|
@@ -354,10 +354,16 @@ export class SpotPerpArbStrategy {
|
|
|
354
354
|
return cached.score;
|
|
355
355
|
try {
|
|
356
356
|
const history = await adapter.getFundingHistory(symbol, p.history_periods);
|
|
357
|
-
if (history.length
|
|
357
|
+
if (history.length < 2)
|
|
358
358
|
return null;
|
|
359
359
|
const rates = history.map(h => parseFloat(h.rate));
|
|
360
|
-
|
|
360
|
+
// Compute actual funding period from timestamps (no static map dependency)
|
|
361
|
+
const times = history.map(h => h.time).sort((a, b) => a - b);
|
|
362
|
+
let totalInterval = 0;
|
|
363
|
+
for (let i = 1; i < times.length; i++)
|
|
364
|
+
totalInterval += times[i] - times[i - 1];
|
|
365
|
+
const avgIntervalH = totalInterval / (times.length - 1) / 3600000;
|
|
366
|
+
const fH = avgIntervalH < 1.5 ? 1 : avgIntervalH < 5 ? 4 : 8;
|
|
361
367
|
const avgRate = rates.reduce((s, r) => s + r, 0) / rates.length;
|
|
362
368
|
const consistency = rates.filter(r => r > 0).length / rates.length;
|
|
363
369
|
const annualized = (avgRate / fH) * 8760 * 100;
|
|
@@ -388,13 +394,18 @@ export class SpotPerpArbStrategy {
|
|
|
388
394
|
calcAnnualized(pos, ratesByExchange) {
|
|
389
395
|
const ri = findRate(ratesByExchange, pos.perpExchange, getPerpSymbol(pos.symbol, pos.perpExchange))
|
|
390
396
|
?? findRate(ratesByExchange, pos.perpExchange, pos.symbol);
|
|
391
|
-
|
|
392
|
-
|
|
397
|
+
if (!ri)
|
|
398
|
+
return null; // rate unknown — don't assume 0
|
|
399
|
+
const fH = ri.fundingHours ?? getFundingHours(pos.perpExchange);
|
|
400
|
+
return (ri.rate / fH) * 8760 * 100;
|
|
393
401
|
}
|
|
394
402
|
shouldClose(pos, ratesByExchange, p) {
|
|
395
403
|
const minHoldMs = p.min_hold_hours * 60 * 60 * 1000;
|
|
396
404
|
const holdTime = pos.entryTime > 0 ? Date.now() - pos.entryTime : Infinity;
|
|
397
405
|
const ann = this.calcAnnualized(pos, ratesByExchange);
|
|
406
|
+
// Rate unknown — don't close (rate data not yet available)
|
|
407
|
+
if (ann === null)
|
|
408
|
+
return false;
|
|
398
409
|
// Emergency close: instantaneous spread deeply negative
|
|
399
410
|
if (ann < -50)
|
|
400
411
|
return true;
|
|
@@ -442,8 +453,24 @@ export class SpotPerpArbStrategy {
|
|
|
442
453
|
return false;
|
|
443
454
|
}
|
|
444
455
|
}
|
|
445
|
-
//
|
|
446
|
-
|
|
456
|
+
// Risk policy: enforce max_position_usd from risk config
|
|
457
|
+
const riskConfig = ctx.state.get("riskConfig");
|
|
458
|
+
const maxTotalPositionUsd = riskConfig?.max_position_usd ?? Infinity;
|
|
459
|
+
const positions = ctx.state.get("spaPositions");
|
|
460
|
+
let currentTotalUsd = 0;
|
|
461
|
+
for (const pos of positions) {
|
|
462
|
+
const ri = findRate(ratesByExchange, pos.perpExchange, getPerpSymbol(pos.symbol, pos.perpExchange))
|
|
463
|
+
?? findRate(ratesByExchange, pos.perpExchange, pos.symbol);
|
|
464
|
+
const price = ri?.price ?? 0;
|
|
465
|
+
currentTotalUsd += price * parseFloat(pos.size);
|
|
466
|
+
}
|
|
467
|
+
const remainingBudget = maxTotalPositionUsd - currentTotalUsd;
|
|
468
|
+
if (remainingBudget < 10) {
|
|
469
|
+
ctx.log(` [SPA] Skip ${symbol}: risk limit — total positions $${currentTotalUsd.toFixed(0)}/$${maxTotalPositionUsd} (no budget)`);
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
// Size decision (capped by risk budget)
|
|
473
|
+
let targetSizeUsd = Math.min(p.size_usd, remainingBudget);
|
|
447
474
|
const sameExchange = spotExchange === perpExchange;
|
|
448
475
|
if (sameExchange) {
|
|
449
476
|
const perpBal = await perpAdapter.getBalance();
|
|
@@ -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
|
|
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
|
|
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
|
}
|
|
@@ -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
|
}
|