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 < 70) {
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
- availableAdapters.set(name, a); // on error, include it
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
- toClose.push(pos);
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
- ctx.log(` [ARB] Close long leg failed for ${pos.symbol}: ${msg} (cooldown 5m)`);
341
- 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" } });
342
- this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
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
- ctx.log(` [ARB] Close short leg failed for ${pos.symbol}: ${msg} — re-opening long to restore hedge`);
353
- 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" } });
354
- // Rollback: re-open long to restore hedge
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
- catch (rbErr) {
362
- const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
363
- ctx.log(` [ARB] CRITICAL: Close rollback failed for ${pos.symbol}: ${rbMsg}`);
364
- logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "failed", error: rbMsg, dryRun: false });
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 Promise.all([
514
- longAdapter.setLeverage(best.symbol, leverage, "cross"),
515
- shortAdapter.setLeverage(best.symbol, leverage, "cross"),
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", 0);
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} on stop (spot sell + perp buy)`);
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 { /* non-critical */ }
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 > 70) {
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 { /* non-critical */ }
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 spot = new HyperliquidSpotAdapter(adapter);
962
- await spot.init();
963
- return spot;
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 spot = new LighterSpotAdapter(adapter);
971
- await spot.init();
972
- return spot;
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, _exchangeName) {
1139
+ async fetchRates(adapter, exchangeName) {
1015
1140
  try {
1016
1141
  const markets = await adapter.getMarkets();
1017
- return markets
1018
- .filter(m => m.fundingRate != null)
1019
- .map(m => ({
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][];
@@ -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
- const nextFunding = Number(premium?.nextFundingTime ?? 0);
81
- const hoursFromMidnight = nextFunding > 0 ? (nextFunding % 86400000) / 3600000 : 0;
82
- const fundingHours = nextFunding <= 0 ? 1
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"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.7.43",
3
+ "version": "0.7.48",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",