perp-cli 0.7.43 → 0.7.48
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.
|
@@ -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,14 +326,17 @@ 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);
|
|
330
335
|
const shortAdapter = adapters.get(pos.shortExchange);
|
|
331
336
|
if (longAdapter && shortAdapter) {
|
|
337
|
+
const isPositionGone = (msg) => msg.includes("ReduceOnly") || msg.includes("reduceOnly") || msg.includes("-2022");
|
|
332
338
|
let closeLongOk = false;
|
|
339
|
+
let longPositionGone = false;
|
|
333
340
|
try {
|
|
334
341
|
const closeLongResult = await longAdapter.marketOrder(pos.symbol, "sell", pos.size, { reduceOnly: true });
|
|
335
342
|
closeLongOk = true;
|
|
@@ -337,11 +344,18 @@ export class FundingArbStrategy {
|
|
|
337
344
|
}
|
|
338
345
|
catch (err) {
|
|
339
346
|
const msg = err instanceof Error ? err.message : String(err);
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
347
|
+
if (isPositionGone(msg)) {
|
|
348
|
+
// Position already liquidated/closed on this exchange
|
|
349
|
+
longPositionGone = true;
|
|
350
|
+
ctx.log(` [ARB] Long position gone for ${pos.symbol} on ${pos.longExchange} (liquidated?) — force closing short`);
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
ctx.log(` [ARB] Close long leg failed for ${pos.symbol}: ${msg} (cooldown 5m)`);
|
|
354
|
+
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" } });
|
|
355
|
+
this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
|
|
356
|
+
}
|
|
343
357
|
}
|
|
344
|
-
if (!closeLongOk)
|
|
358
|
+
if (!closeLongOk && !longPositionGone)
|
|
345
359
|
continue;
|
|
346
360
|
try {
|
|
347
361
|
const closeShortResult = await shortAdapter.marketOrder(pos.symbol, "buy", pos.size, { reduceOnly: true });
|
|
@@ -349,25 +363,44 @@ export class FundingArbStrategy {
|
|
|
349
363
|
}
|
|
350
364
|
catch (err) {
|
|
351
365
|
const msg = err instanceof Error ? err.message : String(err);
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
try {
|
|
356
|
-
await longAdapter.marketOrder(pos.symbol, "buy", pos.size);
|
|
357
|
-
ctx.log(` [ARB] Rollback: re-opened long ${pos.size} ${pos.symbol} on ${pos.longExchange}`);
|
|
358
|
-
// Note: re-open is NOT reduceOnly (it's a new position)
|
|
359
|
-
logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false });
|
|
366
|
+
if (isPositionGone(msg)) {
|
|
367
|
+
// Short position also gone — both liquidated, just clean up tracking
|
|
368
|
+
ctx.log(` [ARB] Short position also gone for ${pos.symbol} on ${pos.shortExchange} — removing from tracking`);
|
|
360
369
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
ctx.log(` [ARB]
|
|
364
|
-
logExecution({ type: "
|
|
370
|
+
else if (closeLongOk) {
|
|
371
|
+
// Long was closed but short failed (not liquidated) — rollback long
|
|
372
|
+
ctx.log(` [ARB] Close short leg failed for ${pos.symbol}: ${msg} — re-opening long to restore hedge`);
|
|
373
|
+
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" } });
|
|
374
|
+
try {
|
|
375
|
+
await longAdapter.marketOrder(pos.symbol, "buy", pos.size);
|
|
376
|
+
ctx.log(` [ARB] Rollback: re-opened long ${pos.size} ${pos.symbol} on ${pos.longExchange}`);
|
|
377
|
+
logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false });
|
|
378
|
+
}
|
|
379
|
+
catch (rbErr) {
|
|
380
|
+
const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
|
|
381
|
+
ctx.log(` [ARB] CRITICAL: Close rollback failed for ${pos.symbol}: ${rbMsg}`);
|
|
382
|
+
logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "failed", error: rbMsg, dryRun: false });
|
|
383
|
+
}
|
|
384
|
+
this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
// Long was gone, short failed (not liquidated) — orphaned short, force close without reduceOnly
|
|
389
|
+
ctx.log(` [ARB] Force closing orphaned short for ${pos.symbol}: ${msg}`);
|
|
390
|
+
try {
|
|
391
|
+
await shortAdapter.marketOrder(pos.symbol, "buy", pos.size);
|
|
392
|
+
ctx.log(` [ARB] Force closed short ${pos.size} ${pos.symbol} on ${pos.shortExchange}`);
|
|
393
|
+
}
|
|
394
|
+
catch (forceErr) {
|
|
395
|
+
const forceMsg = forceErr instanceof Error ? forceErr.message : String(forceErr);
|
|
396
|
+
ctx.log(` [ARB] CRITICAL: Force close short failed for ${pos.symbol}: ${forceMsg}`);
|
|
397
|
+
this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 10 * 60 * 1000);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
365
400
|
}
|
|
366
|
-
this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
|
|
367
|
-
continue;
|
|
368
401
|
}
|
|
369
402
|
toClose.push(pos);
|
|
370
|
-
ctx.log(` [ARB] Closed ${pos.symbol} position`);
|
|
403
|
+
ctx.log(` [ARB] Closed ${pos.symbol} position${longPositionGone ? ' (long was liquidated)' : ''}`);
|
|
371
404
|
}
|
|
372
405
|
}
|
|
373
406
|
}
|
|
@@ -510,12 +543,23 @@ export class FundingArbStrategy {
|
|
|
510
543
|
const leverage = Math.min(configLeverage, longMaxLev, shortMaxLev);
|
|
511
544
|
// Set leverage on both exchanges before ordering
|
|
512
545
|
try {
|
|
513
|
-
await
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
546
|
+
await longAdapter.setLeverage(best.symbol, leverage, "cross");
|
|
547
|
+
}
|
|
548
|
+
catch (err) {
|
|
549
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
550
|
+
ctx.log(` [ARB] setLeverage failed on ${best.longExchange} for ${best.symbol}: ${msg}`);
|
|
551
|
+
this._failCooldown.set(`${best.symbol}:entry`, Date.now() + 5 * 60 * 1000);
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
await shortAdapter.setLeverage(best.symbol, leverage, "cross");
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
559
|
+
ctx.log(` [ARB] setLeverage failed on ${best.shortExchange} for ${best.symbol}: ${msg}`);
|
|
560
|
+
this._failCooldown.set(`${best.symbol}:entry`, Date.now() + 5 * 60 * 1000);
|
|
561
|
+
continue;
|
|
517
562
|
}
|
|
518
|
-
catch { /* non-critical, some exchanges set leverage on order */ }
|
|
519
563
|
// Balance + margin check (use pre-fetched balances from tick start)
|
|
520
564
|
let targetSizeUsd = params.size_usd;
|
|
521
565
|
const longBal = exchangeBalances.get(best.longExchange);
|
|
@@ -579,6 +623,20 @@ export class FundingArbStrategy {
|
|
|
579
623
|
const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
|
|
580
624
|
ctx.log(` [ARB] CRITICAL: Rollback failed for ${best.symbol}: ${rbMsg}`);
|
|
581
625
|
logExecution({ type: "multi_leg_rollback", exchange: best.longExchange, symbol: best.symbol, side: "sell", size: matched.size, status: "failed", error: rbMsg, dryRun: false });
|
|
626
|
+
// Track orphaned long position for manual cleanup
|
|
627
|
+
const currentPositions = ctx.state.get("arbOpenPositions");
|
|
628
|
+
currentPositions.push({
|
|
629
|
+
symbol: best.symbol,
|
|
630
|
+
longExchange: best.longExchange,
|
|
631
|
+
shortExchange: best.shortExchange, // note: short leg doesn't exist
|
|
632
|
+
entrySpread: 0,
|
|
633
|
+
size: matched.size,
|
|
634
|
+
mode: "perp-perp",
|
|
635
|
+
entryTime: 0, // flag as orphaned
|
|
636
|
+
});
|
|
637
|
+
ctx.state.set("arbOpenPositions", currentPositions);
|
|
638
|
+
ctx.state.set("arbPositions", currentPositions.length);
|
|
639
|
+
ctx.log(` [ARB] Tracking orphaned long position for manual cleanup`);
|
|
582
640
|
}
|
|
583
641
|
this._failCooldown.set(`${best.symbol}:entry`, Date.now() + 5 * 60 * 1000);
|
|
584
642
|
continue;
|
|
@@ -674,38 +732,70 @@ export class FundingArbStrategy {
|
|
|
674
732
|
for (const [name, a] of extraAdapters)
|
|
675
733
|
adapters.set(name, a);
|
|
676
734
|
}
|
|
735
|
+
const remainingPositions = [];
|
|
677
736
|
for (const pos of openPositions) {
|
|
678
737
|
if (pos.mode === "spot-perp") {
|
|
679
|
-
await this.closeSpotPerp(pos, adapters, ctx);
|
|
738
|
+
const closed = await this.closeSpotPerp(pos, adapters, ctx);
|
|
739
|
+
if (!closed) {
|
|
740
|
+
ctx.log(` [ARB] CRITICAL: spot-perp ${pos.symbol} could not be fully closed on stop — requires manual cleanup`);
|
|
741
|
+
remainingPositions.push(pos);
|
|
742
|
+
}
|
|
680
743
|
}
|
|
681
744
|
else {
|
|
682
745
|
const longAdapter = adapters.get(pos.longExchange);
|
|
683
746
|
const shortAdapter = adapters.get(pos.shortExchange);
|
|
684
747
|
if (longAdapter && shortAdapter) {
|
|
685
748
|
ctx.log(` [ARB] Closing ${pos.symbol} on stop (${pos.size})`);
|
|
749
|
+
let closeLongOk = false;
|
|
686
750
|
try {
|
|
687
751
|
const closeLongResult = await longAdapter.marketOrder(pos.symbol, "sell", pos.size, { reduceOnly: true });
|
|
688
752
|
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 } });
|
|
753
|
+
closeLongOk = true;
|
|
689
754
|
}
|
|
690
755
|
catch (err) {
|
|
691
756
|
const msg = err instanceof Error ? err.message : String(err);
|
|
692
757
|
ctx.log(` [ARB] Close on stop failed (long) for ${pos.symbol}: ${msg}`);
|
|
693
758
|
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 } });
|
|
694
759
|
}
|
|
760
|
+
if (!closeLongOk) {
|
|
761
|
+
ctx.log(` [ARB] CRITICAL: ${pos.symbol} long close failed on stop — skipping short close to preserve hedge`);
|
|
762
|
+
remainingPositions.push(pos);
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
let closeShortOk = false;
|
|
695
766
|
try {
|
|
696
767
|
const closeShortResult = await shortAdapter.marketOrder(pos.symbol, "buy", pos.size, { reduceOnly: true });
|
|
697
768
|
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 } });
|
|
769
|
+
closeShortOk = true;
|
|
698
770
|
}
|
|
699
771
|
catch (err) {
|
|
700
772
|
const msg = err instanceof Error ? err.message : String(err);
|
|
701
|
-
ctx.log(` [ARB] Close on stop failed (short) for ${pos.symbol}: ${msg}`);
|
|
773
|
+
ctx.log(` [ARB] Close on stop failed (short) for ${pos.symbol}: ${msg} — re-opening long to restore hedge`);
|
|
702
774
|
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 } });
|
|
775
|
+
// Rollback: re-open long to restore hedge
|
|
776
|
+
try {
|
|
777
|
+
await longAdapter.marketOrder(pos.symbol, "buy", pos.size);
|
|
778
|
+
ctx.log(` [ARB] Rollback: re-opened long ${pos.size} ${pos.symbol} on ${pos.longExchange}`);
|
|
779
|
+
logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false });
|
|
780
|
+
}
|
|
781
|
+
catch (rbErr) {
|
|
782
|
+
const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
|
|
783
|
+
ctx.log(` [ARB] CRITICAL: Stop rollback failed for ${pos.symbol}: ${rbMsg}`);
|
|
784
|
+
logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "failed", error: rbMsg, dryRun: false });
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (!closeShortOk) {
|
|
788
|
+
remainingPositions.push(pos);
|
|
703
789
|
}
|
|
704
790
|
}
|
|
791
|
+
else {
|
|
792
|
+
ctx.log(` [ARB] CRITICAL: ${pos.symbol} adapters not found on stop — requires manual cleanup`);
|
|
793
|
+
remainingPositions.push(pos);
|
|
794
|
+
}
|
|
705
795
|
}
|
|
706
796
|
}
|
|
707
|
-
ctx.state.set("arbOpenPositions",
|
|
708
|
-
ctx.state.set("arbPositions",
|
|
797
|
+
ctx.state.set("arbOpenPositions", remainingPositions);
|
|
798
|
+
ctx.state.set("arbPositions", remainingPositions.length);
|
|
709
799
|
}
|
|
710
800
|
// Cancel on extra adapters directly
|
|
711
801
|
if (extraAdapters) {
|
|
@@ -718,29 +808,34 @@ export class FundingArbStrategy {
|
|
|
718
808
|
}
|
|
719
809
|
return [{ type: "cancel_all" }];
|
|
720
810
|
}
|
|
721
|
-
/** Close a spot-perp position: sell spot, transfer USDC back, close perp short
|
|
811
|
+
/** Close a spot-perp position: sell spot, transfer USDC back, close perp short.
|
|
812
|
+
* Returns true only if ALL steps succeed. If spot sell succeeds but perp close fails,
|
|
813
|
+
* attempts to re-buy spot as rollback and returns false. */
|
|
722
814
|
async closeSpotPerp(pos, adapters, ctx) {
|
|
723
815
|
const exchangeName = pos.longExchange.replace("-spot", "");
|
|
724
816
|
const perpAdapter = adapters.get(pos.shortExchange) ?? adapters.get(exchangeName);
|
|
725
817
|
if (!perpAdapter) {
|
|
726
818
|
ctx.log(` [ARB] Close spot-perp failed: no adapter for ${pos.shortExchange}`);
|
|
727
|
-
return;
|
|
819
|
+
return false;
|
|
728
820
|
}
|
|
729
821
|
try {
|
|
730
822
|
const spotAdapter = await this.getSpotAdapter(exchangeName, perpAdapter);
|
|
731
823
|
if (!spotAdapter) {
|
|
732
824
|
ctx.log(` [ARB] Close spot-perp failed: spot adapter not available for ${exchangeName}`);
|
|
733
|
-
return;
|
|
825
|
+
return false;
|
|
734
826
|
}
|
|
735
|
-
ctx.log(` [ARB] Closing spot-perp ${pos.symbol}
|
|
827
|
+
ctx.log(` [ARB] Closing spot-perp ${pos.symbol} (spot sell + perp buy)`);
|
|
736
828
|
// Step 1: Sell spot
|
|
829
|
+
let spotSold = false;
|
|
737
830
|
try {
|
|
738
831
|
await spotAdapter.spotMarketOrder(pos.symbol, "sell", pos.size);
|
|
739
832
|
ctx.log(` [ARB] Sold spot ${pos.size} ${pos.symbol}`);
|
|
833
|
+
spotSold = true;
|
|
740
834
|
}
|
|
741
835
|
catch (err) {
|
|
742
836
|
const msg = err instanceof Error ? err.message : String(err);
|
|
743
837
|
ctx.log(` [ARB] Spot sell failed for ${pos.symbol}: ${msg}`);
|
|
838
|
+
return false;
|
|
744
839
|
}
|
|
745
840
|
// Step 2: Transfer USDC back to perp (use 95% of notional as estimate)
|
|
746
841
|
try {
|
|
@@ -755,21 +850,36 @@ export class FundingArbStrategy {
|
|
|
755
850
|
catch (err) {
|
|
756
851
|
const msg = err instanceof Error ? err.message : String(err);
|
|
757
852
|
ctx.log(` [ARB] USDC transfer back failed: ${msg}`);
|
|
853
|
+
// Non-fatal: continue to close perp short
|
|
758
854
|
}
|
|
759
855
|
// Step 3: Close perp short
|
|
856
|
+
const perpSymbol = this.getPerpSymbol(pos.symbol, pos.shortExchange);
|
|
760
857
|
try {
|
|
761
|
-
const perpSymbol = this.getPerpSymbol(pos.symbol, pos.shortExchange);
|
|
762
858
|
await perpAdapter.marketOrder(perpSymbol, "buy", pos.size, { reduceOnly: true });
|
|
763
859
|
ctx.log(` [ARB] Closed perp short ${pos.size} ${perpSymbol}`);
|
|
860
|
+
return true;
|
|
764
861
|
}
|
|
765
862
|
catch (err) {
|
|
766
863
|
const msg = err instanceof Error ? err.message : String(err);
|
|
767
864
|
ctx.log(` [ARB] Perp close failed for ${pos.symbol}: ${msg}`);
|
|
865
|
+
// Rollback: re-buy spot to restore hedge
|
|
866
|
+
if (spotSold) {
|
|
867
|
+
try {
|
|
868
|
+
await spotAdapter.spotMarketOrder(pos.symbol, "buy", pos.size);
|
|
869
|
+
ctx.log(` [ARB] Rollback: re-bought spot ${pos.size} ${pos.symbol} on ${exchangeName}`);
|
|
870
|
+
}
|
|
871
|
+
catch (rbErr) {
|
|
872
|
+
const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
|
|
873
|
+
ctx.log(` [ARB] CRITICAL: Spot-perp close rollback failed for ${pos.symbol}: ${rbMsg}`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return false;
|
|
768
877
|
}
|
|
769
878
|
}
|
|
770
879
|
catch (err) {
|
|
771
880
|
const msg = err instanceof Error ? err.message : String(err);
|
|
772
881
|
ctx.log(` [ARB] Close spot-perp failed for ${pos.symbol}: ${msg}`);
|
|
882
|
+
return false;
|
|
773
883
|
}
|
|
774
884
|
}
|
|
775
885
|
/** Open a spot-perp position: transfer USDC to spot, buy spot, short perp */
|
|
@@ -798,7 +908,11 @@ export class FundingArbStrategy {
|
|
|
798
908
|
try {
|
|
799
909
|
await perpAdapter.setLeverage(this.getPerpSymbol(opp.symbol, opp.shortExchange), leverage, "cross");
|
|
800
910
|
}
|
|
801
|
-
catch {
|
|
911
|
+
catch (err) {
|
|
912
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
913
|
+
ctx.log(` [ARB] setLeverage failed on ${opp.shortExchange} for ${opp.symbol}: ${msg}`);
|
|
914
|
+
return false;
|
|
915
|
+
}
|
|
802
916
|
// Balance check: spot needs full notional, perp needs sizeUsd / leverage margin
|
|
803
917
|
let targetSizeUsd = params.size_usd;
|
|
804
918
|
try {
|
|
@@ -806,7 +920,7 @@ export class FundingArbStrategy {
|
|
|
806
920
|
// Check margin usage
|
|
807
921
|
const perpEquity = parseFloat(perpBal.equity);
|
|
808
922
|
const perpMarginPct = perpEquity > 0 ? parseFloat(perpBal.marginUsed) / perpEquity * 100 : 0;
|
|
809
|
-
if (perpMarginPct >
|
|
923
|
+
if (perpMarginPct > 90) {
|
|
810
924
|
ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: perp margin usage too high (${perpMarginPct.toFixed(0)}%)`);
|
|
811
925
|
return false;
|
|
812
926
|
}
|
|
@@ -825,7 +939,11 @@ export class FundingArbStrategy {
|
|
|
825
939
|
targetSizeUsd = Math.floor(maxNotional);
|
|
826
940
|
}
|
|
827
941
|
}
|
|
828
|
-
catch {
|
|
942
|
+
catch (err) {
|
|
943
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
944
|
+
ctx.log(` [ARB] Balance check failed for spot-perp ${opp.symbol}: ${msg}`);
|
|
945
|
+
return false;
|
|
946
|
+
}
|
|
829
947
|
const perpSymbol = this.getPerpSymbol(opp.symbol, opp.shortExchange);
|
|
830
948
|
const priceEstimate = await this.getPriceEstimate(perpAdapter, perpSymbol, opp.symbol);
|
|
831
949
|
if (priceEstimate <= 0) {
|
|
@@ -953,25 +1071,32 @@ export class FundingArbStrategy {
|
|
|
953
1071
|
* Returns null if the exchange doesn't support spot or instantiation fails.
|
|
954
1072
|
*/
|
|
955
1073
|
async getSpotAdapter(name, adapter) {
|
|
1074
|
+
const cached = this._spotAdapterCache.get(name);
|
|
1075
|
+
if (cached)
|
|
1076
|
+
return cached;
|
|
956
1077
|
try {
|
|
1078
|
+
let spot = null;
|
|
957
1079
|
if (name === "hyperliquid") {
|
|
958
1080
|
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
|
|
959
1081
|
const { HyperliquidAdapter } = await import("../../exchanges/hyperliquid.js");
|
|
960
1082
|
if (adapter instanceof HyperliquidAdapter) {
|
|
961
|
-
const
|
|
962
|
-
await
|
|
963
|
-
|
|
1083
|
+
const instance = new HyperliquidSpotAdapter(adapter);
|
|
1084
|
+
await instance.init();
|
|
1085
|
+
spot = instance;
|
|
964
1086
|
}
|
|
965
1087
|
}
|
|
966
1088
|
else if (name === "lighter") {
|
|
967
1089
|
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
|
|
968
1090
|
const { LighterAdapter } = await import("../../exchanges/lighter.js");
|
|
969
1091
|
if (adapter instanceof LighterAdapter) {
|
|
970
|
-
const
|
|
971
|
-
await
|
|
972
|
-
|
|
1092
|
+
const instance = new LighterSpotAdapter(adapter);
|
|
1093
|
+
await instance.init();
|
|
1094
|
+
spot = instance;
|
|
973
1095
|
}
|
|
974
1096
|
}
|
|
1097
|
+
if (spot)
|
|
1098
|
+
this._spotAdapterCache.set(name, spot);
|
|
1099
|
+
return spot;
|
|
975
1100
|
}
|
|
976
1101
|
catch { /* not supported */ }
|
|
977
1102
|
return null;
|
|
@@ -1011,12 +1136,22 @@ export class FundingArbStrategy {
|
|
|
1011
1136
|
return 0;
|
|
1012
1137
|
}
|
|
1013
1138
|
}
|
|
1014
|
-
async fetchRates(adapter,
|
|
1139
|
+
async fetchRates(adapter, exchangeName) {
|
|
1015
1140
|
try {
|
|
1016
1141
|
const markets = await adapter.getMarkets();
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1142
|
+
const withRates = markets.filter(m => m.fundingRate != null);
|
|
1143
|
+
// Bootstrap aster funding hours lazily (1 API call per unknown symbol, cached permanently)
|
|
1144
|
+
if (exchangeName === "aster" && "getFundingHours" in adapter) {
|
|
1145
|
+
const aster = adapter;
|
|
1146
|
+
// Only bootstrap symbols not yet cached (up to 5 per tick to limit API calls)
|
|
1147
|
+
const uncached = withRates.filter(m => m.fundingHours === undefined || m.fundingHours === 1);
|
|
1148
|
+
const toBootstrap = uncached.slice(0, 5);
|
|
1149
|
+
for (const m of toBootstrap) {
|
|
1150
|
+
const fh = await aster.getFundingHours(m.symbol);
|
|
1151
|
+
m.fundingHours = fh;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
return withRates.map(m => ({
|
|
1020
1155
|
symbol: m.symbol,
|
|
1021
1156
|
rate: parseFloat(m.fundingRate),
|
|
1022
1157
|
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][];
|
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"),
|