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.
@@ -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,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
- 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);
@@ -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; // don't close short if long failed — keep hedge intact
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 Promise.all([
513
- longAdapter.setLeverage(best.symbol, leverage, "cross"),
514
- shortAdapter.setLeverage(best.symbol, leverage, "cross"),
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", 0);
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} on stop (spot sell + perp buy)`);
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
- const perpSymbol = this.getPerpSymbol(pos.symbol, pos.shortExchange);
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 { /* non-critical */ }
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 > 70) {
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 { /* non-critical */ }
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 spot = new HyperliquidSpotAdapter(adapter);
961
- await spot.init();
962
- return spot;
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 spot = new LighterSpotAdapter(adapter);
970
- await spot.init();
971
- return spot;
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, _exchangeName) {
1111
+ async fetchRates(adapter, exchangeName) {
1014
1112
  try {
1015
1113
  const markets = await adapter.getMarkets();
1016
- return markets
1017
- .filter(m => m.fundingRate != null)
1018
- .map(m => ({
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): Promise<unknown>;
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;
@@ -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"),
@@ -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 result = await this._signedPost("/fapi/v1/order", {
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): Promise<unknown>;
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): Promise<unknown>;
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): Promise<unknown>;
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): Promise<unknown>;
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.7.42",
3
+ "version": "0.7.47",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",