perp-cli 0.7.42 → 0.7.47
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/strategies/funding-arb-strategy.d.ts +4 -1
- package/dist/bot/strategies/funding-arb-strategy.js +149 -41
- package/dist/exchanges/aster.d.ts +6 -1
- package/dist/exchanges/aster.js +30 -9
- package/dist/exchanges/hyperliquid.d.ts +3 -1
- package/dist/exchanges/hyperliquid.js +1 -1
- package/dist/exchanges/interface.d.ts +3 -1
- package/dist/exchanges/lighter.d.ts +3 -1
- package/dist/exchanges/lighter.js +2 -1
- package/dist/exchanges/pacifica.d.ts +3 -1
- package/dist/exchanges/pacifica.js +2 -2
- package/package.json +1 -1
|
@@ -26,10 +26,13 @@ export declare class FundingArbStrategy implements Strategy {
|
|
|
26
26
|
private get params();
|
|
27
27
|
private _config;
|
|
28
28
|
private _failCooldown;
|
|
29
|
+
private _spotAdapterCache;
|
|
29
30
|
init(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<void>;
|
|
30
31
|
onTick(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<StrategyAction[]>;
|
|
31
32
|
onStop(ctx: StrategyContext): Promise<StrategyAction[]>;
|
|
32
|
-
/** Close a spot-perp position: sell spot, transfer USDC back, close perp short
|
|
33
|
+
/** Close a spot-perp position: sell spot, transfer USDC back, close perp short.
|
|
34
|
+
* Returns true only if ALL steps succeed. If spot sell succeeds but perp close fails,
|
|
35
|
+
* attempts to re-buy spot as rollback and returns false. */
|
|
33
36
|
private closeSpotPerp;
|
|
34
37
|
/** Open a spot-perp position: transfer USDC to spot, buy spot, short perp */
|
|
35
38
|
private openSpotPerp;
|
|
@@ -25,6 +25,7 @@ export class FundingArbStrategy {
|
|
|
25
25
|
}
|
|
26
26
|
_config = {};
|
|
27
27
|
_failCooldown = new Map(); // "symbol:action" → timestamp of next retry
|
|
28
|
+
_spotAdapterCache = new Map();
|
|
28
29
|
async init(ctx, _snapshot) {
|
|
29
30
|
// Prune old execution logs (keep 30 days)
|
|
30
31
|
try {
|
|
@@ -168,6 +169,7 @@ export class FundingArbStrategy {
|
|
|
168
169
|
ctx.state.set("fundingIncome", historicalFunding);
|
|
169
170
|
ctx.log(` [FUND] Total historical funding: $${historicalFunding.toFixed(4)}`);
|
|
170
171
|
}
|
|
172
|
+
// Note: aster funding intervals are bootstrapped lazily via getFundingHours()
|
|
171
173
|
}
|
|
172
174
|
catch (err) {
|
|
173
175
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -200,15 +202,17 @@ export class FundingArbStrategy {
|
|
|
200
202
|
const marginUsed = parseFloat(bal.marginUsed);
|
|
201
203
|
const marginPct = equity > 0 ? (marginUsed / equity) * 100 : 100;
|
|
202
204
|
exchangeBalances.set(name, { equity, available: parseFloat(bal.available), marginPct });
|
|
203
|
-
if (marginPct <
|
|
205
|
+
if (marginPct < 90) {
|
|
204
206
|
availableAdapters.set(name, a);
|
|
205
207
|
}
|
|
206
208
|
else {
|
|
207
209
|
ctx.log(` [ARB] ${name} margin ${marginPct.toFixed(0)}% — skipping for this tick`);
|
|
208
210
|
}
|
|
209
211
|
}
|
|
210
|
-
catch {
|
|
211
|
-
|
|
212
|
+
catch (err) {
|
|
213
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
214
|
+
ctx.log(` [ARB] getBalance failed for ${name}: ${msg} — skipping`);
|
|
215
|
+
// Don't include in availableAdapters
|
|
212
216
|
}
|
|
213
217
|
}
|
|
214
218
|
// Determine which exchanges need rate fetching.
|
|
@@ -322,8 +326,9 @@ export class FundingArbStrategy {
|
|
|
322
326
|
const sign = signedSpread >= 0 ? "+" : "";
|
|
323
327
|
ctx.log(` [ARB] Closing ${pos.symbol}: signed spread ${sign}${signedSpread.toFixed(1)}% (close_spread=${params.close_spread}%, entry=${pos.entrySpread.toFixed(1)}%)`);
|
|
324
328
|
if (pos.mode === "spot-perp") {
|
|
325
|
-
await this.closeSpotPerp(pos, adapters, ctx);
|
|
326
|
-
|
|
329
|
+
const spotPerpClosed = await this.closeSpotPerp(pos, adapters, ctx);
|
|
330
|
+
if (spotPerpClosed)
|
|
331
|
+
toClose.push(pos);
|
|
327
332
|
}
|
|
328
333
|
else {
|
|
329
334
|
const longAdapter = adapters.get(pos.longExchange);
|
|
@@ -331,7 +336,7 @@ export class FundingArbStrategy {
|
|
|
331
336
|
if (longAdapter && shortAdapter) {
|
|
332
337
|
let closeLongOk = false;
|
|
333
338
|
try {
|
|
334
|
-
const closeLongResult = await longAdapter.marketOrder(pos.symbol, "sell", pos.size);
|
|
339
|
+
const closeLongResult = await longAdapter.marketOrder(pos.symbol, "sell", pos.size, { reduceOnly: true });
|
|
335
340
|
closeLongOk = true;
|
|
336
341
|
logExecution({ type: "arb_close", exchange: pos.longExchange, symbol: pos.symbol, side: "sell", size: pos.size, status: "success", dryRun: false, meta: { mode: pos.mode, leg: "long", signedSpread: signedSpread, response: closeLongResult } });
|
|
337
342
|
}
|
|
@@ -342,9 +347,9 @@ export class FundingArbStrategy {
|
|
|
342
347
|
this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
|
|
343
348
|
}
|
|
344
349
|
if (!closeLongOk)
|
|
345
|
-
continue;
|
|
350
|
+
continue;
|
|
346
351
|
try {
|
|
347
|
-
const closeShortResult = await shortAdapter.marketOrder(pos.symbol, "buy", pos.size);
|
|
352
|
+
const closeShortResult = await shortAdapter.marketOrder(pos.symbol, "buy", pos.size, { reduceOnly: true });
|
|
348
353
|
logExecution({ type: "arb_close", exchange: pos.shortExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false, meta: { mode: pos.mode, leg: "short", signedSpread: signedSpread, response: closeShortResult } });
|
|
349
354
|
}
|
|
350
355
|
catch (err) {
|
|
@@ -355,6 +360,7 @@ export class FundingArbStrategy {
|
|
|
355
360
|
try {
|
|
356
361
|
await longAdapter.marketOrder(pos.symbol, "buy", pos.size);
|
|
357
362
|
ctx.log(` [ARB] Rollback: re-opened long ${pos.size} ${pos.symbol} on ${pos.longExchange}`);
|
|
363
|
+
// Note: re-open is NOT reduceOnly (it's a new position)
|
|
358
364
|
logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false });
|
|
359
365
|
}
|
|
360
366
|
catch (rbErr) {
|
|
@@ -509,12 +515,23 @@ export class FundingArbStrategy {
|
|
|
509
515
|
const leverage = Math.min(configLeverage, longMaxLev, shortMaxLev);
|
|
510
516
|
// Set leverage on both exchanges before ordering
|
|
511
517
|
try {
|
|
512
|
-
await
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
518
|
+
await longAdapter.setLeverage(best.symbol, leverage, "cross");
|
|
519
|
+
}
|
|
520
|
+
catch (err) {
|
|
521
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
522
|
+
ctx.log(` [ARB] setLeverage failed on ${best.longExchange} for ${best.symbol}: ${msg}`);
|
|
523
|
+
this._failCooldown.set(`${best.symbol}:entry`, Date.now() + 5 * 60 * 1000);
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
try {
|
|
527
|
+
await shortAdapter.setLeverage(best.symbol, leverage, "cross");
|
|
528
|
+
}
|
|
529
|
+
catch (err) {
|
|
530
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
531
|
+
ctx.log(` [ARB] setLeverage failed on ${best.shortExchange} for ${best.symbol}: ${msg}`);
|
|
532
|
+
this._failCooldown.set(`${best.symbol}:entry`, Date.now() + 5 * 60 * 1000);
|
|
533
|
+
continue;
|
|
516
534
|
}
|
|
517
|
-
catch { /* non-critical, some exchanges set leverage on order */ }
|
|
518
535
|
// Balance + margin check (use pre-fetched balances from tick start)
|
|
519
536
|
let targetSizeUsd = params.size_usd;
|
|
520
537
|
const longBal = exchangeBalances.get(best.longExchange);
|
|
@@ -570,7 +587,7 @@ export class FundingArbStrategy {
|
|
|
570
587
|
logExecution({ type: "arb_entry", exchange: best.shortExchange, symbol: best.symbol, side: "sell", size: matched.size, status: "failed", error: msg, dryRun: false, meta: { mode: "perp-perp", leg: "short" } });
|
|
571
588
|
// Rollback: close the long position
|
|
572
589
|
try {
|
|
573
|
-
await longAdapter.marketOrder(best.symbol, "sell", matched.size);
|
|
590
|
+
await longAdapter.marketOrder(best.symbol, "sell", matched.size, { reduceOnly: true });
|
|
574
591
|
ctx.log(` [ARB] Rollback success: closed long ${matched.size} ${best.symbol} on ${best.longExchange}`);
|
|
575
592
|
logExecution({ type: "multi_leg_rollback", exchange: best.longExchange, symbol: best.symbol, side: "sell", size: matched.size, status: "success", dryRun: false });
|
|
576
593
|
}
|
|
@@ -578,6 +595,20 @@ export class FundingArbStrategy {
|
|
|
578
595
|
const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
|
|
579
596
|
ctx.log(` [ARB] CRITICAL: Rollback failed for ${best.symbol}: ${rbMsg}`);
|
|
580
597
|
logExecution({ type: "multi_leg_rollback", exchange: best.longExchange, symbol: best.symbol, side: "sell", size: matched.size, status: "failed", error: rbMsg, dryRun: false });
|
|
598
|
+
// Track orphaned long position for manual cleanup
|
|
599
|
+
const currentPositions = ctx.state.get("arbOpenPositions");
|
|
600
|
+
currentPositions.push({
|
|
601
|
+
symbol: best.symbol,
|
|
602
|
+
longExchange: best.longExchange,
|
|
603
|
+
shortExchange: best.shortExchange, // note: short leg doesn't exist
|
|
604
|
+
entrySpread: 0,
|
|
605
|
+
size: matched.size,
|
|
606
|
+
mode: "perp-perp",
|
|
607
|
+
entryTime: 0, // flag as orphaned
|
|
608
|
+
});
|
|
609
|
+
ctx.state.set("arbOpenPositions", currentPositions);
|
|
610
|
+
ctx.state.set("arbPositions", currentPositions.length);
|
|
611
|
+
ctx.log(` [ARB] Tracking orphaned long position for manual cleanup`);
|
|
581
612
|
}
|
|
582
613
|
this._failCooldown.set(`${best.symbol}:entry`, Date.now() + 5 * 60 * 1000);
|
|
583
614
|
continue;
|
|
@@ -617,14 +648,14 @@ export class FundingArbStrategy {
|
|
|
617
648
|
logExecution({ type: "arb_entry", exchange: `${best.longExchange}↔${best.shortExchange}`, symbol: best.symbol, side: "verify", size: matched.size, status: "failed", error: `Position missing after fill: long=${longExists} short=${shortExists}`, dryRun: false });
|
|
618
649
|
if (longExists && !shortExists) {
|
|
619
650
|
try {
|
|
620
|
-
await longAdapter.marketOrder(best.symbol, "sell", matched.size);
|
|
651
|
+
await longAdapter.marketOrder(best.symbol, "sell", matched.size, { reduceOnly: true });
|
|
621
652
|
ctx.log(` [ARB] Rollback: closed phantom long ${best.symbol}`);
|
|
622
653
|
}
|
|
623
654
|
catch { /* best effort */ }
|
|
624
655
|
}
|
|
625
656
|
if (shortExists && !longExists) {
|
|
626
657
|
try {
|
|
627
|
-
await shortAdapter.marketOrder(best.symbol, "buy", matched.size);
|
|
658
|
+
await shortAdapter.marketOrder(best.symbol, "buy", matched.size, { reduceOnly: true });
|
|
628
659
|
ctx.log(` [ARB] Rollback: closed phantom short ${best.symbol}`);
|
|
629
660
|
}
|
|
630
661
|
catch { /* best effort */ }
|
|
@@ -673,38 +704,70 @@ export class FundingArbStrategy {
|
|
|
673
704
|
for (const [name, a] of extraAdapters)
|
|
674
705
|
adapters.set(name, a);
|
|
675
706
|
}
|
|
707
|
+
const remainingPositions = [];
|
|
676
708
|
for (const pos of openPositions) {
|
|
677
709
|
if (pos.mode === "spot-perp") {
|
|
678
|
-
await this.closeSpotPerp(pos, adapters, ctx);
|
|
710
|
+
const closed = await this.closeSpotPerp(pos, adapters, ctx);
|
|
711
|
+
if (!closed) {
|
|
712
|
+
ctx.log(` [ARB] CRITICAL: spot-perp ${pos.symbol} could not be fully closed on stop — requires manual cleanup`);
|
|
713
|
+
remainingPositions.push(pos);
|
|
714
|
+
}
|
|
679
715
|
}
|
|
680
716
|
else {
|
|
681
717
|
const longAdapter = adapters.get(pos.longExchange);
|
|
682
718
|
const shortAdapter = adapters.get(pos.shortExchange);
|
|
683
719
|
if (longAdapter && shortAdapter) {
|
|
684
720
|
ctx.log(` [ARB] Closing ${pos.symbol} on stop (${pos.size})`);
|
|
721
|
+
let closeLongOk = false;
|
|
685
722
|
try {
|
|
686
|
-
const closeLongResult = await longAdapter.marketOrder(pos.symbol, "sell", pos.size);
|
|
723
|
+
const closeLongResult = await longAdapter.marketOrder(pos.symbol, "sell", pos.size, { reduceOnly: true });
|
|
687
724
|
logExecution({ type: "arb_close", exchange: pos.longExchange, symbol: pos.symbol, side: "sell", size: pos.size, status: "success", dryRun: false, meta: { mode: pos.mode, leg: "long", stop: true, response: closeLongResult } });
|
|
725
|
+
closeLongOk = true;
|
|
688
726
|
}
|
|
689
727
|
catch (err) {
|
|
690
728
|
const msg = err instanceof Error ? err.message : String(err);
|
|
691
729
|
ctx.log(` [ARB] Close on stop failed (long) for ${pos.symbol}: ${msg}`);
|
|
692
730
|
logExecution({ type: "arb_close", exchange: pos.longExchange, symbol: pos.symbol, side: "sell", size: pos.size, status: "failed", error: msg, dryRun: false, meta: { mode: pos.mode, leg: "long", stop: true } });
|
|
693
731
|
}
|
|
732
|
+
if (!closeLongOk) {
|
|
733
|
+
ctx.log(` [ARB] CRITICAL: ${pos.symbol} long close failed on stop — skipping short close to preserve hedge`);
|
|
734
|
+
remainingPositions.push(pos);
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
let closeShortOk = false;
|
|
694
738
|
try {
|
|
695
|
-
const closeShortResult = await shortAdapter.marketOrder(pos.symbol, "buy", pos.size);
|
|
739
|
+
const closeShortResult = await shortAdapter.marketOrder(pos.symbol, "buy", pos.size, { reduceOnly: true });
|
|
696
740
|
logExecution({ type: "arb_close", exchange: pos.shortExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false, meta: { mode: pos.mode, leg: "short", stop: true, response: closeShortResult } });
|
|
741
|
+
closeShortOk = true;
|
|
697
742
|
}
|
|
698
743
|
catch (err) {
|
|
699
744
|
const msg = err instanceof Error ? err.message : String(err);
|
|
700
|
-
ctx.log(` [ARB] Close on stop failed (short) for ${pos.symbol}: ${msg}`);
|
|
745
|
+
ctx.log(` [ARB] Close on stop failed (short) for ${pos.symbol}: ${msg} — re-opening long to restore hedge`);
|
|
701
746
|
logExecution({ type: "arb_close", exchange: pos.shortExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "failed", error: msg, dryRun: false, meta: { mode: pos.mode, leg: "short", stop: true } });
|
|
747
|
+
// Rollback: re-open long to restore hedge
|
|
748
|
+
try {
|
|
749
|
+
await longAdapter.marketOrder(pos.symbol, "buy", pos.size);
|
|
750
|
+
ctx.log(` [ARB] Rollback: re-opened long ${pos.size} ${pos.symbol} on ${pos.longExchange}`);
|
|
751
|
+
logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false });
|
|
752
|
+
}
|
|
753
|
+
catch (rbErr) {
|
|
754
|
+
const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
|
|
755
|
+
ctx.log(` [ARB] CRITICAL: Stop rollback failed for ${pos.symbol}: ${rbMsg}`);
|
|
756
|
+
logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "failed", error: rbMsg, dryRun: false });
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
if (!closeShortOk) {
|
|
760
|
+
remainingPositions.push(pos);
|
|
702
761
|
}
|
|
703
762
|
}
|
|
763
|
+
else {
|
|
764
|
+
ctx.log(` [ARB] CRITICAL: ${pos.symbol} adapters not found on stop — requires manual cleanup`);
|
|
765
|
+
remainingPositions.push(pos);
|
|
766
|
+
}
|
|
704
767
|
}
|
|
705
768
|
}
|
|
706
|
-
ctx.state.set("arbOpenPositions",
|
|
707
|
-
ctx.state.set("arbPositions",
|
|
769
|
+
ctx.state.set("arbOpenPositions", remainingPositions);
|
|
770
|
+
ctx.state.set("arbPositions", remainingPositions.length);
|
|
708
771
|
}
|
|
709
772
|
// Cancel on extra adapters directly
|
|
710
773
|
if (extraAdapters) {
|
|
@@ -717,29 +780,34 @@ export class FundingArbStrategy {
|
|
|
717
780
|
}
|
|
718
781
|
return [{ type: "cancel_all" }];
|
|
719
782
|
}
|
|
720
|
-
/** Close a spot-perp position: sell spot, transfer USDC back, close perp short
|
|
783
|
+
/** Close a spot-perp position: sell spot, transfer USDC back, close perp short.
|
|
784
|
+
* Returns true only if ALL steps succeed. If spot sell succeeds but perp close fails,
|
|
785
|
+
* attempts to re-buy spot as rollback and returns false. */
|
|
721
786
|
async closeSpotPerp(pos, adapters, ctx) {
|
|
722
787
|
const exchangeName = pos.longExchange.replace("-spot", "");
|
|
723
788
|
const perpAdapter = adapters.get(pos.shortExchange) ?? adapters.get(exchangeName);
|
|
724
789
|
if (!perpAdapter) {
|
|
725
790
|
ctx.log(` [ARB] Close spot-perp failed: no adapter for ${pos.shortExchange}`);
|
|
726
|
-
return;
|
|
791
|
+
return false;
|
|
727
792
|
}
|
|
728
793
|
try {
|
|
729
794
|
const spotAdapter = await this.getSpotAdapter(exchangeName, perpAdapter);
|
|
730
795
|
if (!spotAdapter) {
|
|
731
796
|
ctx.log(` [ARB] Close spot-perp failed: spot adapter not available for ${exchangeName}`);
|
|
732
|
-
return;
|
|
797
|
+
return false;
|
|
733
798
|
}
|
|
734
|
-
ctx.log(` [ARB] Closing spot-perp ${pos.symbol}
|
|
799
|
+
ctx.log(` [ARB] Closing spot-perp ${pos.symbol} (spot sell + perp buy)`);
|
|
735
800
|
// Step 1: Sell spot
|
|
801
|
+
let spotSold = false;
|
|
736
802
|
try {
|
|
737
803
|
await spotAdapter.spotMarketOrder(pos.symbol, "sell", pos.size);
|
|
738
804
|
ctx.log(` [ARB] Sold spot ${pos.size} ${pos.symbol}`);
|
|
805
|
+
spotSold = true;
|
|
739
806
|
}
|
|
740
807
|
catch (err) {
|
|
741
808
|
const msg = err instanceof Error ? err.message : String(err);
|
|
742
809
|
ctx.log(` [ARB] Spot sell failed for ${pos.symbol}: ${msg}`);
|
|
810
|
+
return false;
|
|
743
811
|
}
|
|
744
812
|
// Step 2: Transfer USDC back to perp (use 95% of notional as estimate)
|
|
745
813
|
try {
|
|
@@ -754,21 +822,36 @@ export class FundingArbStrategy {
|
|
|
754
822
|
catch (err) {
|
|
755
823
|
const msg = err instanceof Error ? err.message : String(err);
|
|
756
824
|
ctx.log(` [ARB] USDC transfer back failed: ${msg}`);
|
|
825
|
+
// Non-fatal: continue to close perp short
|
|
757
826
|
}
|
|
758
827
|
// Step 3: Close perp short
|
|
828
|
+
const perpSymbol = this.getPerpSymbol(pos.symbol, pos.shortExchange);
|
|
759
829
|
try {
|
|
760
|
-
|
|
761
|
-
await perpAdapter.marketOrder(perpSymbol, "buy", pos.size);
|
|
830
|
+
await perpAdapter.marketOrder(perpSymbol, "buy", pos.size, { reduceOnly: true });
|
|
762
831
|
ctx.log(` [ARB] Closed perp short ${pos.size} ${perpSymbol}`);
|
|
832
|
+
return true;
|
|
763
833
|
}
|
|
764
834
|
catch (err) {
|
|
765
835
|
const msg = err instanceof Error ? err.message : String(err);
|
|
766
836
|
ctx.log(` [ARB] Perp close failed for ${pos.symbol}: ${msg}`);
|
|
837
|
+
// Rollback: re-buy spot to restore hedge
|
|
838
|
+
if (spotSold) {
|
|
839
|
+
try {
|
|
840
|
+
await spotAdapter.spotMarketOrder(pos.symbol, "buy", pos.size);
|
|
841
|
+
ctx.log(` [ARB] Rollback: re-bought spot ${pos.size} ${pos.symbol} on ${exchangeName}`);
|
|
842
|
+
}
|
|
843
|
+
catch (rbErr) {
|
|
844
|
+
const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
|
|
845
|
+
ctx.log(` [ARB] CRITICAL: Spot-perp close rollback failed for ${pos.symbol}: ${rbMsg}`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return false;
|
|
767
849
|
}
|
|
768
850
|
}
|
|
769
851
|
catch (err) {
|
|
770
852
|
const msg = err instanceof Error ? err.message : String(err);
|
|
771
853
|
ctx.log(` [ARB] Close spot-perp failed for ${pos.symbol}: ${msg}`);
|
|
854
|
+
return false;
|
|
772
855
|
}
|
|
773
856
|
}
|
|
774
857
|
/** Open a spot-perp position: transfer USDC to spot, buy spot, short perp */
|
|
@@ -797,7 +880,11 @@ export class FundingArbStrategy {
|
|
|
797
880
|
try {
|
|
798
881
|
await perpAdapter.setLeverage(this.getPerpSymbol(opp.symbol, opp.shortExchange), leverage, "cross");
|
|
799
882
|
}
|
|
800
|
-
catch {
|
|
883
|
+
catch (err) {
|
|
884
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
885
|
+
ctx.log(` [ARB] setLeverage failed on ${opp.shortExchange} for ${opp.symbol}: ${msg}`);
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
801
888
|
// Balance check: spot needs full notional, perp needs sizeUsd / leverage margin
|
|
802
889
|
let targetSizeUsd = params.size_usd;
|
|
803
890
|
try {
|
|
@@ -805,7 +892,7 @@ export class FundingArbStrategy {
|
|
|
805
892
|
// Check margin usage
|
|
806
893
|
const perpEquity = parseFloat(perpBal.equity);
|
|
807
894
|
const perpMarginPct = perpEquity > 0 ? parseFloat(perpBal.marginUsed) / perpEquity * 100 : 0;
|
|
808
|
-
if (perpMarginPct >
|
|
895
|
+
if (perpMarginPct > 90) {
|
|
809
896
|
ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: perp margin usage too high (${perpMarginPct.toFixed(0)}%)`);
|
|
810
897
|
return false;
|
|
811
898
|
}
|
|
@@ -824,7 +911,11 @@ export class FundingArbStrategy {
|
|
|
824
911
|
targetSizeUsd = Math.floor(maxNotional);
|
|
825
912
|
}
|
|
826
913
|
}
|
|
827
|
-
catch {
|
|
914
|
+
catch (err) {
|
|
915
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
916
|
+
ctx.log(` [ARB] Balance check failed for spot-perp ${opp.symbol}: ${msg}`);
|
|
917
|
+
return false;
|
|
918
|
+
}
|
|
828
919
|
const perpSymbol = this.getPerpSymbol(opp.symbol, opp.shortExchange);
|
|
829
920
|
const priceEstimate = await this.getPriceEstimate(perpAdapter, perpSymbol, opp.symbol);
|
|
830
921
|
if (priceEstimate <= 0) {
|
|
@@ -952,25 +1043,32 @@ export class FundingArbStrategy {
|
|
|
952
1043
|
* Returns null if the exchange doesn't support spot or instantiation fails.
|
|
953
1044
|
*/
|
|
954
1045
|
async getSpotAdapter(name, adapter) {
|
|
1046
|
+
const cached = this._spotAdapterCache.get(name);
|
|
1047
|
+
if (cached)
|
|
1048
|
+
return cached;
|
|
955
1049
|
try {
|
|
1050
|
+
let spot = null;
|
|
956
1051
|
if (name === "hyperliquid") {
|
|
957
1052
|
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
|
|
958
1053
|
const { HyperliquidAdapter } = await import("../../exchanges/hyperliquid.js");
|
|
959
1054
|
if (adapter instanceof HyperliquidAdapter) {
|
|
960
|
-
const
|
|
961
|
-
await
|
|
962
|
-
|
|
1055
|
+
const instance = new HyperliquidSpotAdapter(adapter);
|
|
1056
|
+
await instance.init();
|
|
1057
|
+
spot = instance;
|
|
963
1058
|
}
|
|
964
1059
|
}
|
|
965
1060
|
else if (name === "lighter") {
|
|
966
1061
|
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
|
|
967
1062
|
const { LighterAdapter } = await import("../../exchanges/lighter.js");
|
|
968
1063
|
if (adapter instanceof LighterAdapter) {
|
|
969
|
-
const
|
|
970
|
-
await
|
|
971
|
-
|
|
1064
|
+
const instance = new LighterSpotAdapter(adapter);
|
|
1065
|
+
await instance.init();
|
|
1066
|
+
spot = instance;
|
|
972
1067
|
}
|
|
973
1068
|
}
|
|
1069
|
+
if (spot)
|
|
1070
|
+
this._spotAdapterCache.set(name, spot);
|
|
1071
|
+
return spot;
|
|
974
1072
|
}
|
|
975
1073
|
catch { /* not supported */ }
|
|
976
1074
|
return null;
|
|
@@ -1010,12 +1108,22 @@ export class FundingArbStrategy {
|
|
|
1010
1108
|
return 0;
|
|
1011
1109
|
}
|
|
1012
1110
|
}
|
|
1013
|
-
async fetchRates(adapter,
|
|
1111
|
+
async fetchRates(adapter, exchangeName) {
|
|
1014
1112
|
try {
|
|
1015
1113
|
const markets = await adapter.getMarkets();
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1114
|
+
const withRates = markets.filter(m => m.fundingRate != null);
|
|
1115
|
+
// Bootstrap aster funding hours lazily (1 API call per unknown symbol, cached permanently)
|
|
1116
|
+
if (exchangeName === "aster" && "getFundingHours" in adapter) {
|
|
1117
|
+
const aster = adapter;
|
|
1118
|
+
// Only bootstrap symbols not yet cached (up to 5 per tick to limit API calls)
|
|
1119
|
+
const uncached = withRates.filter(m => m.fundingHours === undefined || m.fundingHours === 1);
|
|
1120
|
+
const toBootstrap = uncached.slice(0, 5);
|
|
1121
|
+
for (const m of toBootstrap) {
|
|
1122
|
+
const fh = await aster.getFundingHours(m.symbol);
|
|
1123
|
+
m.fundingHours = fh;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
return withRates.map(m => ({
|
|
1019
1127
|
symbol: m.symbol,
|
|
1020
1128
|
rate: parseFloat(m.fundingRate),
|
|
1021
1129
|
price: parseFloat(m.markPrice),
|
|
@@ -14,6 +14,7 @@ export declare class AsterAdapter implements ExchangeAdapter {
|
|
|
14
14
|
private _testnet;
|
|
15
15
|
private _marketsCache;
|
|
16
16
|
private _marketsCacheTime;
|
|
17
|
+
private _fundingHoursCache;
|
|
17
18
|
private _accountCache;
|
|
18
19
|
private _positionsCache;
|
|
19
20
|
private _ordersCache;
|
|
@@ -26,6 +27,8 @@ export declare class AsterAdapter implements ExchangeAdapter {
|
|
|
26
27
|
/** Aster API symbol → CLI symbol (ETHUSDT → ETH) */
|
|
27
28
|
private _fromApi;
|
|
28
29
|
init(): Promise<void>;
|
|
30
|
+
/** Get funding interval for a symbol (lazy bootstrap — queries on first access, cached permanently). */
|
|
31
|
+
getFundingHours(symbol: string): Promise<number>;
|
|
29
32
|
getMarkets(): Promise<ExchangeMarketInfo[]>;
|
|
30
33
|
getOrderbook(symbol: string): Promise<{
|
|
31
34
|
bids: [string, string][];
|
|
@@ -44,7 +47,9 @@ export declare class AsterAdapter implements ExchangeAdapter {
|
|
|
44
47
|
getOrderHistory(limit?: number): Promise<ExchangeOrder[]>;
|
|
45
48
|
getTradeHistory(limit?: number): Promise<ExchangeTrade[]>;
|
|
46
49
|
getFundingPayments(limit?: number): Promise<ExchangeFundingPayment[]>;
|
|
47
|
-
marketOrder(symbol: string, side: "buy" | "sell", size: string
|
|
50
|
+
marketOrder(symbol: string, side: "buy" | "sell", size: string, opts?: {
|
|
51
|
+
reduceOnly?: boolean;
|
|
52
|
+
}): Promise<unknown>;
|
|
48
53
|
limitOrder(symbol: string, side: "buy" | "sell", price: string, size: string, opts?: {
|
|
49
54
|
reduceOnly?: boolean;
|
|
50
55
|
tif?: string;
|
package/dist/exchanges/aster.js
CHANGED
|
@@ -14,6 +14,7 @@ export class AsterAdapter {
|
|
|
14
14
|
_testnet;
|
|
15
15
|
_marketsCache = null;
|
|
16
16
|
_marketsCacheTime = 0;
|
|
17
|
+
_fundingHoursCache = new Map();
|
|
17
18
|
_accountCache = null;
|
|
18
19
|
_positionsCache = null;
|
|
19
20
|
_ordersCache = null;
|
|
@@ -46,6 +47,26 @@ export class AsterAdapter {
|
|
|
46
47
|
await this._publicGet("/fapi/v1/time");
|
|
47
48
|
}
|
|
48
49
|
// ── Market Data ──
|
|
50
|
+
/** Get funding interval for a symbol (lazy bootstrap — queries on first access, cached permanently). */
|
|
51
|
+
async getFundingHours(symbol) {
|
|
52
|
+
const key = symbol.toUpperCase();
|
|
53
|
+
const cached = this._fundingHoursCache.get(key);
|
|
54
|
+
if (cached !== undefined)
|
|
55
|
+
return cached;
|
|
56
|
+
try {
|
|
57
|
+
const apiSym = this._toApi(key);
|
|
58
|
+
const data = await this._publicGet("/fapi/v1/fundingRate", { symbol: apiSym, limit: "2" });
|
|
59
|
+
if (Array.isArray(data) && data.length >= 2) {
|
|
60
|
+
const hours = Math.abs(data[1].fundingTime - data[0].fundingTime) / 3600000;
|
|
61
|
+
const rounded = hours <= 1.5 ? 1 : hours <= 5 ? 4 : 8;
|
|
62
|
+
this._fundingHoursCache.set(key, rounded);
|
|
63
|
+
return rounded;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch { /* non-critical */ }
|
|
67
|
+
this._fundingHoursCache.set(key, 1); // default
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
49
70
|
async getMarkets() {
|
|
50
71
|
// Return cached result if fresh
|
|
51
72
|
if (this._marketsCache && Date.now() - this._marketsCacheTime < AsterAdapter.CACHE_TTL) {
|
|
@@ -77,12 +98,9 @@ export class AsterAdapter {
|
|
|
77
98
|
const maxLev = Number(s.maxLeverage ?? 50);
|
|
78
99
|
const lotFilter = s.filters
|
|
79
100
|
?.find(f => f.filterType === "LOT_SIZE");
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const fundingHours =
|
|
83
|
-
: hoursFromMidnight % 8 === 0 ? 8
|
|
84
|
-
: hoursFromMidnight % 4 === 0 ? 4
|
|
85
|
-
: 1;
|
|
101
|
+
// Use cached funding interval if available (populated by loadFundingIntervals()),
|
|
102
|
+
// otherwise default to 1h.
|
|
103
|
+
const fundingHours = this._fundingHoursCache.get(this._fromApi(sym)) ?? 1;
|
|
86
104
|
return {
|
|
87
105
|
symbol: this._fromApi(sym),
|
|
88
106
|
markPrice: String(premium?.markPrice ?? ticker?.lastPrice ?? "0"),
|
|
@@ -283,14 +301,17 @@ export class AsterAdapter {
|
|
|
283
301
|
}));
|
|
284
302
|
}
|
|
285
303
|
// ── Trading ──
|
|
286
|
-
async marketOrder(symbol, side, size) {
|
|
304
|
+
async marketOrder(symbol, side, size, opts) {
|
|
287
305
|
const apiSymbol = this._toApi(symbol);
|
|
288
|
-
const
|
|
306
|
+
const params = {
|
|
289
307
|
symbol: apiSymbol,
|
|
290
308
|
side: side.toUpperCase(),
|
|
291
309
|
type: "MARKET",
|
|
292
310
|
quantity: size,
|
|
293
|
-
}
|
|
311
|
+
};
|
|
312
|
+
if (opts?.reduceOnly)
|
|
313
|
+
params.reduceOnly = "true";
|
|
314
|
+
const result = await this._signedPost("/fapi/v1/order", params);
|
|
294
315
|
const r = result;
|
|
295
316
|
const executedQty = Number(r.executedQty ?? 0);
|
|
296
317
|
const orderId = String(r.orderId ?? "");
|
|
@@ -55,7 +55,9 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
|
|
|
55
55
|
getPositions(): Promise<ExchangePosition[]>;
|
|
56
56
|
getOpenOrders(): Promise<ExchangeOrder[]>;
|
|
57
57
|
private _invalidateAccountCache;
|
|
58
|
-
marketOrder(symbol: string, side: "buy" | "sell", size: string
|
|
58
|
+
marketOrder(symbol: string, side: "buy" | "sell", size: string, opts?: {
|
|
59
|
+
reduceOnly?: boolean;
|
|
60
|
+
}): Promise<unknown>;
|
|
59
61
|
/**
|
|
60
62
|
* Place a market order on a HIP-3 deployed dex.
|
|
61
63
|
* Bypasses SDK's symbolConversion (which only knows native perps)
|
|
@@ -393,7 +393,7 @@ export class HyperliquidAdapter {
|
|
|
393
393
|
}
|
|
394
394
|
catch { /* ignore */ }
|
|
395
395
|
}
|
|
396
|
-
async marketOrder(symbol, side, size) {
|
|
396
|
+
async marketOrder(symbol, side, size, opts) {
|
|
397
397
|
this.ensureSigner();
|
|
398
398
|
if (this._dex) {
|
|
399
399
|
const r = await this._dexMarketOrder(symbol, side, size);
|
|
@@ -85,7 +85,9 @@ export interface ExchangeAdapter {
|
|
|
85
85
|
getOrderHistory(limit?: number): Promise<ExchangeOrder[]>;
|
|
86
86
|
getTradeHistory(limit?: number): Promise<ExchangeTrade[]>;
|
|
87
87
|
getFundingPayments(limit?: number): Promise<ExchangeFundingPayment[]>;
|
|
88
|
-
marketOrder(symbol: string, side: "buy" | "sell", size: string
|
|
88
|
+
marketOrder(symbol: string, side: "buy" | "sell", size: string, opts?: {
|
|
89
|
+
reduceOnly?: boolean;
|
|
90
|
+
}): Promise<unknown>;
|
|
89
91
|
limitOrder(symbol: string, side: "buy" | "sell", price: string, size: string, opts?: {
|
|
90
92
|
reduceOnly?: boolean;
|
|
91
93
|
tif?: string;
|
|
@@ -62,7 +62,9 @@ export declare class LighterAdapter implements ExchangeAdapter {
|
|
|
62
62
|
private getAuthToken;
|
|
63
63
|
private restGetAuth;
|
|
64
64
|
getOpenOrders(): Promise<ExchangeOrder[]>;
|
|
65
|
-
marketOrder(symbol: string, side: "buy" | "sell", size: string
|
|
65
|
+
marketOrder(symbol: string, side: "buy" | "sell", size: string, opts?: {
|
|
66
|
+
reduceOnly?: boolean;
|
|
67
|
+
}): Promise<unknown>;
|
|
66
68
|
limitOrder(symbol: string, side: "buy" | "sell", price: string, size: string, opts?: {
|
|
67
69
|
reduceOnly?: boolean;
|
|
68
70
|
tif?: string;
|
|
@@ -454,8 +454,9 @@ export class LighterAdapter {
|
|
|
454
454
|
return [];
|
|
455
455
|
}
|
|
456
456
|
}
|
|
457
|
-
async marketOrder(symbol, side, size) {
|
|
457
|
+
async marketOrder(symbol, side, size, opts) {
|
|
458
458
|
this.ensureSigner();
|
|
459
|
+
void opts; // lighter doesn't support reduceOnly on market orders
|
|
459
460
|
await this.ensureMarketMap();
|
|
460
461
|
const nonce = await this.getNextNonce();
|
|
461
462
|
const marketIndex = this.getMarketIndex(symbol);
|
|
@@ -33,7 +33,9 @@ export declare class PacificaAdapter implements ExchangeAdapter {
|
|
|
33
33
|
getBalance(): Promise<ExchangeBalance>;
|
|
34
34
|
getPositions(): Promise<ExchangePosition[]>;
|
|
35
35
|
getOpenOrders(): Promise<ExchangeOrder[]>;
|
|
36
|
-
marketOrder(symbol: string, side: "buy" | "sell", size: string
|
|
36
|
+
marketOrder(symbol: string, side: "buy" | "sell", size: string, opts?: {
|
|
37
|
+
reduceOnly?: boolean;
|
|
38
|
+
}): Promise<unknown>;
|
|
37
39
|
limitOrder(symbol: string, side: "buy" | "sell", price: string, size: string, opts?: {
|
|
38
40
|
reduceOnly?: boolean;
|
|
39
41
|
tif?: string;
|
|
@@ -180,9 +180,9 @@ export class PacificaAdapter {
|
|
|
180
180
|
};
|
|
181
181
|
});
|
|
182
182
|
}
|
|
183
|
-
async marketOrder(symbol, side, size) {
|
|
183
|
+
async marketOrder(symbol, side, size, opts) {
|
|
184
184
|
this.ensureSigner();
|
|
185
|
-
return this.client.createMarketOrder({ symbol, amount: size, side: side === "buy" ? "bid" : "ask", reduce_only: false, slippage_percent: "1" }, this.account, this.signMessage);
|
|
185
|
+
return this.client.createMarketOrder({ symbol, amount: size, side: side === "buy" ? "bid" : "ask", reduce_only: opts?.reduceOnly ?? false, slippage_percent: "1" }, this.account, this.signMessage);
|
|
186
186
|
}
|
|
187
187
|
async limitOrder(symbol, side, price, size, opts) {
|
|
188
188
|
this.ensureSigner();
|