wayfinder-paths 0.1.7__py3-none-any.whl

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.

Potentially problematic release.


This version of wayfinder-paths might be problematic. Click here for more details.

Files changed (149) hide show
  1. wayfinder_paths/CONFIG_GUIDE.md +399 -0
  2. wayfinder_paths/__init__.py +22 -0
  3. wayfinder_paths/abis/generic/erc20.json +383 -0
  4. wayfinder_paths/adapters/__init__.py +0 -0
  5. wayfinder_paths/adapters/balance_adapter/README.md +94 -0
  6. wayfinder_paths/adapters/balance_adapter/adapter.py +238 -0
  7. wayfinder_paths/adapters/balance_adapter/examples.json +6 -0
  8. wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
  9. wayfinder_paths/adapters/balance_adapter/test_adapter.py +59 -0
  10. wayfinder_paths/adapters/brap_adapter/README.md +249 -0
  11. wayfinder_paths/adapters/brap_adapter/__init__.py +7 -0
  12. wayfinder_paths/adapters/brap_adapter/adapter.py +726 -0
  13. wayfinder_paths/adapters/brap_adapter/examples.json +175 -0
  14. wayfinder_paths/adapters/brap_adapter/manifest.yaml +11 -0
  15. wayfinder_paths/adapters/brap_adapter/test_adapter.py +286 -0
  16. wayfinder_paths/adapters/hyperlend_adapter/__init__.py +7 -0
  17. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +305 -0
  18. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +10 -0
  19. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +274 -0
  20. wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +18 -0
  21. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +1093 -0
  22. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +549 -0
  23. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +8 -0
  24. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1050 -0
  25. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +126 -0
  26. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +219 -0
  27. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +220 -0
  28. wayfinder_paths/adapters/hyperliquid_adapter/utils.py +134 -0
  29. wayfinder_paths/adapters/ledger_adapter/README.md +145 -0
  30. wayfinder_paths/adapters/ledger_adapter/__init__.py +7 -0
  31. wayfinder_paths/adapters/ledger_adapter/adapter.py +289 -0
  32. wayfinder_paths/adapters/ledger_adapter/examples.json +137 -0
  33. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +11 -0
  34. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +205 -0
  35. wayfinder_paths/adapters/pool_adapter/README.md +206 -0
  36. wayfinder_paths/adapters/pool_adapter/__init__.py +7 -0
  37. wayfinder_paths/adapters/pool_adapter/adapter.py +282 -0
  38. wayfinder_paths/adapters/pool_adapter/examples.json +143 -0
  39. wayfinder_paths/adapters/pool_adapter/manifest.yaml +10 -0
  40. wayfinder_paths/adapters/pool_adapter/test_adapter.py +220 -0
  41. wayfinder_paths/adapters/token_adapter/README.md +101 -0
  42. wayfinder_paths/adapters/token_adapter/__init__.py +3 -0
  43. wayfinder_paths/adapters/token_adapter/adapter.py +96 -0
  44. wayfinder_paths/adapters/token_adapter/examples.json +26 -0
  45. wayfinder_paths/adapters/token_adapter/manifest.yaml +6 -0
  46. wayfinder_paths/adapters/token_adapter/test_adapter.py +125 -0
  47. wayfinder_paths/config.example.json +22 -0
  48. wayfinder_paths/conftest.py +31 -0
  49. wayfinder_paths/core/__init__.py +18 -0
  50. wayfinder_paths/core/adapters/BaseAdapter.py +65 -0
  51. wayfinder_paths/core/adapters/__init__.py +5 -0
  52. wayfinder_paths/core/adapters/base.py +5 -0
  53. wayfinder_paths/core/adapters/models.py +46 -0
  54. wayfinder_paths/core/analytics/__init__.py +11 -0
  55. wayfinder_paths/core/analytics/bootstrap.py +57 -0
  56. wayfinder_paths/core/analytics/stats.py +48 -0
  57. wayfinder_paths/core/analytics/test_analytics.py +170 -0
  58. wayfinder_paths/core/clients/AuthClient.py +83 -0
  59. wayfinder_paths/core/clients/BRAPClient.py +109 -0
  60. wayfinder_paths/core/clients/ClientManager.py +210 -0
  61. wayfinder_paths/core/clients/HyperlendClient.py +192 -0
  62. wayfinder_paths/core/clients/LedgerClient.py +443 -0
  63. wayfinder_paths/core/clients/PoolClient.py +128 -0
  64. wayfinder_paths/core/clients/SimulationClient.py +192 -0
  65. wayfinder_paths/core/clients/TokenClient.py +89 -0
  66. wayfinder_paths/core/clients/TransactionClient.py +63 -0
  67. wayfinder_paths/core/clients/WalletClient.py +94 -0
  68. wayfinder_paths/core/clients/WayfinderClient.py +269 -0
  69. wayfinder_paths/core/clients/__init__.py +48 -0
  70. wayfinder_paths/core/clients/protocols.py +392 -0
  71. wayfinder_paths/core/clients/sdk_example.py +110 -0
  72. wayfinder_paths/core/config.py +458 -0
  73. wayfinder_paths/core/constants/__init__.py +26 -0
  74. wayfinder_paths/core/constants/base.py +42 -0
  75. wayfinder_paths/core/constants/erc20_abi.py +118 -0
  76. wayfinder_paths/core/constants/hyperlend_abi.py +152 -0
  77. wayfinder_paths/core/engine/StrategyJob.py +188 -0
  78. wayfinder_paths/core/engine/__init__.py +5 -0
  79. wayfinder_paths/core/engine/manifest.py +97 -0
  80. wayfinder_paths/core/services/__init__.py +0 -0
  81. wayfinder_paths/core/services/base.py +179 -0
  82. wayfinder_paths/core/services/local_evm_txn.py +430 -0
  83. wayfinder_paths/core/services/local_token_txn.py +231 -0
  84. wayfinder_paths/core/services/web3_service.py +45 -0
  85. wayfinder_paths/core/settings.py +61 -0
  86. wayfinder_paths/core/strategies/Strategy.py +280 -0
  87. wayfinder_paths/core/strategies/__init__.py +5 -0
  88. wayfinder_paths/core/strategies/base.py +7 -0
  89. wayfinder_paths/core/strategies/descriptors.py +81 -0
  90. wayfinder_paths/core/utils/__init__.py +1 -0
  91. wayfinder_paths/core/utils/evm_helpers.py +206 -0
  92. wayfinder_paths/core/utils/wallets.py +77 -0
  93. wayfinder_paths/core/wallets/README.md +91 -0
  94. wayfinder_paths/core/wallets/WalletManager.py +56 -0
  95. wayfinder_paths/core/wallets/__init__.py +7 -0
  96. wayfinder_paths/policies/enso.py +17 -0
  97. wayfinder_paths/policies/erc20.py +34 -0
  98. wayfinder_paths/policies/evm.py +21 -0
  99. wayfinder_paths/policies/hyper_evm.py +19 -0
  100. wayfinder_paths/policies/hyperlend.py +12 -0
  101. wayfinder_paths/policies/hyperliquid.py +30 -0
  102. wayfinder_paths/policies/moonwell.py +54 -0
  103. wayfinder_paths/policies/prjx.py +30 -0
  104. wayfinder_paths/policies/util.py +27 -0
  105. wayfinder_paths/run_strategy.py +411 -0
  106. wayfinder_paths/scripts/__init__.py +0 -0
  107. wayfinder_paths/scripts/create_strategy.py +181 -0
  108. wayfinder_paths/scripts/make_wallets.py +169 -0
  109. wayfinder_paths/scripts/run_strategy.py +124 -0
  110. wayfinder_paths/scripts/validate_manifests.py +213 -0
  111. wayfinder_paths/strategies/__init__.py +0 -0
  112. wayfinder_paths/strategies/basis_trading_strategy/README.md +213 -0
  113. wayfinder_paths/strategies/basis_trading_strategy/__init__.py +3 -0
  114. wayfinder_paths/strategies/basis_trading_strategy/constants.py +1 -0
  115. wayfinder_paths/strategies/basis_trading_strategy/examples.json +16 -0
  116. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +23 -0
  117. wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1011 -0
  118. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +4522 -0
  119. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +727 -0
  120. wayfinder_paths/strategies/basis_trading_strategy/types.py +39 -0
  121. wayfinder_paths/strategies/config.py +85 -0
  122. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +100 -0
  123. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +8 -0
  124. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +7 -0
  125. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +2270 -0
  126. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +352 -0
  127. wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +96 -0
  128. wayfinder_paths/strategies/stablecoin_yield_strategy/examples.json +17 -0
  129. wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +17 -0
  130. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +1810 -0
  131. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +520 -0
  132. wayfinder_paths/templates/adapter/README.md +105 -0
  133. wayfinder_paths/templates/adapter/adapter.py +26 -0
  134. wayfinder_paths/templates/adapter/examples.json +8 -0
  135. wayfinder_paths/templates/adapter/manifest.yaml +6 -0
  136. wayfinder_paths/templates/adapter/test_adapter.py +49 -0
  137. wayfinder_paths/templates/strategy/README.md +153 -0
  138. wayfinder_paths/templates/strategy/examples.json +11 -0
  139. wayfinder_paths/templates/strategy/manifest.yaml +8 -0
  140. wayfinder_paths/templates/strategy/strategy.py +57 -0
  141. wayfinder_paths/templates/strategy/test_strategy.py +197 -0
  142. wayfinder_paths/tests/__init__.py +0 -0
  143. wayfinder_paths/tests/test_smoke_manifest.py +48 -0
  144. wayfinder_paths/tests/test_test_coverage.py +212 -0
  145. wayfinder_paths/tests/test_utils.py +64 -0
  146. wayfinder_paths-0.1.7.dist-info/LICENSE +21 -0
  147. wayfinder_paths-0.1.7.dist-info/METADATA +777 -0
  148. wayfinder_paths-0.1.7.dist-info/RECORD +149 -0
  149. wayfinder_paths-0.1.7.dist-info/WHEEL +4 -0
@@ -0,0 +1,4522 @@
1
+ """
2
+ BasisTradingStrategy - Delta-neutral basis trading on Hyperliquid.
3
+
4
+ Identifies and executes basis trading opportunities by pairing spot long
5
+ positions with perpetual short positions to capture funding rate payments.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import math
12
+ import random
13
+ import time
14
+ from datetime import UTC, datetime, timedelta
15
+ from decimal import ROUND_UP, Decimal, getcontext
16
+ from pathlib import Path
17
+ from statistics import fmean, mean, pstdev
18
+ from typing import Any
19
+
20
+ from wayfinder_paths.adapters.balance_adapter.adapter import BalanceAdapter
21
+ from wayfinder_paths.adapters.hyperliquid_adapter.adapter import (
22
+ HYPERLIQUID_BRIDGE_ADDRESS,
23
+ HyperliquidAdapter,
24
+ SimpleCache,
25
+ )
26
+ from wayfinder_paths.adapters.hyperliquid_adapter.executor import (
27
+ HyperliquidExecutor,
28
+ LocalHyperliquidExecutor,
29
+ )
30
+ from wayfinder_paths.adapters.hyperliquid_adapter.paired_filler import (
31
+ MIN_NOTIONAL_USD,
32
+ FillConfig,
33
+ PairedFiller,
34
+ )
35
+ from wayfinder_paths.adapters.hyperliquid_adapter.utils import (
36
+ normalize_l2_book as hl_normalize_l2_book,
37
+ )
38
+ from wayfinder_paths.adapters.hyperliquid_adapter.utils import (
39
+ round_size_for_asset as hl_round_size_for_asset,
40
+ )
41
+ from wayfinder_paths.adapters.hyperliquid_adapter.utils import (
42
+ size_step as hl_size_step,
43
+ )
44
+ from wayfinder_paths.adapters.hyperliquid_adapter.utils import (
45
+ spot_index_from_asset_id as hl_spot_index_from_asset_id,
46
+ )
47
+ from wayfinder_paths.adapters.hyperliquid_adapter.utils import (
48
+ sz_decimals_for_asset as hl_sz_decimals_for_asset,
49
+ )
50
+ from wayfinder_paths.adapters.hyperliquid_adapter.utils import (
51
+ usd_depth_in_band as hl_usd_depth_in_band,
52
+ )
53
+ from wayfinder_paths.adapters.ledger_adapter.adapter import LedgerAdapter
54
+ from wayfinder_paths.adapters.token_adapter.adapter import TokenAdapter
55
+ from wayfinder_paths.core.analytics import (
56
+ block_bootstrap_paths as analytics_block_bootstrap_paths,
57
+ )
58
+ from wayfinder_paths.core.analytics import (
59
+ percentile as analytics_percentile,
60
+ )
61
+ from wayfinder_paths.core.analytics import (
62
+ rolling_min_sum as analytics_rolling_min_sum,
63
+ )
64
+ from wayfinder_paths.core.analytics import (
65
+ z_from_conf as analytics_z_from_conf,
66
+ )
67
+ from wayfinder_paths.core.services.base import Web3Service
68
+ from wayfinder_paths.core.services.local_token_txn import LocalTokenTxnService
69
+ from wayfinder_paths.core.services.web3_service import DefaultWeb3Service
70
+ from wayfinder_paths.core.strategies.descriptors import (
71
+ Complexity,
72
+ Directionality,
73
+ Frequency,
74
+ StratDescriptor,
75
+ TokenExposure,
76
+ Volatility,
77
+ )
78
+ from wayfinder_paths.core.strategies.Strategy import StatusDict, StatusTuple, Strategy
79
+ from wayfinder_paths.core.wallets.WalletManager import WalletManager
80
+ from wayfinder_paths.strategies.basis_trading_strategy.constants import (
81
+ USDC_ARBITRUM_TOKEN_ID,
82
+ )
83
+ from wayfinder_paths.strategies.basis_trading_strategy.snapshot_mixin import (
84
+ BasisSnapshotMixin,
85
+ )
86
+ from wayfinder_paths.strategies.basis_trading_strategy.types import (
87
+ BasisCandidate,
88
+ BasisPosition,
89
+ )
90
+
91
+ # Set decimal precision for precise price/size calculations
92
+ getcontext().prec = 28
93
+
94
+ # Hyperliquid price decimal limits
95
+ MAX_DECIMALS_PERP = 6
96
+ MAX_DECIMALS_SPOT = 8
97
+
98
+
99
+ def _d(x: float | Decimal | str) -> Decimal:
100
+ """Convert to Decimal for precise calculations."""
101
+ return x if isinstance(x, Decimal) else Decimal(str(x))
102
+
103
+
104
+ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
105
+ """
106
+ Delta-neutral basis trading strategy on Hyperliquid.
107
+
108
+ Captures funding rate payments by maintaining offsetting spot long and
109
+ perpetual short positions. Uses historical funding rate and volatility
110
+ analysis to select optimal opportunities.
111
+ """
112
+
113
+ name = "Basis Trading Strategy"
114
+
115
+ # Strategy parameters
116
+ MIN_DEPOSIT_USDC = 25
117
+ DEFAULT_LOOKBACK_DAYS = 30 # Supports up to ~208 days via chunked API calls
118
+ DEFAULT_CONFIDENCE = 0.975
119
+ DEFAULT_FEE_EPS = 0.003 # 0.3% fee buffer
120
+ DEFAULT_OI_FLOOR = 100_000.0 # Min OI in USD (matches Django)
121
+ DEFAULT_DAY_VLM_FLOOR = 100_000 # Min daily volume
122
+ DEFAULT_MAX_LEVERAGE = 2
123
+ GAS_MAXIMUM = 0.01 # ETH
124
+ DEFAULT_BOOTSTRAP_SIMS = 50
125
+ DEFAULT_BOOTSTRAP_BLOCK_HOURS = 48
126
+
127
+ # Liquidation and rebalance thresholds (from Django funding_rate_strategy.py)
128
+ LIQUIDATION_REBALANCE_THRESHOLD = 0.75 # Trigger rebalance at 75% to liquidation
129
+ LIQUIDATION_STOP_LOSS_THRESHOLD = 0.90 # Stop-loss at 90% to liquidation (closer)
130
+ FUNDING_REBALANCE_THRESHOLD = 0.02 # Rebalance when funding hits 2% gains
131
+
132
+ # Position tolerances (from Django hyperliquid_adapter.py)
133
+ SPOT_POSITION_DUST_TOLERANCE = 0.04 # ±4% size drift allowed
134
+ MIN_UNUSED_USD = 5.0 # Minimum idle USD threshold
135
+ UNUSED_REL_EPS = 0.01 # 1% of bankroll idle threshold
136
+
137
+ # Rotation cooldown
138
+ ROTATION_MIN_INTERVAL_DAYS = 14 # 14 days between rotations
139
+
140
+ # Builder fee for Hyperliquid trades
141
+ HYPE_FEE_WALLET: str = "0xaA1D89f333857eD78F8434CC4f896A9293EFE65c"
142
+ HYPE_PRO_FEE: int = 30 # in tenths of basis points (0.03% = 3 bps)
143
+ DEFAULT_BUILDER_FEE: dict[str, Any] = {"b": HYPE_FEE_WALLET, "f": HYPE_PRO_FEE}
144
+
145
+ INFO = StratDescriptor(
146
+ description="""Delta-neutral basis trading on Hyperliquid that captures funding rate payments.
147
+ **What it does:** Analyzes historical funding rates, price volatility, and liquidity across
148
+ Hyperliquid markets to identify optimal basis trading opportunities. Opens matched spot long
149
+ and perpetual short positions to capture positive funding while remaining market neutral.
150
+ **Exposure type:** Delta-neutral - equal long spot and short perp exposure cancels price risk.
151
+ **Chains:** Hyperliquid (Arbitrum for deposits).
152
+ **Deposit/Withdrawal:** Deposits USDC which is used to open basis positions.
153
+ Withdrawals close all positions and return USDC to main wallet.
154
+ **Risk:** Funding rates can flip negative; liquidation risk if leverage too high.
155
+ """,
156
+ summary=(
157
+ "Automated delta-neutral basis trading on Hyperliquid, capturing funding rate payments "
158
+ "through matched spot long / perp short positions with intelligent leverage sizing."
159
+ ),
160
+ gas_token_symbol="ETH",
161
+ gas_token_id="ethereum-arbitrum",
162
+ deposit_token_id="usd-coin-arbitrum",
163
+ minimum_net_deposit=MIN_DEPOSIT_USDC,
164
+ gas_maximum=GAS_MAXIMUM,
165
+ gas_threshold=GAS_MAXIMUM / 3,
166
+ volatility=Volatility.MEDIUM,
167
+ volatility_description_short="Delta-neutral but funding can flip negative.",
168
+ directionality=Directionality.DELTA_NEUTRAL,
169
+ directionality_description="Matched spot long and perp short cancels directional exposure.",
170
+ complexity=Complexity.MEDIUM,
171
+ complexity_description="Requires understanding of funding rates and leverage.",
172
+ token_exposure=TokenExposure.STABLECOINS,
173
+ token_exposure_description="Capital in USDC, exposed to crypto through hedged positions.",
174
+ frequency=Frequency.LOW,
175
+ frequency_description="Positions held for days/weeks to accumulate funding.",
176
+ return_drivers=["funding rate", "basis spread"],
177
+ config={
178
+ "deposit": {
179
+ "description": "Deposit USDC to fund basis trading positions.",
180
+ "parameters": {
181
+ "main_token_amount": {
182
+ "type": "float",
183
+ "unit": "USDC",
184
+ "description": "Amount of USDC to allocate.",
185
+ "minimum": MIN_DEPOSIT_USDC,
186
+ },
187
+ "gas_token_amount": {
188
+ "type": "float",
189
+ "unit": "ETH",
190
+ "description": "Amount of ETH for gas.",
191
+ "minimum": 0.0,
192
+ "maximum": GAS_MAXIMUM,
193
+ },
194
+ },
195
+ },
196
+ "update": {
197
+ "description": "Analyze markets and open/monitor positions.",
198
+ },
199
+ "withdraw": {
200
+ "description": "Close all positions and return USDC to main wallet.",
201
+ },
202
+ },
203
+ )
204
+
205
+ def __init__(
206
+ self,
207
+ config: dict[str, Any] | None = None,
208
+ *,
209
+ main_wallet: dict[str, Any] | None = None,
210
+ strategy_wallet: dict[str, Any] | None = None,
211
+ simulation: bool = False,
212
+ web3_service: Web3Service | None = None,
213
+ hyperliquid_executor: HyperliquidExecutor | None = None,
214
+ api_key: str | None = None,
215
+ ) -> None:
216
+ super().__init__(api_key=api_key)
217
+
218
+ merged_config = dict(config or {})
219
+ if main_wallet:
220
+ merged_config["main_wallet"] = main_wallet
221
+ if strategy_wallet:
222
+ merged_config["strategy_wallet"] = strategy_wallet
223
+ self.config = merged_config
224
+ self.simulation = simulation
225
+
226
+ # Position tracking
227
+ self.current_position: BasisPosition | None = None
228
+ self.deposit_amount: float = 0.0
229
+
230
+ # Builder fee for Hyperliquid trades (from config or default)
231
+ # Format: {"b": "0x...", "f": 10} where 'b' is address, 'f' is fee in bps
232
+ self.builder_fee: dict[str, Any] | None = self.config.get(
233
+ "builder_fee", self.DEFAULT_BUILDER_FEE
234
+ )
235
+
236
+ # Initialize cache
237
+ self._cache = SimpleCache()
238
+ self._margin_table_cache: dict[int, list[dict[str, float]]] = {}
239
+
240
+ # Adapters (some are optional for analysis-only usage).
241
+ self.balance_adapter: BalanceAdapter | None = None
242
+ self.token_adapter: TokenAdapter | None = None
243
+ self.ledger_adapter: LedgerAdapter | None = None
244
+ self.hyperliquid_adapter: HyperliquidAdapter | None = None
245
+
246
+ adapter_config = {
247
+ "main_wallet": self.config.get("main_wallet"),
248
+ "strategy_wallet": self.config.get("strategy_wallet"),
249
+ "strategy": self.config,
250
+ }
251
+
252
+ # Create Hyperliquid executor if not provided and not in simulation.
253
+ # This is only required for placing/canceling orders (not market reads).
254
+ hl_executor = hyperliquid_executor
255
+ if hl_executor is None and not self.simulation:
256
+ try:
257
+ hl_executor = LocalHyperliquidExecutor(config=adapter_config)
258
+ self.logger.info("Created LocalHyperliquidExecutor for real execution")
259
+ except Exception as e:
260
+ self.logger.warning(
261
+ f"Could not create LocalHyperliquidExecutor: {e}. "
262
+ "Real Hyperliquid execution will not be available."
263
+ )
264
+
265
+ # Hyperliquid market data adapter should be usable even when wallet/web3
266
+ # configuration is missing (e.g. local --action analyze).
267
+ try:
268
+ self.hyperliquid_adapter = HyperliquidAdapter(
269
+ config=adapter_config,
270
+ simulation=self.simulation,
271
+ executor=hl_executor,
272
+ )
273
+ except Exception as e:
274
+ self.logger.warning(f"Could not initialize HyperliquidAdapter: {e}")
275
+
276
+ # Other adapters require a configured wallet provider / web3 service.
277
+ try:
278
+ if web3_service is None:
279
+ wallet_provider = WalletManager.get_provider(adapter_config)
280
+ tx_adapter = LocalTokenTxnService(
281
+ adapter_config,
282
+ wallet_provider=wallet_provider,
283
+ simulation=self.simulation,
284
+ )
285
+ web3_service = DefaultWeb3Service(
286
+ wallet_provider=wallet_provider, evm_transactions=tx_adapter
287
+ )
288
+
289
+ self.web3_service = web3_service
290
+ self.balance_adapter = BalanceAdapter(
291
+ adapter_config, web3_service=web3_service
292
+ )
293
+ self.token_adapter = TokenAdapter()
294
+ self.ledger_adapter = LedgerAdapter()
295
+ except Exception as e:
296
+ self.logger.warning(f"Wallet/web3 adapter initialization deferred: {e}")
297
+
298
+ adapters: list[Any] = []
299
+ if self.balance_adapter is not None:
300
+ adapters.append(self.balance_adapter)
301
+ if self.token_adapter is not None:
302
+ adapters.append(self.token_adapter)
303
+ if self.ledger_adapter is not None:
304
+ adapters.append(self.ledger_adapter)
305
+ if self.hyperliquid_adapter is not None:
306
+ adapters.append(self.hyperliquid_adapter)
307
+ if adapters:
308
+ self.register_adapters(adapters)
309
+
310
+ async def setup(self) -> None:
311
+ """Initialize strategy state from chain/ledger and discover existing positions."""
312
+ self.logger.info("Starting BasisTradingStrategy setup")
313
+ start_time = time.time()
314
+
315
+ await super().setup()
316
+
317
+ # Get net deposit from ledger
318
+ try:
319
+ success, deposit_data = await self.ledger_adapter.get_strategy_net_deposit(
320
+ wallet_address=self._get_strategy_wallet_address()
321
+ )
322
+ if success and deposit_data:
323
+ self.deposit_amount = float(deposit_data.get("net_deposit", 0) or 0)
324
+ except Exception as e:
325
+ self.logger.warning(f"Could not fetch deposit data: {e}")
326
+
327
+ # Discover existing positions from Hyperliquid (critical for restart recovery)
328
+ try:
329
+ await self._discover_existing_position()
330
+ except Exception as e:
331
+ self.logger.warning(f"Could not discover existing positions: {e}")
332
+
333
+ elapsed = time.time() - start_time
334
+ self.logger.info(f"BasisTradingStrategy setup completed in {elapsed:.2f}s")
335
+
336
+ async def _discover_existing_position(self) -> None:
337
+ """
338
+ Discover existing delta-neutral position from Hyperliquid state.
339
+
340
+ This is critical for restart recovery - we must not open new positions
341
+ if one already exists on-chain.
342
+ """
343
+ address = self._get_strategy_wallet_address()
344
+
345
+ # Get perp positions
346
+ success, user_state = await self.hyperliquid_adapter.get_user_state(address)
347
+ if not success:
348
+ self.logger.warning("Could not fetch user state for position discovery")
349
+ return
350
+
351
+ asset_positions = user_state.get("assetPositions", [])
352
+ if not asset_positions:
353
+ self.logger.info("No existing perp positions found")
354
+ return
355
+
356
+ # Find SHORT perp position (basis trading uses short perp)
357
+ perp_position = None
358
+ for pos_wrapper in asset_positions:
359
+ pos = pos_wrapper.get("position", {})
360
+ szi = float(pos.get("szi", 0))
361
+ if szi < 0: # Short position
362
+ perp_position = pos
363
+ break
364
+
365
+ if not perp_position:
366
+ self.logger.info("No short perp position found")
367
+ return
368
+
369
+ coin = perp_position.get("coin")
370
+ perp_size = abs(float(perp_position.get("szi", 0)))
371
+ entry_px = float(perp_position.get("entryPx", 0))
372
+
373
+ # Get spot positions to find matching spot leg
374
+ success, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
375
+ address
376
+ )
377
+ if not success:
378
+ self.logger.warning(
379
+ f"Found perp position on {coin} but could not fetch spot state"
380
+ )
381
+ return
382
+
383
+ # Find matching spot position
384
+ spot_position = None
385
+ spot_balances = spot_state.get("balances", [])
386
+ for bal in spot_balances:
387
+ bal_coin = bal.get("coin", "")
388
+ # Match coin name (spot might have different naming)
389
+ if (
390
+ bal_coin == coin
391
+ or bal_coin.startswith(coin)
392
+ or coin.startswith(bal_coin.replace("U", ""))
393
+ ):
394
+ total = float(bal.get("total", 0))
395
+ if total > 0:
396
+ spot_position = bal
397
+ break
398
+
399
+ if not spot_position:
400
+ self.logger.warning(
401
+ f"Found perp position on {coin} but no matching spot position - "
402
+ "may have partial exposure"
403
+ )
404
+ # Still track it so we don't open another position
405
+ spot_size = 0.0
406
+ else:
407
+ spot_size = float(spot_position.get("total", 0))
408
+
409
+ # Get asset IDs
410
+ perp_asset_id = self.hyperliquid_adapter.coin_to_asset.get(coin)
411
+ # Spot asset ID: look up from spot meta or estimate
412
+ spot_asset_id = None
413
+ success, spot_meta = await self.hyperliquid_adapter.get_spot_meta()
414
+ if success:
415
+ tokens = spot_meta.get("tokens", [])
416
+ universe = spot_meta.get("universe", [])
417
+ for pair in universe:
418
+ base_idx = pair["tokens"][0]
419
+ for t in tokens:
420
+ if t["index"] == base_idx:
421
+ # Check if this token matches our coin
422
+ if (
423
+ t["name"] == coin
424
+ or t["name"] == f"U{coin}"
425
+ or t["name"].replace("U", "") == coin
426
+ ):
427
+ spot_asset_id = pair["index"] + 10000
428
+ break
429
+ if spot_asset_id:
430
+ break
431
+
432
+ # Reconstruct position state
433
+ self.current_position = BasisPosition(
434
+ coin=coin,
435
+ spot_asset_id=spot_asset_id or 0,
436
+ perp_asset_id=perp_asset_id or 0,
437
+ spot_amount=spot_size,
438
+ perp_amount=perp_size,
439
+ entry_price=entry_px,
440
+ leverage=2, # Default, actual leverage can be inferred
441
+ entry_timestamp=int(time.time() * 1000), # Approximate
442
+ funding_collected=abs(
443
+ float(perp_position.get("cumFunding", {}).get("sinceOpen", 0))
444
+ ),
445
+ )
446
+
447
+ # Update deposit amount from actual account value if not set
448
+ if self.deposit_amount <= 0:
449
+ margin_summary = user_state.get("marginSummary", {})
450
+ self.deposit_amount = float(margin_summary.get("accountValue", 0))
451
+
452
+ self.logger.info(
453
+ f"Discovered existing position: {coin} "
454
+ f"(perp={perp_size:.4f}, spot={spot_size:.4f}, entry=${entry_px:.2f})"
455
+ )
456
+
457
+ async def deposit(
458
+ self,
459
+ main_token_amount: float = 0.0,
460
+ gas_token_amount: float = 0.0,
461
+ ) -> StatusTuple:
462
+ """
463
+ Deposit USDC to Hyperliquid L1 for basis trading.
464
+
465
+ Sends USDC from the strategy wallet to the Hyperliquid bridge address on Arbitrum,
466
+ then waits for it to be credited on Hyperliquid L1.
467
+
468
+ Args:
469
+ main_token_amount: Amount of USDC to deposit
470
+ gas_token_amount: Amount of ETH for gas (unused, kept for interface compatibility)
471
+
472
+ Returns:
473
+ StatusTuple (success, message)
474
+ """
475
+ if main_token_amount < self.MIN_DEPOSIT_USDC:
476
+ return (False, f"Minimum deposit is {self.MIN_DEPOSIT_USDC} USDC")
477
+
478
+ if gas_token_amount > self.GAS_MAXIMUM:
479
+ return (False, f"Gas amount exceeds maximum {self.GAS_MAXIMUM} ETH")
480
+
481
+ self.logger.info(f"Depositing {main_token_amount} USDC to Hyperliquid L1")
482
+
483
+ # Transfer ETH for gas if requested
484
+ if gas_token_amount > 0:
485
+ main_address = self._get_main_wallet_address()
486
+ strategy_address = self._get_strategy_wallet_address()
487
+ self.logger.info(
488
+ f"Transferring {gas_token_amount} ETH for gas from main wallet "
489
+ f"({main_address}) to strategy wallet ({strategy_address})"
490
+ )
491
+ (
492
+ gas_ok,
493
+ gas_res,
494
+ ) = await self.balance_adapter.move_from_main_wallet_to_strategy_wallet(
495
+ token_id="ethereum-arbitrum", # Native ETH on Arbitrum
496
+ amount=gas_token_amount,
497
+ strategy_name=self.name or "basis_trading_strategy",
498
+ skip_ledger=True,
499
+ )
500
+ if not gas_ok:
501
+ self.logger.error(f"Failed to transfer ETH for gas: {gas_res}")
502
+ return (False, f"Failed to transfer ETH for gas: {gas_res}")
503
+ self.logger.info(f"Gas transfer successful: {gas_res}")
504
+
505
+ # Simulation mode - just track the deposit
506
+ if self.simulation:
507
+ self.logger.info(
508
+ f"[SIMULATION] Would send {main_token_amount} USDC to Hyperliquid bridge"
509
+ )
510
+ self.deposit_amount = main_token_amount
511
+ return (
512
+ True,
513
+ f"[SIMULATION] Deposited {main_token_amount} USDC. "
514
+ f"Call update() to analyze and open positions.",
515
+ )
516
+
517
+ # Real deposit: ensure funds are in the strategy wallet, then send USDC to bridge.
518
+ try:
519
+ main_address = self._get_main_wallet_address()
520
+ strategy_wallet = self.config.get("strategy_wallet")
521
+ strategy_address = self._get_strategy_wallet_address()
522
+
523
+ # Check if strategy wallet already has sufficient USDC
524
+ (
525
+ strategy_balance_ok,
526
+ strategy_balance,
527
+ ) = await self.balance_adapter.get_balance(
528
+ token_id=USDC_ARBITRUM_TOKEN_ID,
529
+ wallet_address=strategy_address,
530
+ )
531
+ strategy_usdc = 0.0
532
+ if strategy_balance_ok and strategy_balance:
533
+ # Balance is returned in raw units, USDC has 6 decimals
534
+ strategy_usdc = float(strategy_balance) / 1e6
535
+
536
+ need_to_move = main_token_amount - strategy_usdc
537
+ if main_address.lower() != strategy_address.lower() and need_to_move > 0.01:
538
+ self.logger.info(
539
+ f"Moving {need_to_move:.2f} USDC from main wallet ({main_address}) "
540
+ f"to strategy wallet ({strategy_address}) [existing: {strategy_usdc:.2f}]"
541
+ )
542
+ (
543
+ move_ok,
544
+ move_res,
545
+ ) = await self.balance_adapter.move_from_main_wallet_to_strategy_wallet(
546
+ token_id=USDC_ARBITRUM_TOKEN_ID,
547
+ amount=need_to_move,
548
+ strategy_name=self.name or "basis_trading_strategy",
549
+ skip_ledger=True,
550
+ )
551
+ if not move_ok:
552
+ self.logger.error(
553
+ f"Failed to move USDC into strategy wallet: {move_res}"
554
+ )
555
+ return (
556
+ False,
557
+ f"Failed to move USDC into strategy wallet: {move_res}",
558
+ )
559
+ elif strategy_usdc >= main_token_amount:
560
+ self.logger.info(
561
+ f"Strategy wallet already has {strategy_usdc:.2f} USDC, skipping transfer from main"
562
+ )
563
+
564
+ self.logger.info(
565
+ f"Sending {main_token_amount} USDC from strategy wallet ({strategy_address}) "
566
+ f"to Hyperliquid bridge ({HYPERLIQUID_BRIDGE_ADDRESS})"
567
+ )
568
+
569
+ # Send USDC to bridge address (deposit credits the sender address on Hyperliquid)
570
+ success, result = await self.balance_adapter.send_to_address(
571
+ token_id=USDC_ARBITRUM_TOKEN_ID,
572
+ amount=main_token_amount,
573
+ from_wallet=strategy_wallet,
574
+ to_address=HYPERLIQUID_BRIDGE_ADDRESS,
575
+ skip_ledger=True, # We'll record after HL credits the deposit
576
+ )
577
+
578
+ if not success:
579
+ self.logger.error(f"Failed to send USDC to bridge: {result}")
580
+ return (False, f"Failed to send USDC to bridge: {result}")
581
+
582
+ self.logger.info(f"USDC sent to bridge, tx: {result}")
583
+
584
+ # Wait for Hyperliquid to credit the deposit
585
+ self.logger.info("Waiting for Hyperliquid to credit the deposit...")
586
+
587
+ (
588
+ deposit_confirmed,
589
+ final_balance,
590
+ ) = await self.hyperliquid_adapter.wait_for_deposit(
591
+ address=strategy_address,
592
+ expected_increase=main_token_amount,
593
+ timeout_s=180, # 3 minutes for initial deposit
594
+ poll_interval_s=10,
595
+ )
596
+
597
+ if not deposit_confirmed:
598
+ self.logger.warning(
599
+ f"Deposit not confirmed within timeout. "
600
+ f"Current HL balance: ${final_balance:.2f}. "
601
+ f"Deposit may still be processing."
602
+ )
603
+ # Still track the deposit amount since we sent it
604
+ self.deposit_amount = main_token_amount
605
+ return (
606
+ True,
607
+ f"Sent {main_token_amount} USDC to bridge. Deposit still processing. "
608
+ f"Current HL balance: ${final_balance:.2f}",
609
+ )
610
+
611
+ self.deposit_amount = main_token_amount
612
+
613
+ # Record in ledger
614
+ try:
615
+ await self.ledger_adapter.record_deposit(
616
+ wallet_address=strategy_address,
617
+ chain_id=42161, # Arbitrum
618
+ token_address="hyperliquid-vault-usd", # Synthetic address for HL USD
619
+ token_amount=str(main_token_amount),
620
+ usd_value=main_token_amount,
621
+ data={"destination": "hyperliquid_l1"},
622
+ strategy_name=self.name,
623
+ )
624
+ except Exception as e:
625
+ self.logger.warning(f"Failed to record deposit in ledger: {e}")
626
+
627
+ return (
628
+ True,
629
+ f"Deposited {main_token_amount} USDC to Hyperliquid L1. "
630
+ f"Balance: ${final_balance:.2f}. Call update() to open positions.",
631
+ )
632
+
633
+ except Exception as e:
634
+ self.logger.error(f"Deposit failed: {e}")
635
+ return (False, f"Deposit failed: {e}")
636
+
637
+ async def update(self) -> StatusTuple:
638
+ """
639
+ Analyze markets and manage positions.
640
+
641
+ - If no position exists, analyzes opportunities and opens the best one.
642
+ - If position exists, monitors and maintains it:
643
+ - Checks for rebalance conditions
644
+ - Verifies leg balance (spot == perp)
645
+ - Deploys any idle capital
646
+ - Ensures stop-loss orders are valid
647
+
648
+ Returns:
649
+ StatusTuple (success, message)
650
+ """
651
+ # If deposit_amount not set, try to detect from Hyperliquid balance
652
+ if self.deposit_amount <= 0:
653
+ address = self._get_strategy_wallet_address()
654
+ success, user_state = await self.hyperliquid_adapter.get_user_state(address)
655
+ if success:
656
+ margin_summary = user_state.get("marginSummary", {})
657
+ account_value = float(margin_summary.get("accountValue", 0))
658
+ if account_value > 1.0:
659
+ self.logger.info(
660
+ f"Detected ${account_value:.2f} on Hyperliquid, using as deposit amount"
661
+ )
662
+ self.deposit_amount = account_value
663
+
664
+ if self.deposit_amount <= 0:
665
+ return (False, "No deposit to manage. Call deposit() first.")
666
+
667
+ # If no position, find and open one
668
+ if self.current_position is None:
669
+ return await self._find_and_open_position()
670
+
671
+ # Monitor existing position (handles idle capital, leg balance, stop-loss)
672
+ return await self._monitor_position()
673
+
674
+ async def analyze(
675
+ self, deposit_usdc: float = 1000.0, verbose: bool = True
676
+ ) -> dict[str, Any]:
677
+ """
678
+ Analyze basis trading opportunities without executing.
679
+
680
+ Uses the Net-APY + stop-churn backtest solver with block-bootstrap
681
+ resampling (ported from Django's NetApyBasisTradingService).
682
+
683
+ Args:
684
+ deposit_usdc: Hypothetical deposit amount for sizing calculations (default $1000)
685
+ verbose: Include debug info about filtering
686
+
687
+ Returns:
688
+ Dict with opportunities sorted by net APY (includes bootstrap metrics)
689
+ """
690
+ self.logger.info(
691
+ f"Analyzing basis opportunities for ${deposit_usdc} deposit..."
692
+ )
693
+
694
+ debug_info: dict[str, Any] = {}
695
+
696
+ try:
697
+ snapshot = self._snapshot_from_config()
698
+ if snapshot is not None:
699
+ try:
700
+ opportunities = self.opportunities_from_snapshot(
701
+ snapshot=snapshot, deposit_usdc=deposit_usdc
702
+ )
703
+ return {
704
+ "success": True,
705
+ "source": "snapshot",
706
+ "snapshot_path": None,
707
+ "snapshot_hour_bucket_utc": snapshot.get("hour_bucket_utc"),
708
+ "deposit_usdc": deposit_usdc,
709
+ "opportunities_count": len(opportunities),
710
+ "opportunities": opportunities,
711
+ "debug": debug_info if verbose else None,
712
+ }
713
+ except Exception as exc: # noqa: BLE001
714
+ self.logger.warning(
715
+ f"Failed to use in-memory snapshot: {exc}. Falling back to live analysis."
716
+ )
717
+
718
+ snapshot_path = self._snapshot_path_from_config()
719
+ if snapshot_path and Path(snapshot_path).exists():
720
+ try:
721
+ snapshot = self.load_snapshot_from_path(snapshot_path)
722
+ opportunities = self.opportunities_from_snapshot(
723
+ snapshot=snapshot, deposit_usdc=deposit_usdc
724
+ )
725
+ return {
726
+ "success": True,
727
+ "source": "snapshot",
728
+ "snapshot_path": snapshot_path,
729
+ "snapshot_hour_bucket_utc": snapshot.get("hour_bucket_utc"),
730
+ "deposit_usdc": deposit_usdc,
731
+ "opportunities_count": len(opportunities),
732
+ "opportunities": opportunities,
733
+ "debug": debug_info if verbose else None,
734
+ }
735
+ except Exception as exc: # noqa: BLE001
736
+ self.logger.warning(
737
+ f"Failed to load/use snapshot from {snapshot_path}: {exc}. "
738
+ "Falling back to live analysis."
739
+ )
740
+
741
+ # Get market data for debug info
742
+ (
743
+ success,
744
+ perps_ctx_pack,
745
+ ) = await self.hyperliquid_adapter.get_meta_and_asset_ctxs()
746
+ if success:
747
+ perps_meta_list = perps_ctx_pack[0]["universe"]
748
+ debug_info["perp_count"] = len(perps_meta_list)
749
+
750
+ success, spot_meta = await self.hyperliquid_adapter.get_spot_meta()
751
+ if success:
752
+ spot_pairs = spot_meta.get("universe", [])
753
+ debug_info["spot_pair_count"] = len(spot_pairs)
754
+
755
+ bootstrap_sims = int(
756
+ self._cfg_get("bootstrap_sims", self.DEFAULT_BOOTSTRAP_SIMS) or 0
757
+ )
758
+ bootstrap_block_hours = int(
759
+ self._cfg_get(
760
+ "bootstrap_block_hours", self.DEFAULT_BOOTSTRAP_BLOCK_HOURS
761
+ )
762
+ or 0
763
+ )
764
+ bootstrap_seed = self._cfg_get("bootstrap_seed")
765
+ bootstrap_seed = int(bootstrap_seed) if bootstrap_seed is not None else None
766
+ self.logger.info(
767
+ f"Bootstrap settings: sims={bootstrap_sims}, block_hours={bootstrap_block_hours}, "
768
+ f"seed={'random' if bootstrap_seed is None else bootstrap_seed}"
769
+ )
770
+
771
+ opportunities = await self.solve_candidates_max_net_apy_with_stop(
772
+ deposit_usdc=deposit_usdc,
773
+ stop_frac=self.LIQUIDATION_REBALANCE_THRESHOLD,
774
+ lookback_days=self.DEFAULT_LOOKBACK_DAYS,
775
+ fee_eps=self.DEFAULT_FEE_EPS,
776
+ oi_floor=self.DEFAULT_OI_FLOOR,
777
+ day_vlm_floor=self.DEFAULT_DAY_VLM_FLOOR,
778
+ max_leverage=self.DEFAULT_MAX_LEVERAGE,
779
+ bootstrap_sims=bootstrap_sims,
780
+ bootstrap_block_hours=bootstrap_block_hours,
781
+ bootstrap_seed=bootstrap_seed,
782
+ )
783
+
784
+ if verbose:
785
+ self.logger.info(
786
+ f"Found {len(opportunities)} opportunities after all filters"
787
+ )
788
+
789
+ return {
790
+ "success": True,
791
+ "source": "live",
792
+ "deposit_usdc": deposit_usdc,
793
+ "bootstrap": {
794
+ "sims": bootstrap_sims,
795
+ "block_hours": bootstrap_block_hours,
796
+ "seed": bootstrap_seed,
797
+ },
798
+ "opportunities_count": len(opportunities),
799
+ "opportunities": opportunities,
800
+ "debug": debug_info if verbose else None,
801
+ }
802
+
803
+ except Exception as e:
804
+ self.logger.error(f"Analysis failed: {e}")
805
+ import traceback
806
+
807
+ traceback.print_exc()
808
+ return {
809
+ "success": False,
810
+ "error": str(e),
811
+ "opportunities": [],
812
+ "debug": debug_info if verbose else None,
813
+ }
814
+
815
+ def _cfg_get(self, key: str, default: Any | None = None) -> Any:
816
+ """
817
+ Read a strategy config value.
818
+
819
+ Supports both flat configs (common in this repo) and nested configs
820
+ where strategy settings live under a "strategy" key.
821
+ """
822
+ if key in self.config:
823
+ return self.config.get(key, default)
824
+ nested = self.config.get("strategy")
825
+ if isinstance(nested, dict) and key in nested:
826
+ return nested.get(key, default)
827
+ return default
828
+
829
+ async def withdraw(self, amount: float | None = None) -> StatusTuple:
830
+ """
831
+ Close all positions and return funds to main wallet.
832
+
833
+ Handles funds in:
834
+ 1. Strategy wallet on Arbitrum (USDC)
835
+ 2. Hyperliquid L1 (positions + margin)
836
+
837
+ Args:
838
+ amount: Amount to withdraw (None = all)
839
+
840
+ Returns:
841
+ StatusTuple (success, message)
842
+ """
843
+ address = self._get_strategy_wallet_address()
844
+ main_address = self._get_main_wallet_address()
845
+ usdc_token_id = "usd-coin-arbitrum"
846
+ total_withdrawn = 0.0
847
+
848
+ # Check for USDC already in strategy wallet on Arbitrum
849
+ strategy_usdc = 0.0
850
+ try:
851
+ success, balance_data = await self.balance_adapter.get_balance(
852
+ token_id=usdc_token_id,
853
+ wallet_address=address,
854
+ )
855
+ if success:
856
+ strategy_usdc = float(balance_data) / 1e6 # USDC has 6 decimals
857
+ except Exception as e:
858
+ self.logger.warning(f"Could not get strategy wallet balance: {e}")
859
+
860
+ # Get current Hyperliquid value (perp + spot)
861
+ hl_perp_value = 0.0
862
+ hl_spot_usdc = 0.0
863
+ success, user_state = await self.hyperliquid_adapter.get_user_state(address)
864
+ if success:
865
+ margin_summary = user_state.get("marginSummary", {})
866
+ hl_perp_value = float(margin_summary.get("accountValue", 0))
867
+
868
+ # Also check spot USDC balance
869
+ success, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
870
+ address
871
+ )
872
+ if success:
873
+ for bal in spot_state.get("balances", []):
874
+ if bal.get("coin") == "USDC":
875
+ hl_spot_usdc = float(bal.get("total", 0))
876
+ break
877
+
878
+ hl_value = hl_perp_value + hl_spot_usdc
879
+
880
+ # Check if there's anything to withdraw
881
+ if strategy_usdc < 1.0 and hl_value < 1.0 and self.current_position is None:
882
+ return (False, "Nothing to withdraw")
883
+
884
+ # Step 0: Send any USDC already in strategy wallet to main wallet
885
+ if strategy_usdc > 1.0 and main_address.lower() != address.lower():
886
+ amount_to_send = strategy_usdc # Send full amount
887
+ self.logger.info(
888
+ f"Found ${strategy_usdc:.2f} USDC in strategy wallet, "
889
+ f"sending ${amount_to_send:.2f} to main wallet"
890
+ )
891
+
892
+ try:
893
+ (
894
+ send_success,
895
+ send_result,
896
+ ) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
897
+ token_id=usdc_token_id,
898
+ amount=amount_to_send,
899
+ strategy_name=self.name,
900
+ skip_ledger=False,
901
+ )
902
+
903
+ if send_success:
904
+ self.logger.info(f"Sent ${amount_to_send:.2f} USDC to main wallet")
905
+ total_withdrawn += amount_to_send
906
+ else:
907
+ self.logger.warning(
908
+ f"Failed to send USDC to main wallet: {send_result}"
909
+ )
910
+ except Exception as e:
911
+ self.logger.error(f"Error sending USDC to main wallet: {e}")
912
+
913
+ # If nothing on Hyperliquid, we're done
914
+ if hl_value < 1.0 and self.current_position is None:
915
+ self.deposit_amount = 0
916
+ if total_withdrawn > 0:
917
+ return (
918
+ True,
919
+ f"Sent ${total_withdrawn:.2f} USDC to main wallet ({main_address})",
920
+ )
921
+ return (True, "No funds on Hyperliquid to withdraw")
922
+
923
+ # Close any open position
924
+ if self.current_position is not None:
925
+ close_success, close_msg = await self._close_position()
926
+ if not close_success:
927
+ return (False, f"Failed to close position: {close_msg}")
928
+
929
+ if self.simulation:
930
+ withdrawn = self.deposit_amount
931
+ self.deposit_amount = 0
932
+ return (True, f"[SIMULATION] Withdrew {withdrawn} USDC to main wallet")
933
+
934
+ # Step 1: Transfer any spot USDC to perp for withdrawal
935
+ success, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
936
+ address
937
+ )
938
+ if success:
939
+ spot_balances = spot_state.get("balances", [])
940
+ for bal in spot_balances:
941
+ if bal.get("coin") == "USDC":
942
+ spot_usdc = float(bal.get("total", 0))
943
+ if spot_usdc > 1.0: # Only transfer if meaningful amount
944
+ self.logger.info(
945
+ f"Transferring ${spot_usdc:.2f} from spot to perp"
946
+ )
947
+ await self.hyperliquid_adapter.transfer_spot_to_perp(
948
+ amount=spot_usdc,
949
+ address=address,
950
+ )
951
+ break
952
+
953
+ # Step 2: Get updated perp balance for withdrawal (with retry)
954
+ # Wait a moment for transfers to settle
955
+ await asyncio.sleep(2)
956
+
957
+ withdrawable = 0.0
958
+ for attempt in range(3):
959
+ success, user_state = await self.hyperliquid_adapter.get_user_state(address)
960
+ if not success:
961
+ continue
962
+
963
+ # withdrawable is at top level of user_state, not in marginSummary
964
+ withdrawable = float(user_state.get("withdrawable", 0))
965
+
966
+ if withdrawable > 1.0:
967
+ break
968
+
969
+ self.logger.info(
970
+ f"Waiting for funds to be withdrawable (attempt {attempt + 1}/3)..."
971
+ )
972
+ await asyncio.sleep(3)
973
+
974
+ if withdrawable <= 0:
975
+ return (False, "No withdrawable funds available")
976
+
977
+ # Step 3: Withdraw from Hyperliquid to Arbitrum (strategy wallet)
978
+ self.logger.info(
979
+ f"Withdrawing ${withdrawable:.2f} from Hyperliquid to Arbitrum"
980
+ )
981
+ success, withdraw_result = await self.hyperliquid_adapter.withdraw(
982
+ amount=withdrawable,
983
+ address=address,
984
+ )
985
+
986
+ if not success:
987
+ return (False, f"Hyperliquid withdrawal failed: {withdraw_result}")
988
+
989
+ self.logger.info(f"Withdrawal initiated: {withdraw_result}")
990
+
991
+ # Step 4: Wait for withdrawal to appear on-chain
992
+ # Hyperliquid withdrawals typically take 5-15 minutes
993
+ self.logger.info("Waiting for withdrawal to appear on-chain...")
994
+
995
+ (
996
+ withdrawal_success,
997
+ withdrawals,
998
+ ) = await self.hyperliquid_adapter.wait_for_withdrawal(
999
+ address=address,
1000
+ lookback_s=5,
1001
+ max_poll_time_s=20 * 60, # 20 minutes max
1002
+ poll_interval_s=10,
1003
+ )
1004
+
1005
+ if not withdrawal_success or not withdrawals:
1006
+ return (
1007
+ False,
1008
+ f"Withdrawal of ${withdrawable:.2f} initiated but not confirmed on-chain. "
1009
+ "Check Hyperliquid explorer for status.",
1010
+ )
1011
+
1012
+ # Get the withdrawal amount from the most recent tx
1013
+ tx_hash = list(withdrawals.keys())[-1]
1014
+ withdrawn_amount = withdrawals[tx_hash]
1015
+ self.logger.info(
1016
+ f"Withdrawal confirmed: tx={tx_hash}, amount=${withdrawn_amount:.2f}"
1017
+ )
1018
+
1019
+ # Record withdrawal in ledger
1020
+ try:
1021
+ await self.ledger_adapter.record_withdrawal(
1022
+ wallet_address=address,
1023
+ chain_id=42161, # Arbitrum
1024
+ token_address="hyperliquid-vault-usd",
1025
+ token_amount=str(withdrawn_amount),
1026
+ usd_value=withdrawn_amount,
1027
+ data={
1028
+ "source": "hyperliquid_l1",
1029
+ "destination": "arbitrum",
1030
+ "tx_hash": tx_hash,
1031
+ },
1032
+ strategy_name=self.name,
1033
+ )
1034
+ self.logger.info(
1035
+ f"Recorded withdrawal of ${withdrawn_amount:.2f} in ledger"
1036
+ )
1037
+ except Exception as e:
1038
+ self.logger.warning(f"Failed to record withdrawal in ledger: {e}")
1039
+
1040
+ # Step 5: Wait a bit for the USDC to be credited on Arbitrum
1041
+ await asyncio.sleep(10)
1042
+
1043
+ # Get final USDC balance
1044
+ final_balance = 0.0
1045
+ try:
1046
+ success, balance_data = await self.balance_adapter.get_balance(
1047
+ token_id=usdc_token_id,
1048
+ wallet_address=address,
1049
+ )
1050
+ if success:
1051
+ final_balance = float(balance_data) / 1e6 # USDC has 6 decimals
1052
+ except Exception as e:
1053
+ self.logger.warning(f"Could not get final balance: {e}")
1054
+
1055
+ # Step 6: Send USDC from strategy wallet to main wallet
1056
+ amount_to_send = final_balance # Send full amount
1057
+
1058
+ if amount_to_send > 1.0 and main_address.lower() != address.lower():
1059
+ self.logger.info(
1060
+ f"Sending ${amount_to_send:.2f} USDC from strategy wallet to main wallet"
1061
+ )
1062
+
1063
+ try:
1064
+ (
1065
+ send_success,
1066
+ send_result,
1067
+ ) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
1068
+ token_id=usdc_token_id,
1069
+ amount=amount_to_send,
1070
+ strategy_name=self.name,
1071
+ skip_ledger=False, # Record in ledger
1072
+ )
1073
+
1074
+ if send_success:
1075
+ self.logger.info(
1076
+ f"Successfully sent ${amount_to_send:.2f} USDC to main wallet"
1077
+ )
1078
+ total_withdrawn += amount_to_send
1079
+ else:
1080
+ self.logger.warning(
1081
+ f"Failed to send USDC to main wallet: {send_result}"
1082
+ )
1083
+ return (
1084
+ True,
1085
+ f"Withdrew ${withdrawable:.2f} from Hyperliquid but failed to send to main wallet: {send_result}. "
1086
+ f"USDC is in strategy wallet ({address}).",
1087
+ )
1088
+ except Exception as e:
1089
+ self.logger.error(f"Error sending to main wallet: {e}")
1090
+ return (
1091
+ True,
1092
+ f"Withdrew ${withdrawable:.2f} from Hyperliquid but error sending to main wallet: {e}. "
1093
+ f"USDC is in strategy wallet ({address}).",
1094
+ )
1095
+ elif main_address.lower() == address.lower():
1096
+ self.logger.info("Main wallet is strategy wallet, no transfer needed")
1097
+ total_withdrawn += withdrawable
1098
+ else:
1099
+ self.logger.warning(f"Amount too small to transfer: ${amount_to_send:.2f}")
1100
+
1101
+ self.deposit_amount = 0
1102
+ self.current_position = None
1103
+
1104
+ return (
1105
+ True,
1106
+ f"Withdrew ${total_withdrawn:.2f} total to main wallet ({main_address}).",
1107
+ )
1108
+
1109
+ async def _status(self) -> StatusDict:
1110
+ """Return portfolio value and strategy status with live data."""
1111
+ total_value, hl_value, vault_value = await self._get_total_portfolio_value()
1112
+
1113
+ status_payload: dict[str, Any] = {
1114
+ "has_position": self.current_position is not None,
1115
+ "hyperliquid_value": hl_value,
1116
+ "vault_wallet_value": vault_value,
1117
+ }
1118
+
1119
+ if self.current_position is not None:
1120
+ status_payload.update(
1121
+ {
1122
+ "coin": self.current_position.coin,
1123
+ "spot_amount": self.current_position.spot_amount,
1124
+ "perp_amount": self.current_position.perp_amount,
1125
+ "entry_price": self.current_position.entry_price,
1126
+ "leverage": self.current_position.leverage,
1127
+ "funding_collected": self.current_position.funding_collected,
1128
+ }
1129
+ )
1130
+
1131
+ # Get net deposit from ledger
1132
+ try:
1133
+ success, deposit_data = await self.ledger_adapter.get_strategy_net_deposit(
1134
+ wallet_address=self._get_strategy_wallet_address()
1135
+ )
1136
+ net_deposit = (
1137
+ float(deposit_data.get("net_deposit", 0) or 0)
1138
+ if success
1139
+ else self.deposit_amount
1140
+ )
1141
+ except Exception:
1142
+ net_deposit = self.deposit_amount
1143
+
1144
+ return StatusDict(
1145
+ portfolio_value=total_value,
1146
+ net_deposit=float(net_deposit),
1147
+ strategy_status=status_payload,
1148
+ gas_available=0.0,
1149
+ gassed_up=True,
1150
+ )
1151
+
1152
+ @staticmethod
1153
+ async def policies() -> list[str]:
1154
+ """Return wallet permission policies."""
1155
+ # Placeholder - would include Hyperliquid-specific policies
1156
+ return []
1157
+
1158
+ async def ensure_builder_fee_approved(self) -> StatusTuple:
1159
+ """
1160
+ Ensure the builder fee is approved before trading.
1161
+
1162
+ Checks the current max builder fee approval for the user/builder pair.
1163
+ If the current approval is less than required, submits an approval transaction.
1164
+
1165
+ Returns:
1166
+ StatusTuple (success, message)
1167
+ """
1168
+ if not self.builder_fee:
1169
+ return True, "No builder fee configured"
1170
+
1171
+ if self.simulation:
1172
+ return True, "[SIMULATION] Builder fee approval skipped"
1173
+
1174
+ address = self._get_strategy_wallet_address()
1175
+ builder = self.builder_fee.get("b", "")
1176
+ required_fee = self.builder_fee.get("f", 0)
1177
+
1178
+ if not builder or required_fee <= 0:
1179
+ return True, "Builder fee not required"
1180
+
1181
+ try:
1182
+ # Check current approval
1183
+ success, current_fee = await self.hyperliquid_adapter.get_max_builder_fee(
1184
+ user=address,
1185
+ builder=builder,
1186
+ )
1187
+
1188
+ if not success:
1189
+ self.logger.warning(
1190
+ "Failed to check builder fee approval, continuing anyway"
1191
+ )
1192
+ return True, "Could not verify builder fee, proceeding"
1193
+
1194
+ self.logger.info(
1195
+ f"Builder fee approval check: current={current_fee}, required={required_fee}"
1196
+ )
1197
+
1198
+ if current_fee >= required_fee:
1199
+ return True, f"Builder fee already approved: {current_fee}"
1200
+
1201
+ # Need to approve
1202
+ # Convert fee to percentage string (e.g., 30 tenths bp = 0.030%)
1203
+ max_fee_rate = f"{required_fee / 1000:.3f}%"
1204
+ self.logger.info(
1205
+ f"Approving builder fee: builder={builder}, rate={max_fee_rate}"
1206
+ )
1207
+
1208
+ success, result = await self.hyperliquid_adapter.approve_builder_fee(
1209
+ builder=builder,
1210
+ max_fee_rate=max_fee_rate,
1211
+ address=address,
1212
+ )
1213
+
1214
+ if not success:
1215
+ self.logger.error(f"Builder fee approval failed: {result}")
1216
+ return False, f"Builder fee approval failed: {result}"
1217
+
1218
+ self.logger.info(f"Builder fee approved: {result}")
1219
+ return True, f"Builder fee approved at {max_fee_rate}"
1220
+
1221
+ except Exception as e:
1222
+ self.logger.error(f"Builder fee approval error: {e}")
1223
+ return False, f"Builder fee approval error: {e}"
1224
+
1225
+ # ------------------------------------------------------------------ #
1226
+ # Position Management #
1227
+ # ------------------------------------------------------------------ #
1228
+
1229
+ async def _find_and_open_position(self) -> StatusTuple:
1230
+ """Analyze markets and open the best basis position."""
1231
+ self.logger.info("Analyzing basis trading opportunities...")
1232
+
1233
+ try:
1234
+ best: dict[str, Any] | None = None
1235
+
1236
+ snapshot = self._snapshot_from_config()
1237
+ if snapshot is not None:
1238
+ try:
1239
+ opps = self.opportunities_from_snapshot(
1240
+ snapshot=snapshot, deposit_usdc=self.deposit_amount
1241
+ )
1242
+ if opps:
1243
+ self.logger.info(
1244
+ "Selecting best opportunity from in-memory batch snapshot"
1245
+ )
1246
+ best = await self.score_opportunity_from_snapshot(
1247
+ opportunity=opps[0],
1248
+ deposit_usdc=self.deposit_amount,
1249
+ horizons_days=[1, 7],
1250
+ stop_frac=self.LIQUIDATION_REBALANCE_THRESHOLD,
1251
+ lookback_days=self.DEFAULT_LOOKBACK_DAYS,
1252
+ fee_eps=self.DEFAULT_FEE_EPS,
1253
+ perp_slippage_bps=1.0,
1254
+ cooloff_hours=0,
1255
+ bootstrap_sims=int(
1256
+ self._cfg_get(
1257
+ "bootstrap_sims", self.DEFAULT_BOOTSTRAP_SIMS
1258
+ )
1259
+ or self.DEFAULT_BOOTSTRAP_SIMS
1260
+ ),
1261
+ bootstrap_block_hours=int(
1262
+ self._cfg_get(
1263
+ "bootstrap_block_hours",
1264
+ self.DEFAULT_BOOTSTRAP_BLOCK_HOURS,
1265
+ )
1266
+ or 0
1267
+ ),
1268
+ bootstrap_seed=self._cfg_get("bootstrap_seed"),
1269
+ )
1270
+ except Exception as exc: # noqa: BLE001
1271
+ self.logger.warning(
1272
+ f"Snapshot selection failed (in-memory): {exc}. Falling back to live solver."
1273
+ )
1274
+
1275
+ snapshot_path = self._snapshot_path_from_config()
1276
+ if best is None and snapshot_path and Path(snapshot_path).exists():
1277
+ try:
1278
+ snapshot = self.load_snapshot_from_path(snapshot_path)
1279
+ opps = self.opportunities_from_snapshot(
1280
+ snapshot=snapshot, deposit_usdc=self.deposit_amount
1281
+ )
1282
+ if opps:
1283
+ self.logger.info(
1284
+ f"Selecting best opportunity from snapshot {snapshot_path}"
1285
+ )
1286
+ best = await self.score_opportunity_from_snapshot(
1287
+ opportunity=opps[0],
1288
+ deposit_usdc=self.deposit_amount,
1289
+ horizons_days=[1, 7],
1290
+ stop_frac=self.LIQUIDATION_REBALANCE_THRESHOLD,
1291
+ lookback_days=self.DEFAULT_LOOKBACK_DAYS,
1292
+ fee_eps=self.DEFAULT_FEE_EPS,
1293
+ perp_slippage_bps=1.0,
1294
+ cooloff_hours=0,
1295
+ bootstrap_sims=int(
1296
+ self._cfg_get(
1297
+ "bootstrap_sims", self.DEFAULT_BOOTSTRAP_SIMS
1298
+ )
1299
+ or self.DEFAULT_BOOTSTRAP_SIMS
1300
+ ),
1301
+ bootstrap_block_hours=int(
1302
+ self._cfg_get(
1303
+ "bootstrap_block_hours",
1304
+ self.DEFAULT_BOOTSTRAP_BLOCK_HOURS,
1305
+ )
1306
+ or 0
1307
+ ),
1308
+ bootstrap_seed=self._cfg_get("bootstrap_seed"),
1309
+ )
1310
+ except Exception as exc: # noqa: BLE001
1311
+ self.logger.warning(
1312
+ f"Snapshot selection failed ({snapshot_path}): {exc}. "
1313
+ "Falling back to live solver."
1314
+ )
1315
+
1316
+ if best is None:
1317
+ best = await self.find_best_trade_with_backtest(
1318
+ deposit_usdc=self.deposit_amount,
1319
+ stop_frac=self.LIQUIDATION_REBALANCE_THRESHOLD,
1320
+ lookback_days=self.DEFAULT_LOOKBACK_DAYS,
1321
+ fee_eps=self.DEFAULT_FEE_EPS,
1322
+ oi_floor=self.DEFAULT_OI_FLOOR,
1323
+ day_vlm_floor=self.DEFAULT_DAY_VLM_FLOOR,
1324
+ horizons_days=[1, 7],
1325
+ max_leverage=self.DEFAULT_MAX_LEVERAGE,
1326
+ )
1327
+
1328
+ if not best:
1329
+ return (True, "No suitable basis opportunities found at this time.")
1330
+
1331
+ coin = best.get("coin", "unknown")
1332
+ safe = best.get("safe", {})
1333
+
1334
+ # Use 7-day horizon sizing by default
1335
+ safe_7 = safe.get("7") or {}
1336
+ if safe_7.get("spot_usdc", 0) <= 0 or safe_7.get("perp_amount", 0) <= 0:
1337
+ return (True, f"Best opportunity ({coin}) returned zero sizing.")
1338
+
1339
+ leverage = int(safe_7.get("safe_leverage", best.get("best_L", 1)) or 1)
1340
+ expected_net_apy_pct = float(best.get("net_apy", 0.0) or 0.0) * 100.0
1341
+ target_qty = min(
1342
+ float(safe_7.get("spot_amount", 0.0) or 0.0),
1343
+ float(safe_7.get("perp_amount", 0.0) or 0.0),
1344
+ )
1345
+ spot_asset_id = best.get("spot_asset_id", 0)
1346
+ perp_asset_id = best.get("perp_asset_id", 0)
1347
+
1348
+ self.logger.info(
1349
+ f"Best opportunity: {coin} at {leverage}x leverage, "
1350
+ f"expected net APY: {expected_net_apy_pct:.2f}%, target qty: {target_qty}"
1351
+ )
1352
+
1353
+ # Execute position using PairedFiller
1354
+ address = self._get_strategy_wallet_address()
1355
+ order_usd = float(safe_7.get("spot_usdc", 0.0) or 0.0)
1356
+ order_usd = float(
1357
+ Decimal(str(order_usd)).quantize(Decimal("0.01"), rounding=ROUND_UP)
1358
+ )
1359
+
1360
+ if self.simulation:
1361
+ self.logger.info(
1362
+ f"[SIMULATION] Would open {target_qty} {coin} basis position"
1363
+ )
1364
+ spot_filled = target_qty
1365
+ perp_filled = target_qty
1366
+ spot_notional = order_usd
1367
+ perp_notional = order_usd
1368
+ entry_price = float(best.get("mark_price", 0.0) or 100.0)
1369
+ else:
1370
+ # Step 1: Ensure builder fee is approved
1371
+ fee_success, fee_msg = await self.ensure_builder_fee_approved()
1372
+ if not fee_success:
1373
+ return (False, f"Builder fee approval failed: {fee_msg}")
1374
+
1375
+ # Step 2: Update leverage for the perp asset
1376
+ self.logger.info(f"Setting leverage to {leverage}x for {coin}")
1377
+ success, lev_result = await self.hyperliquid_adapter.update_leverage(
1378
+ asset_id=perp_asset_id,
1379
+ leverage=leverage,
1380
+ is_cross=True,
1381
+ address=address,
1382
+ )
1383
+ if not success:
1384
+ self.logger.warning(f"Failed to set leverage: {lev_result}")
1385
+ # Continue anyway - leverage might already be set
1386
+
1387
+ # Step 3: Transfer USDC from perp to spot for spot purchase
1388
+ # We need approximately order_usd in spot to buy the asset
1389
+ self.logger.info(
1390
+ f"Transferring ${order_usd:.2f} from perp to spot for {coin}"
1391
+ )
1392
+ (
1393
+ success,
1394
+ transfer_result,
1395
+ ) = await self.hyperliquid_adapter.transfer_perp_to_spot(
1396
+ amount=order_usd,
1397
+ address=address,
1398
+ )
1399
+ if not success:
1400
+ self.logger.warning(
1401
+ f"Perp to spot transfer failed: {transfer_result}"
1402
+ )
1403
+ # May fail if already in spot, continue
1404
+
1405
+ # Step 4: Execute paired fill
1406
+ filler = PairedFiller(
1407
+ adapter=self.hyperliquid_adapter,
1408
+ address=address,
1409
+ cfg=FillConfig(max_slip_bps=35, max_chunk_usd=7500.0),
1410
+ )
1411
+
1412
+ (
1413
+ spot_filled,
1414
+ perp_filled,
1415
+ spot_notional,
1416
+ perp_notional,
1417
+ spot_pointers,
1418
+ perp_pointers,
1419
+ ) = await filler.fill_pair_units(
1420
+ coin=coin,
1421
+ spot_asset_id=spot_asset_id,
1422
+ perp_asset_id=perp_asset_id,
1423
+ total_units=target_qty,
1424
+ direction="long_spot_short_perp",
1425
+ builder_fee=self.builder_fee,
1426
+ )
1427
+
1428
+ if spot_filled <= 0 or perp_filled <= 0:
1429
+ return (False, f"Failed to fill basis position on {coin}")
1430
+
1431
+ self.logger.info(
1432
+ f"Filled basis position: spot={spot_filled:.6f}, perp={perp_filled:.6f}, "
1433
+ f"notional=${spot_notional:.2f}/${perp_notional:.2f}"
1434
+ )
1435
+
1436
+ # Get entry price from current mid
1437
+ success, mids = await self.hyperliquid_adapter.get_all_mid_prices()
1438
+ entry_price = mids.get(coin, 0.0) if success else 0.0
1439
+
1440
+ # Step 5: Get liquidation price and place stop-loss
1441
+ success, user_state = await self.hyperliquid_adapter.get_user_state(
1442
+ address
1443
+ )
1444
+ liquidation_price = None
1445
+ if success:
1446
+ for pos_wrapper in user_state.get("assetPositions", []):
1447
+ pos = pos_wrapper.get("position", {})
1448
+ if pos.get("coin") == coin:
1449
+ liquidation_price = float(pos.get("liquidationPx", 0))
1450
+ break
1451
+
1452
+ if liquidation_price and liquidation_price > 0:
1453
+ sl_success, sl_msg = await self._place_stop_loss_orders(
1454
+ coin=coin,
1455
+ perp_asset_id=perp_asset_id,
1456
+ position_size=perp_filled,
1457
+ entry_price=entry_price,
1458
+ liquidation_price=liquidation_price,
1459
+ spot_asset_id=spot_asset_id,
1460
+ spot_position_size=spot_filled,
1461
+ )
1462
+ if not sl_success:
1463
+ self.logger.warning(f"Stop-loss placement failed: {sl_msg}")
1464
+ else:
1465
+ self.logger.warning("Could not get liquidation price for stop-loss")
1466
+
1467
+ # Create position record
1468
+ self.current_position = BasisPosition(
1469
+ coin=coin,
1470
+ spot_asset_id=spot_asset_id,
1471
+ perp_asset_id=perp_asset_id,
1472
+ spot_amount=spot_filled,
1473
+ perp_amount=perp_filled,
1474
+ entry_price=entry_price,
1475
+ leverage=leverage,
1476
+ entry_timestamp=int(time.time() * 1000),
1477
+ )
1478
+
1479
+ return (
1480
+ True,
1481
+ f"Opened basis position on {coin}: {spot_filled:.4f} units at {leverage}x, expected net APY: {expected_net_apy_pct:.1f}%",
1482
+ )
1483
+
1484
+ except Exception as e:
1485
+ self.logger.error(f"Error finding basis opportunities: {e}")
1486
+ return (False, f"Analysis failed: {e}")
1487
+
1488
+ # ------------------------------------------------------------------ #
1489
+ # Position Scaling #
1490
+ # ------------------------------------------------------------------ #
1491
+
1492
+ async def _get_undeployed_capital(self) -> tuple[float, float]:
1493
+ """
1494
+ Calculate undeployed capital that can be added to the position.
1495
+
1496
+ Returns:
1497
+ (perp_margin_available, spot_usdc_available)
1498
+ """
1499
+ address = self._get_strategy_wallet_address()
1500
+
1501
+ # Get perp state
1502
+ success, user_state = await self.hyperliquid_adapter.get_user_state(address)
1503
+ if not success:
1504
+ return 0.0, 0.0
1505
+
1506
+ # Hyperliquid userState commonly nests withdrawable under marginSummary, but keep
1507
+ # compatibility with any top-level "withdrawable" shape.
1508
+ withdrawable_val = user_state.get("withdrawable")
1509
+ if withdrawable_val is None:
1510
+ margin_summary = user_state.get("marginSummary") or {}
1511
+ if isinstance(margin_summary, dict):
1512
+ withdrawable_val = margin_summary.get("withdrawable")
1513
+
1514
+ withdrawable = float(withdrawable_val or 0.0)
1515
+
1516
+ # Get spot USDC balance
1517
+ success, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
1518
+ address
1519
+ )
1520
+ spot_usdc = 0.0
1521
+ if success:
1522
+ for bal in spot_state.get("balances", []):
1523
+ if bal.get("coin") == "USDC":
1524
+ spot_usdc = float(bal.get("total", 0))
1525
+ break
1526
+
1527
+ return withdrawable, spot_usdc
1528
+
1529
+ async def _scale_up_position(self, additional_capital: float) -> StatusTuple:
1530
+ """
1531
+ Add capital to existing position without breaking it.
1532
+
1533
+ Uses PairedFiller to atomically add to both spot and perp legs,
1534
+ maintaining delta neutrality.
1535
+
1536
+ Args:
1537
+ additional_capital: USD amount of new capital to deploy
1538
+
1539
+ Returns:
1540
+ StatusTuple (success, message)
1541
+ """
1542
+ if self.current_position is None:
1543
+ return False, "No position to scale up"
1544
+
1545
+ pos = self.current_position
1546
+ address = self._get_strategy_wallet_address()
1547
+
1548
+ # Get current leverage from position
1549
+ leverage = pos.leverage or 2
1550
+
1551
+ # Calculate how much to add to each leg
1552
+ # order_usd = capital * (L / (L + 1)) for leveraged position
1553
+ order_usd = additional_capital * (leverage / (leverage + 1))
1554
+
1555
+ # Get current price
1556
+ success, mids = await self.hyperliquid_adapter.get_all_mid_prices()
1557
+ if not success:
1558
+ return False, "Failed to get mid prices"
1559
+
1560
+ price = mids.get(pos.coin, 0.0)
1561
+ if price <= 0:
1562
+ return False, f"Invalid price for {pos.coin}"
1563
+
1564
+ # Check minimum notional ($10 USD per side)
1565
+ if order_usd < MIN_NOTIONAL_USD:
1566
+ return (
1567
+ True,
1568
+ f"Additional capital ${order_usd:.2f} below minimum notional ${MIN_NOTIONAL_USD}",
1569
+ )
1570
+
1571
+ # Calculate units to add
1572
+ units_to_add = order_usd / price
1573
+
1574
+ # Round to valid decimals for the assets
1575
+ spot_valid = self.hyperliquid_adapter.get_valid_order_size(
1576
+ pos.spot_asset_id, units_to_add
1577
+ )
1578
+ perp_valid = self.hyperliquid_adapter.get_valid_order_size(
1579
+ pos.perp_asset_id, units_to_add
1580
+ )
1581
+ units_to_add = min(spot_valid, perp_valid)
1582
+
1583
+ if units_to_add <= 0:
1584
+ return (
1585
+ True,
1586
+ "Additional capital rounds to zero units after decimal adjustment",
1587
+ )
1588
+
1589
+ self.logger.info(
1590
+ f"Scaling up {pos.coin} position: adding {units_to_add:.4f} units "
1591
+ f"(${order_usd:.2f}) at {leverage}x leverage"
1592
+ )
1593
+
1594
+ # Transfer USDC from perp to spot for the spot purchase
1595
+ perp_margin, spot_usdc = await self._get_undeployed_capital()
1596
+
1597
+ if perp_margin > 1.0 and spot_usdc < order_usd:
1598
+ # Need to move some from perp margin to spot
1599
+ transfer_amount = min(perp_margin, order_usd - spot_usdc)
1600
+ success, result = await self.hyperliquid_adapter.transfer_perp_to_spot(
1601
+ amount=transfer_amount,
1602
+ address=address,
1603
+ )
1604
+ if not success:
1605
+ self.logger.warning(f"Perp to spot transfer failed: {result}")
1606
+
1607
+ # Execute paired fill to add to both legs
1608
+ filler = PairedFiller(
1609
+ adapter=self.hyperliquid_adapter,
1610
+ address=address,
1611
+ cfg=FillConfig(max_slip_bps=35, max_chunk_usd=7500.0),
1612
+ )
1613
+
1614
+ try:
1615
+ (
1616
+ spot_filled,
1617
+ perp_filled,
1618
+ spot_notional,
1619
+ perp_notional,
1620
+ _,
1621
+ _,
1622
+ ) = await filler.fill_pair_units(
1623
+ coin=pos.coin,
1624
+ spot_asset_id=pos.spot_asset_id,
1625
+ perp_asset_id=pos.perp_asset_id,
1626
+ total_units=units_to_add,
1627
+ direction="long_spot_short_perp", # Buy spot, sell perp
1628
+ builder_fee=self.builder_fee,
1629
+ )
1630
+ except Exception as e:
1631
+ self.logger.error(f"PairedFiller failed: {e}")
1632
+ return False, f"Failed to scale position: {e}"
1633
+
1634
+ if spot_filled <= 0 or perp_filled <= 0:
1635
+ return False, f"Failed to add to position on {pos.coin}"
1636
+
1637
+ # Update position tracking
1638
+ self.current_position = BasisPosition(
1639
+ coin=pos.coin,
1640
+ spot_asset_id=pos.spot_asset_id,
1641
+ perp_asset_id=pos.perp_asset_id,
1642
+ spot_amount=pos.spot_amount + spot_filled,
1643
+ perp_amount=pos.perp_amount + perp_filled,
1644
+ entry_price=price, # Use new price as weighted entry
1645
+ leverage=leverage,
1646
+ entry_timestamp=pos.entry_timestamp, # Keep original
1647
+ funding_collected=pos.funding_collected,
1648
+ )
1649
+
1650
+ self.logger.info(
1651
+ f"Scaled up position: +{spot_filled:.4f} spot, +{perp_filled:.4f} perp. "
1652
+ f"Total now: {self.current_position.spot_amount:.4f} / {self.current_position.perp_amount:.4f}"
1653
+ )
1654
+
1655
+ return (
1656
+ True,
1657
+ f"Added {spot_filled:.4f} {pos.coin} to position (${spot_notional:.2f})",
1658
+ )
1659
+
1660
+ async def _monitor_position(self) -> StatusTuple:
1661
+ """
1662
+ Monitor existing position for exit/rebalance conditions.
1663
+
1664
+ Checks:
1665
+ 1. Whether rebalance is needed (funding, liquidity, etc.)
1666
+ 2. Both legs are balanced (spot and perp amounts match)
1667
+ 3. No significant idle capital (deploy if found)
1668
+ 4. Stop-loss orders are in place and valid
1669
+ """
1670
+ if self.current_position is None:
1671
+ return (True, "No position to monitor")
1672
+
1673
+ pos = self.current_position
1674
+ coin = pos.coin
1675
+ address = self._get_strategy_wallet_address()
1676
+ actions_taken: list[str] = []
1677
+
1678
+ # Get current state
1679
+ success, state = await self.hyperliquid_adapter.get_user_state(address)
1680
+ if not success:
1681
+ return (False, f"Failed to fetch user state: {state}")
1682
+
1683
+ # Calculate deposited amount from current on-exchange value
1684
+ total_value, hl_value, _ = await self._get_total_portfolio_value()
1685
+
1686
+ # ------------------------------------------------------------------ #
1687
+ # Check 1: Rebalance needed? #
1688
+ # ------------------------------------------------------------------ #
1689
+ needs_rebalance, reason = await self._needs_new_position(state, hl_value)
1690
+
1691
+ if needs_rebalance:
1692
+ # Check rotation cooldown
1693
+ rotation_allowed, cooldown_reason = await self._is_rotation_allowed()
1694
+
1695
+ if not rotation_allowed:
1696
+ self.logger.info(f"Rebalance needed ({reason}) but {cooldown_reason}")
1697
+ return (
1698
+ True,
1699
+ f"Position needs attention but in cooldown: {cooldown_reason}",
1700
+ )
1701
+
1702
+ # Perform rebalance: close and reopen
1703
+ self.logger.info(f"Rebalancing position: {reason}")
1704
+
1705
+ # Close existing position
1706
+ close_success, close_msg = await self._close_position()
1707
+ if not close_success:
1708
+ return (False, f"Rebalance failed - could not close: {close_msg}")
1709
+
1710
+ # Open new position
1711
+ return await self._find_and_open_position()
1712
+
1713
+ # ------------------------------------------------------------------ #
1714
+ # Check 2: Verify both legs are balanced #
1715
+ # ------------------------------------------------------------------ #
1716
+ leg_ok, leg_msg = await self._verify_leg_balance(state)
1717
+ if not leg_ok:
1718
+ self.logger.warning(f"Leg imbalance detected: {leg_msg}")
1719
+ # Try to repair the imbalance
1720
+ repair_ok, repair_msg = await self._repair_leg_imbalance(state)
1721
+ if repair_ok:
1722
+ actions_taken.append(f"Repaired leg imbalance: {repair_msg}")
1723
+ else:
1724
+ actions_taken.append(f"Leg imbalance repair failed: {repair_msg}")
1725
+
1726
+ # ------------------------------------------------------------------ #
1727
+ # Check 3: Deploy any idle capital #
1728
+ # ------------------------------------------------------------------ #
1729
+ perp_margin, spot_usdc = await self._get_undeployed_capital()
1730
+ total_idle = perp_margin + spot_usdc
1731
+ min_deploy = max(self.MIN_UNUSED_USD, self.UNUSED_REL_EPS * self.deposit_amount)
1732
+
1733
+ if total_idle > min_deploy:
1734
+ self.logger.info(
1735
+ f"Found ${total_idle:.2f} idle capital, scaling up position"
1736
+ )
1737
+ scale_ok, scale_msg = await self._scale_up_position(total_idle)
1738
+ if scale_ok:
1739
+ actions_taken.append(f"Scaled up: {scale_msg}")
1740
+ # Refresh state after scale-up so stop-loss uses new position size/liq price
1741
+ success, state = await self.hyperliquid_adapter.get_user_state(address)
1742
+ if not success:
1743
+ self.logger.warning("Could not refresh state after scale-up")
1744
+ else:
1745
+ actions_taken.append(f"Scale-up failed: {scale_msg}")
1746
+
1747
+ # ------------------------------------------------------------------ #
1748
+ # Check 4: Verify stop-loss orders #
1749
+ # ------------------------------------------------------------------ #
1750
+ sl_ok, sl_msg = await self._ensure_stop_loss_valid(state)
1751
+ if not sl_ok:
1752
+ actions_taken.append(f"Stop-loss issue: {sl_msg}")
1753
+ elif "placed" in sl_msg.lower() or "updated" in sl_msg.lower():
1754
+ actions_taken.append(sl_msg)
1755
+
1756
+ position_age_hours = (time.time() * 1000 - pos.entry_timestamp) / (1000 * 3600)
1757
+
1758
+ if actions_taken:
1759
+ return (
1760
+ True,
1761
+ f"Position on {coin} monitored, age: {position_age_hours:.1f}h. Actions: {'; '.join(actions_taken)}",
1762
+ )
1763
+
1764
+ return (
1765
+ True,
1766
+ f"Position on {coin} healthy, age: {position_age_hours:.1f}h",
1767
+ )
1768
+
1769
+ async def _verify_leg_balance(self, state: dict[str, Any]) -> tuple[bool, str]:
1770
+ """
1771
+ Verify that spot and perp legs are balanced (delta neutral).
1772
+
1773
+ Returns:
1774
+ (is_balanced, message)
1775
+ """
1776
+ if self.current_position is None:
1777
+ return True, "No position"
1778
+
1779
+ pos = self.current_position
1780
+ coin = pos.coin
1781
+
1782
+ # Get actual perp position size from state
1783
+ perp_size = 0.0
1784
+ for pos_wrapper in state.get("assetPositions", []):
1785
+ position = pos_wrapper.get("position", {})
1786
+ if position.get("coin") == coin:
1787
+ perp_size = abs(float(position.get("szi", 0)))
1788
+ break
1789
+
1790
+ # Get actual spot balance
1791
+ address = self._get_strategy_wallet_address()
1792
+ success, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
1793
+ address
1794
+ )
1795
+ spot_size = 0.0
1796
+ if success:
1797
+ for bal in spot_state.get("balances", []):
1798
+ if bal.get("coin") == coin:
1799
+ spot_size = float(bal.get("total", 0))
1800
+ break
1801
+
1802
+ # Check balance - allow 2% tolerance
1803
+ if spot_size <= 0 and perp_size <= 0:
1804
+ return False, "Both legs are zero"
1805
+
1806
+ max_size = max(spot_size, perp_size)
1807
+ if max_size > 0:
1808
+ imbalance_pct = abs(spot_size - perp_size) / max_size
1809
+ if imbalance_pct > 0.02: # 2% tolerance
1810
+ return (
1811
+ False,
1812
+ f"Imbalance: spot={spot_size:.6f}, perp={perp_size:.6f} ({imbalance_pct * 100:.1f}%)",
1813
+ )
1814
+
1815
+ # Update tracked position with actual values
1816
+ self.current_position = BasisPosition(
1817
+ coin=pos.coin,
1818
+ spot_asset_id=pos.spot_asset_id,
1819
+ perp_asset_id=pos.perp_asset_id,
1820
+ spot_amount=spot_size,
1821
+ perp_amount=perp_size,
1822
+ entry_price=pos.entry_price,
1823
+ leverage=pos.leverage,
1824
+ entry_timestamp=pos.entry_timestamp,
1825
+ funding_collected=pos.funding_collected,
1826
+ )
1827
+
1828
+ return True, f"Balanced: spot={spot_size:.6f}, perp={perp_size:.6f}"
1829
+
1830
+ async def _repair_leg_imbalance(self, state: dict[str, Any]) -> tuple[bool, str]:
1831
+ """
1832
+ Attempt to repair an imbalance between spot and perp legs.
1833
+
1834
+ If one leg is larger, adds to the smaller leg to match.
1835
+ """
1836
+ if self.current_position is None:
1837
+ return True, "No position"
1838
+
1839
+ if self.simulation:
1840
+ return True, "[SIMULATION] Would repair leg imbalance"
1841
+
1842
+ pos = self.current_position
1843
+ coin = pos.coin
1844
+ address = self._get_strategy_wallet_address()
1845
+
1846
+ # Get actual sizes
1847
+ perp_size = 0.0
1848
+ for pos_wrapper in state.get("assetPositions", []):
1849
+ position = pos_wrapper.get("position", {})
1850
+ if position.get("coin") == coin:
1851
+ perp_size = abs(float(position.get("szi", 0)))
1852
+ break
1853
+
1854
+ success, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
1855
+ address
1856
+ )
1857
+ spot_size = 0.0
1858
+ if success:
1859
+ for bal in spot_state.get("balances", []):
1860
+ if bal.get("coin") == coin:
1861
+ spot_size = float(bal.get("total", 0))
1862
+ break
1863
+
1864
+ diff = abs(spot_size - perp_size)
1865
+ if diff < 0.001:
1866
+ return True, "Legs already balanced"
1867
+
1868
+ # Get current price
1869
+ success, mids = await self.hyperliquid_adapter.get_all_mid_prices()
1870
+ if not success:
1871
+ return False, "Failed to get mid prices"
1872
+ price = mids.get(coin, 0)
1873
+ if price <= 0:
1874
+ return False, f"Invalid price for {coin}"
1875
+
1876
+ diff_usd = diff * price
1877
+ if diff_usd < 10: # Below minimum notional
1878
+ return True, f"Imbalance ${diff_usd:.2f} below minimum notional"
1879
+
1880
+ try:
1881
+ if spot_size > perp_size:
1882
+ # Need more perp (short more)
1883
+ self.logger.info(
1884
+ f"Repairing imbalance: shorting {diff:.6f} {coin} perp"
1885
+ )
1886
+ success, result = await self.hyperliquid_adapter.place_market_order(
1887
+ asset_id=pos.perp_asset_id,
1888
+ is_buy=False,
1889
+ slippage=0.01,
1890
+ size=self.hyperliquid_adapter.get_valid_order_size(
1891
+ pos.perp_asset_id, diff
1892
+ ),
1893
+ address=address,
1894
+ builder=self.builder_fee,
1895
+ )
1896
+ if not success:
1897
+ return False, f"Failed to add perp: {result}"
1898
+ return True, f"Added {diff:.6f} perp short"
1899
+ else:
1900
+ # Need more spot (buy more)
1901
+ self.logger.info(f"Repairing imbalance: buying {diff:.6f} {coin} spot")
1902
+ success, result = await self.hyperliquid_adapter.place_market_order(
1903
+ asset_id=pos.spot_asset_id,
1904
+ is_buy=True,
1905
+ slippage=0.01,
1906
+ size=self.hyperliquid_adapter.get_valid_order_size(
1907
+ pos.spot_asset_id, diff
1908
+ ),
1909
+ address=address,
1910
+ builder=self.builder_fee,
1911
+ )
1912
+ if not success:
1913
+ return False, f"Failed to add spot: {result}"
1914
+ return True, f"Added {diff:.6f} spot"
1915
+ except Exception as e:
1916
+ return False, f"Repair failed: {e}"
1917
+
1918
+ async def _ensure_stop_loss_valid(self, state: dict[str, Any]) -> tuple[bool, str]:
1919
+ """
1920
+ Ensure stop-loss orders are in place and valid for current position.
1921
+
1922
+ Checks:
1923
+ - Stop-loss exists for the perp leg
1924
+ - Trigger price is valid (below liquidation price)
1925
+ - Size matches position size
1926
+
1927
+ Returns:
1928
+ (success, message)
1929
+ """
1930
+ if self.current_position is None:
1931
+ return True, "No position"
1932
+
1933
+ if self.simulation:
1934
+ return True, "[SIMULATION] Stop-loss check skipped"
1935
+
1936
+ pos = self.current_position
1937
+ coin = pos.coin
1938
+
1939
+ # Get current perp position and liquidation price
1940
+ perp_size = 0.0
1941
+ liquidation_price = None
1942
+ entry_price = pos.entry_price
1943
+
1944
+ for pos_wrapper in state.get("assetPositions", []):
1945
+ position = pos_wrapper.get("position", {})
1946
+ if position.get("coin") == coin:
1947
+ perp_size = abs(float(position.get("szi", 0)))
1948
+ liquidation_price = float(position.get("liquidationPx", 0))
1949
+ # Update entry price from position if available
1950
+ entry_px = position.get("entryPx")
1951
+ if entry_px:
1952
+ entry_price = float(entry_px)
1953
+ break
1954
+
1955
+ if perp_size <= 0:
1956
+ return True, "No perp position to protect"
1957
+
1958
+ if not liquidation_price or liquidation_price <= 0:
1959
+ return False, "Could not determine liquidation price"
1960
+
1961
+ # Get spot position size from LIVE balance (not stored position)
1962
+ # to ensure stop-loss covers the actual spot holdings
1963
+ spot_position = await self._get_spot_position()
1964
+ if spot_position:
1965
+ spot_size = float(spot_position.get("total", 0))
1966
+ else:
1967
+ spot_size = pos.spot_amount
1968
+
1969
+ # Call existing method which checks and places/updates if needed
1970
+ return await self._place_stop_loss_orders(
1971
+ coin=coin,
1972
+ perp_asset_id=pos.perp_asset_id,
1973
+ position_size=perp_size,
1974
+ entry_price=entry_price,
1975
+ liquidation_price=liquidation_price,
1976
+ spot_asset_id=pos.spot_asset_id,
1977
+ spot_position_size=spot_size,
1978
+ )
1979
+
1980
+ async def _cancel_all_position_orders(self) -> None:
1981
+ """Cancel all open orders (stop-loss, limit) for the current position."""
1982
+ if self.current_position is None:
1983
+ return
1984
+
1985
+ pos = self.current_position
1986
+ address = self._get_strategy_wallet_address()
1987
+ spot_coin = (
1988
+ f"@{pos.spot_asset_id - 10000}" if pos.spot_asset_id >= 10000 else None
1989
+ )
1990
+
1991
+ # Get all open orders including triggers
1992
+ success, open_orders = await self.hyperliquid_adapter.get_frontend_open_orders(
1993
+ address
1994
+ )
1995
+ if not success:
1996
+ self.logger.warning("Could not fetch open orders to cancel")
1997
+ return
1998
+
1999
+ for order in open_orders:
2000
+ order_coin = order.get("coin", "")
2001
+ order_id = order.get("oid")
2002
+
2003
+ # Cancel perp orders for this coin
2004
+ if order_coin == pos.coin and order_id:
2005
+ self.logger.info(f"Canceling perp order {order_id} for {pos.coin}")
2006
+ await self.hyperliquid_adapter.cancel_order(
2007
+ asset_id=pos.perp_asset_id,
2008
+ order_id=order_id,
2009
+ address=address,
2010
+ )
2011
+
2012
+ # Cancel spot orders for this coin
2013
+ if spot_coin and order_coin == spot_coin and order_id:
2014
+ self.logger.info(f"Canceling spot order {order_id} for {spot_coin}")
2015
+ await self.hyperliquid_adapter.cancel_order(
2016
+ asset_id=pos.spot_asset_id,
2017
+ order_id=order_id,
2018
+ address=address,
2019
+ )
2020
+
2021
+ async def _close_position(self) -> StatusTuple:
2022
+ """Close the current position."""
2023
+ if self.current_position is None:
2024
+ return (True, "No position to close")
2025
+
2026
+ pos = self.current_position
2027
+ self.logger.info(f"Closing position on {pos.coin}")
2028
+
2029
+ if self.simulation:
2030
+ self.logger.info(
2031
+ f"[SIMULATION] Would close {pos.spot_amount} {pos.coin} basis position"
2032
+ )
2033
+ self.current_position = None
2034
+ return (True, "Position closed (simulation)")
2035
+
2036
+ # Cancel all stop-loss and limit orders first
2037
+ await self._cancel_all_position_orders()
2038
+
2039
+ # Real execution via PairedFiller - reverse direction to close
2040
+ try:
2041
+ address = self._get_strategy_wallet_address()
2042
+ filler = PairedFiller(
2043
+ adapter=self.hyperliquid_adapter,
2044
+ address=address,
2045
+ cfg=FillConfig(max_slip_bps=50, max_chunk_usd=7500.0),
2046
+ )
2047
+
2048
+ # Close by going opposite direction: sell spot, buy perp
2049
+ close_units = max(pos.spot_amount, pos.perp_amount)
2050
+ (
2051
+ spot_closed,
2052
+ perp_closed,
2053
+ spot_notional,
2054
+ perp_notional,
2055
+ _,
2056
+ _,
2057
+ ) = await filler.fill_pair_units(
2058
+ coin=pos.coin,
2059
+ spot_asset_id=pos.spot_asset_id,
2060
+ perp_asset_id=pos.perp_asset_id,
2061
+ total_units=close_units,
2062
+ direction="short_spot_long_perp", # Reverse to close
2063
+ builder_fee=self.builder_fee,
2064
+ )
2065
+
2066
+ if spot_closed <= 0 and perp_closed <= 0:
2067
+ self.logger.warning(
2068
+ f"Position close may be incomplete: spot={spot_closed}, perp={perp_closed}"
2069
+ )
2070
+
2071
+ self.logger.info(
2072
+ f"Closed position: spot={spot_closed:.6f}, perp={perp_closed:.6f}"
2073
+ )
2074
+ self.current_position = None
2075
+ return (True, f"Closed position on {pos.coin}")
2076
+
2077
+ except Exception as e:
2078
+ self.logger.error(f"Error closing position: {e}")
2079
+ return (False, f"Failed to close position: {e}")
2080
+
2081
+ # ------------------------------------------------------------------ #
2082
+ # Position Health Checks #
2083
+ # ------------------------------------------------------------------ #
2084
+
2085
+ async def _needs_new_position(
2086
+ self,
2087
+ state: dict[str, Any],
2088
+ deposited_amount: float,
2089
+ best: dict[str, Any] | None = None,
2090
+ ) -> tuple[bool, str]:
2091
+ """
2092
+ Check if current delta-neutral position needs rebalancing.
2093
+
2094
+ Implements 7 health checks from Django hyperliquid_adapter.py:
2095
+ 1. Missing positions
2096
+ 2. Asset mismatch (if best specified)
2097
+ 3. Funding accumulation threshold
2098
+ 4. Perp must be SHORT
2099
+ 5. Position imbalance (±4% dust tolerance)
2100
+ 6. Unused bankroll
2101
+ 7. Stop-loss orders exist and are valid
2102
+
2103
+ Returns:
2104
+ (needs_rebalance, reason) - True if rebalance needed
2105
+ """
2106
+ perp_position = self._get_perp_position(state)
2107
+ spot_position = await self._get_spot_position()
2108
+
2109
+ # Check 1: Missing positions
2110
+ if perp_position is None or spot_position is None:
2111
+ return True, "Missing perp or spot position"
2112
+
2113
+ # Check 2: Asset mismatch (if best specified)
2114
+ if best:
2115
+ if perp_position.get("asset_id") != best.get("perp_asset_id"):
2116
+ return True, "Perp asset mismatch"
2117
+ if spot_position.get("asset_id") != best.get("spot_asset_id"):
2118
+ return True, "Spot asset mismatch"
2119
+
2120
+ # Check 3: Funding accumulation threshold
2121
+ funding_earned = self._get_funding_earned(state)
2122
+ if funding_earned > deposited_amount * self.FUNDING_REBALANCE_THRESHOLD:
2123
+ return True, f"Funding earned {funding_earned:.2f} exceeds threshold"
2124
+
2125
+ # Check 4: Perp must be SHORT
2126
+ perp_size = float(perp_position.get("szi", 0))
2127
+ if perp_size >= 0:
2128
+ return True, "Perp position is not short"
2129
+
2130
+ # Check 5: Position imbalance (±4% dust tolerance)
2131
+ spot_size = abs(float(spot_position.get("total", 0)))
2132
+ perp_size_abs = abs(perp_size)
2133
+ lower = spot_size * (1 - self.SPOT_POSITION_DUST_TOLERANCE)
2134
+ upper = spot_size * (1 + self.SPOT_POSITION_DUST_TOLERANCE)
2135
+ if not (lower <= perp_size_abs <= upper):
2136
+ return True, f"Position imbalance: spot={spot_size}, perp={perp_size_abs}"
2137
+
2138
+ # Note: Unused capital is handled by _scale_up_position() in _monitor_position's
2139
+ # Check 3, NOT here. We should never trigger a full rebalance just because
2140
+ # there's idle capital - that should be added to the existing position.
2141
+
2142
+ # Note: Stop-loss validation is handled separately in _monitor_position's
2143
+ # Check 4 (_ensure_stop_loss_valid) which will place/update orders as needed
2144
+ # without triggering a full rebalance.
2145
+
2146
+ return False, "Position healthy"
2147
+
2148
+ def _get_perp_position(self, state: dict[str, Any]) -> dict[str, Any] | None:
2149
+ """Extract perp position matching current position from user state."""
2150
+ if self.current_position is None:
2151
+ return None
2152
+
2153
+ asset_positions = state.get("assetPositions", [])
2154
+ for pos_wrapper in asset_positions:
2155
+ pos = pos_wrapper.get("position", {})
2156
+ coin = pos.get("coin")
2157
+ if coin == self.current_position.coin:
2158
+ pos["asset_id"] = self.current_position.perp_asset_id
2159
+ return pos
2160
+
2161
+ return None
2162
+
2163
+ async def _get_spot_position(self) -> dict[str, Any] | None:
2164
+ """Get spot position from spot user state."""
2165
+ if self.current_position is None:
2166
+ return None
2167
+
2168
+ address = self._get_strategy_wallet_address()
2169
+ success, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
2170
+ address
2171
+ )
2172
+ if not success:
2173
+ return None
2174
+
2175
+ balances = spot_state.get("balances", [])
2176
+ for bal in balances:
2177
+ coin = bal.get("coin", "")
2178
+ # Match by stripping /USDC suffix or checking against coin name
2179
+ if coin == self.current_position.coin or coin.startswith(
2180
+ self.current_position.coin
2181
+ ):
2182
+ bal["asset_id"] = self.current_position.spot_asset_id
2183
+ return bal
2184
+
2185
+ return None
2186
+
2187
+ def _get_funding_earned(self, state: dict[str, Any]) -> float:
2188
+ """Extract cumulative funding earned from user state."""
2189
+ if self.current_position is None:
2190
+ return 0.0
2191
+
2192
+ asset_positions = state.get("assetPositions", [])
2193
+ for pos_wrapper in asset_positions:
2194
+ pos = pos_wrapper.get("position", {})
2195
+ if pos.get("coin") == self.current_position.coin:
2196
+ return abs(float(pos.get("cumFunding", {}).get("sinceOpen", 0)))
2197
+
2198
+ return 0.0
2199
+
2200
+ def _calculate_unused_usd(
2201
+ self, state: dict[str, Any], deposited_amount: float
2202
+ ) -> float:
2203
+ """Calculate unused USD not deployed in positions."""
2204
+ # Get account value
2205
+ margin_summary = state.get("marginSummary", {})
2206
+ account_value = float(margin_summary.get("accountValue", 0))
2207
+
2208
+ # Get total position value
2209
+ total_ntl = float(margin_summary.get("totalNtlPos", 0))
2210
+
2211
+ # Unused = account value - position value
2212
+ # For basis trading, we want most capital deployed
2213
+ unused = account_value - abs(total_ntl)
2214
+ return max(0.0, unused)
2215
+
2216
+ async def _validate_stop_loss_orders(
2217
+ self,
2218
+ state: dict[str, Any],
2219
+ perp_position: dict[str, Any],
2220
+ ) -> tuple[bool, str]:
2221
+ """Validate stop-loss orders exist and are below liquidation price."""
2222
+ address = self._get_strategy_wallet_address()
2223
+
2224
+ # Get liquidation price from perp position
2225
+ liquidation_price = perp_position.get("liquidationPx")
2226
+ if liquidation_price is None:
2227
+ return False, "No liquidation price found"
2228
+ liquidation_price = float(liquidation_price)
2229
+
2230
+ # Get open orders
2231
+ success, open_orders = await self.hyperliquid_adapter.get_open_orders(address)
2232
+ if not success:
2233
+ return False, "Failed to fetch open orders"
2234
+
2235
+ perp_asset_id = perp_position.get("asset_id")
2236
+
2237
+ # Find stop-loss orders for perp
2238
+ perp_sl_order = None
2239
+
2240
+ for order in open_orders:
2241
+ order_type = order.get("orderType", "")
2242
+ if "trigger" not in str(order_type).lower():
2243
+ continue
2244
+
2245
+ asset = order.get("coin") or order.get("asset")
2246
+ coin_match = (
2247
+ asset == self.current_position.coin if self.current_position else False
2248
+ )
2249
+
2250
+ if coin_match or order.get("asset_id") == perp_asset_id:
2251
+ perp_sl_order = order
2252
+ break
2253
+
2254
+ # Validate perp stop-loss exists
2255
+ if perp_sl_order is None:
2256
+ return False, "Missing perp stop-loss order"
2257
+
2258
+ # Validate price is below liquidation (for short, SL triggers on price RISE)
2259
+ perp_sl_price = float(perp_sl_order.get("triggerPx", 0))
2260
+ if perp_sl_price >= liquidation_price:
2261
+ return (
2262
+ False,
2263
+ f"Perp stop-loss {perp_sl_price} >= liquidation {liquidation_price}",
2264
+ )
2265
+
2266
+ return True, "Stop-loss orders valid"
2267
+
2268
+ async def _place_stop_loss_orders(
2269
+ self,
2270
+ coin: str,
2271
+ perp_asset_id: int,
2272
+ position_size: float,
2273
+ entry_price: float,
2274
+ liquidation_price: float,
2275
+ spot_asset_id: int | None = None,
2276
+ spot_position_size: float | None = None,
2277
+ ) -> tuple[bool, str]:
2278
+ """
2279
+ Place stop-loss orders for both perp and spot legs.
2280
+
2281
+ For basis trading:
2282
+ - Perp leg: Stop-market trigger order (buy to close short when price rises)
2283
+ - Spot leg: Limit sell order (sell spot at stop-loss price)
2284
+
2285
+ Both orders together maintain delta neutrality when the stop-loss is hit.
2286
+ """
2287
+ address = self._get_strategy_wallet_address()
2288
+
2289
+ # Get spot info from current position if not provided
2290
+ if spot_asset_id is None or spot_position_size is None:
2291
+ if self.current_position:
2292
+ spot_asset_id = self.current_position.spot_asset_id
2293
+ spot_position_size = self.current_position.spot_amount
2294
+ else:
2295
+ spot_asset_id = None
2296
+ spot_position_size = 0.0
2297
+
2298
+ # Calculate stop-loss trigger price (90% of distance to liquidation)
2299
+ # For short perp, liquidation is ABOVE entry price
2300
+ stop_loss_price = (
2301
+ entry_price
2302
+ + (liquidation_price - entry_price) * self.LIQUIDATION_STOP_LOSS_THRESHOLD
2303
+ )
2304
+ # Round to 5 significant figures to avoid SDK float_to_wire precision errors
2305
+ stop_loss_price = float(f"{stop_loss_price:.5g}")
2306
+
2307
+ # Get all open orders (frontend_open_orders includes trigger orders)
2308
+ success, open_orders = await self.hyperliquid_adapter.get_frontend_open_orders(
2309
+ address
2310
+ )
2311
+
2312
+ # Track existing valid orders and orders to cancel
2313
+ has_valid_perp_stop = False
2314
+ has_valid_spot_limit = False
2315
+ orders_to_cancel = []
2316
+
2317
+ # Spot coin name for matching (e.g., "@4" for HYPE spot)
2318
+ spot_coin = (
2319
+ f"@{spot_asset_id - 10000}"
2320
+ if spot_asset_id and spot_asset_id >= 10000
2321
+ else None
2322
+ )
2323
+
2324
+ if success:
2325
+ for order in open_orders:
2326
+ order_coin = order.get("coin", "")
2327
+ order_id = order.get("oid")
2328
+ is_trigger = order.get("isTrigger", False)
2329
+ order_type = str(order.get("orderType", "")).lower()
2330
+ is_sell = order.get("side", "").upper() == "A" # "A" = Ask/Sell
2331
+
2332
+ # Check PERP trigger orders (stop-loss)
2333
+ if order_coin == coin:
2334
+ is_trigger_order = (
2335
+ is_trigger or "stop" in order_type or "trigger" in order_type
2336
+ )
2337
+
2338
+ if is_trigger_order:
2339
+ existing_trigger = float(order.get("triggerPx", 0))
2340
+ existing_size = float(order.get("sz", 0))
2341
+
2342
+ if (
2343
+ existing_trigger < liquidation_price
2344
+ and existing_size >= position_size * 0.95
2345
+ and not has_valid_perp_stop
2346
+ ):
2347
+ # First valid perp stop-loss found
2348
+ has_valid_perp_stop = True
2349
+ self.logger.info(
2350
+ f"Valid perp stop-loss exists for {coin} at {existing_trigger} "
2351
+ f"(size: {existing_size})"
2352
+ )
2353
+ else:
2354
+ # Invalid or duplicate - mark for cancellation
2355
+ if order_id:
2356
+ orders_to_cancel.append(
2357
+ (perp_asset_id, order_id, "perp stop-loss")
2358
+ )
2359
+
2360
+ # Check SPOT limit sell orders
2361
+ if spot_coin and order_coin == spot_coin and is_sell:
2362
+ # This is a spot sell order (could be our stop-loss limit)
2363
+ existing_price = float(order.get("limitPx", 0))
2364
+ existing_size = float(order.get("sz", 0))
2365
+
2366
+ # Check if it's around our stop-loss price (within 5%)
2367
+ price_match = (
2368
+ abs(existing_price - stop_loss_price) / stop_loss_price < 0.05
2369
+ )
2370
+ # Spot limit must cover at least 99% of spot holdings
2371
+ size_valid = existing_size >= (spot_position_size or 0) * 0.99
2372
+
2373
+ if price_match and size_valid and not has_valid_spot_limit:
2374
+ # First valid spot limit sell found
2375
+ has_valid_spot_limit = True
2376
+ self.logger.info(
2377
+ f"Valid spot limit sell exists for {spot_coin} at {existing_price} "
2378
+ f"(size: {existing_size})"
2379
+ )
2380
+ elif not is_trigger:
2381
+ # Invalid or duplicate spot limit - mark for cancellation
2382
+ # But only cancel if it's a limit order (not trigger)
2383
+ if order_id:
2384
+ orders_to_cancel.append(
2385
+ (spot_asset_id, order_id, "spot limit")
2386
+ )
2387
+
2388
+ # Cancel invalid/duplicate orders
2389
+ for asset_id, order_id, order_desc in orders_to_cancel:
2390
+ self.logger.info(f"Canceling {order_desc} order {order_id}")
2391
+ await self.hyperliquid_adapter.cancel_order(
2392
+ asset_id=asset_id,
2393
+ order_id=order_id,
2394
+ address=address,
2395
+ )
2396
+
2397
+ # Place perp stop-loss if not valid one exists
2398
+ if not has_valid_perp_stop:
2399
+ success, result = await self.hyperliquid_adapter.place_stop_loss(
2400
+ asset_id=perp_asset_id,
2401
+ is_buy=True, # Buy to close short
2402
+ trigger_price=stop_loss_price,
2403
+ size=position_size,
2404
+ address=address,
2405
+ )
2406
+ if not success:
2407
+ return False, f"Failed to place perp stop-loss: {result}"
2408
+ self.logger.info(f"Placed perp stop-loss at {stop_loss_price} for {coin}")
2409
+
2410
+ # Place spot limit sell if needed
2411
+ if (
2412
+ spot_asset_id
2413
+ and spot_position_size
2414
+ and spot_position_size > 0
2415
+ and not has_valid_spot_limit
2416
+ ):
2417
+ # Get valid order size for spot
2418
+ spot_sell_size = self.hyperliquid_adapter.get_valid_order_size(
2419
+ spot_asset_id, spot_position_size
2420
+ )
2421
+ if spot_sell_size > 0:
2422
+ success, result = await self.hyperliquid_adapter.place_limit_order(
2423
+ asset_id=spot_asset_id,
2424
+ is_buy=False, # Sell
2425
+ price=stop_loss_price,
2426
+ size=spot_sell_size,
2427
+ address=address,
2428
+ reduce_only=False, # Spot doesn't have reduce_only
2429
+ )
2430
+ if not success:
2431
+ self.logger.warning(f"Failed to place spot limit sell: {result}")
2432
+ else:
2433
+ self.logger.info(
2434
+ f"Placed spot limit sell at {stop_loss_price} for {spot_coin} "
2435
+ f"(size: {spot_sell_size})"
2436
+ )
2437
+
2438
+ return True, "Stop-loss orders verified/placed"
2439
+
2440
+ # ------------------------------------------------------------------ #
2441
+ # Rotation Cooldown #
2442
+ # ------------------------------------------------------------------ #
2443
+
2444
+ async def _get_last_rotation_time(self) -> datetime | None:
2445
+ """Get timestamp of last position rotation from ledger."""
2446
+ wallet_address = self._get_strategy_wallet_address()
2447
+
2448
+ try:
2449
+ success, transactions = await self.ledger_adapter.get_strategy_transactions(
2450
+ wallet_address=wallet_address,
2451
+ limit=50,
2452
+ )
2453
+ if not success or not transactions:
2454
+ return None
2455
+
2456
+ # Find most recent spot buy transaction (indicates rotation)
2457
+ for txn in transactions:
2458
+ data = txn.get("data", {})
2459
+ op_data = data.get("op_data", {})
2460
+ if (
2461
+ op_data.get("type") == "HYPE_SPOT"
2462
+ and op_data.get("buy_or_sell") == "buy"
2463
+ ):
2464
+ created_at = txn.get("created_at")
2465
+ if created_at:
2466
+ return datetime.fromisoformat(created_at.replace("Z", "+00:00"))
2467
+
2468
+ return None
2469
+ except Exception as e:
2470
+ self.logger.warning(f"Could not get last rotation time: {e}")
2471
+ return None
2472
+
2473
+ async def _is_rotation_allowed(self) -> tuple[bool, str]:
2474
+ """Check if rotation cooldown has passed."""
2475
+ if self.current_position is None:
2476
+ return True, "No existing position"
2477
+
2478
+ last_rotation = await self._get_last_rotation_time()
2479
+ if last_rotation is None:
2480
+ return True, "No prior rotation found"
2481
+
2482
+ now = datetime.now(UTC)
2483
+ # Ensure last_rotation is timezone-aware
2484
+ if last_rotation.tzinfo is None:
2485
+ last_rotation = last_rotation.replace(tzinfo=UTC)
2486
+
2487
+ elapsed = now - last_rotation
2488
+ cooldown = timedelta(days=self.ROTATION_MIN_INTERVAL_DAYS)
2489
+
2490
+ if elapsed >= cooldown:
2491
+ return True, "Cooldown passed"
2492
+
2493
+ remaining = cooldown - elapsed
2494
+ return False, f"Rotation cooldown: {remaining.days} days remaining"
2495
+
2496
+ # ------------------------------------------------------------------ #
2497
+ # Live Portfolio Value #
2498
+ # ------------------------------------------------------------------ #
2499
+
2500
+ async def _get_total_portfolio_value(self) -> tuple[float, float, float]:
2501
+ """
2502
+ Get total portfolio value including Hyperliquid and vault balances.
2503
+
2504
+ Returns:
2505
+ (total_value, hyperliquid_value, vault_wallet_value)
2506
+ """
2507
+ address = self._get_strategy_wallet_address()
2508
+
2509
+ # Get Hyperliquid account value
2510
+ hl_value = 0.0
2511
+ success, user_state = await self.hyperliquid_adapter.get_user_state(address)
2512
+ if success:
2513
+ margin_summary = user_state.get("marginSummary", {})
2514
+ hl_value = float(margin_summary.get("accountValue", 0))
2515
+
2516
+ # Add spot value (all spot holdings, not just USDC)
2517
+ (
2518
+ success_spot,
2519
+ spot_state,
2520
+ ) = await self.hyperliquid_adapter.get_spot_user_state(address)
2521
+ if success_spot:
2522
+ spot_balances = spot_state.get("balances", [])
2523
+ # Get mid prices for non-USDC assets
2524
+ mid_prices: dict[str, float] = {}
2525
+ if any(bal.get("coin") != "USDC" for bal in spot_balances):
2526
+ (
2527
+ success_mids,
2528
+ mids,
2529
+ ) = await self.hyperliquid_adapter.get_all_mid_prices()
2530
+ if success_mids:
2531
+ mid_prices = mids
2532
+
2533
+ for bal in spot_balances:
2534
+ coin = bal.get("coin", "")
2535
+ total = float(bal.get("total", 0))
2536
+ if total <= 0:
2537
+ continue
2538
+
2539
+ if coin == "USDC":
2540
+ # USDC is 1:1
2541
+ hl_value += total
2542
+ else:
2543
+ # Look up mid price for non-USDC assets
2544
+ mid_price = mid_prices.get(coin, 0.0)
2545
+ if mid_price > 0:
2546
+ hl_value += total * mid_price
2547
+ else:
2548
+ self.logger.debug(
2549
+ f"No mid price found for spot {coin}, skipping"
2550
+ )
2551
+
2552
+ # Get strategy wallet USDC balance (on Arbitrum)
2553
+ strategy_wallet_value = 0.0
2554
+ try:
2555
+ strategy_address = self._get_strategy_wallet_address()
2556
+ success, balance = await self.balance_adapter.get_balance(
2557
+ token_id=USDC_ARBITRUM_TOKEN_ID,
2558
+ wallet_address=strategy_address,
2559
+ )
2560
+ if success and balance:
2561
+ strategy_wallet_value = float(balance) / 1e6 # Convert from raw to USDC
2562
+ except Exception as e:
2563
+ self.logger.debug(f"Could not fetch strategy wallet balance: {e}")
2564
+
2565
+ total_value = hl_value + strategy_wallet_value
2566
+ return total_value, hl_value, strategy_wallet_value
2567
+
2568
+ # ------------------------------------------------------------------ #
2569
+ # Analysis Methods (ported from BasisTradingService) #
2570
+ # ------------------------------------------------------------------ #
2571
+
2572
+ async def find_best_basis_trades(
2573
+ self,
2574
+ deposit_usdc: float,
2575
+ lookback_days: int = 180,
2576
+ confidence: float = 0.975,
2577
+ fee_eps: float = 0.003,
2578
+ oi_floor: float = 50.0,
2579
+ day_vlm_floor: float = 1e5,
2580
+ horizons_days: list[int] | None = None,
2581
+ max_leverage: int = 3,
2582
+ ) -> list[dict[str, Any]]:
2583
+ """
2584
+ Find optimal basis trading opportunities.
2585
+
2586
+ Args:
2587
+ deposit_usdc: Total deposit amount in USDC
2588
+ lookback_days: Days of historical data to analyze
2589
+ confidence: VaR confidence level (default 97.5%)
2590
+ fee_eps: Fee buffer as fraction of notional
2591
+ oi_floor: Minimum open interest threshold in USD
2592
+ day_vlm_floor: Minimum daily volume threshold in USD
2593
+ horizons_days: Time horizons for risk calculation
2594
+ max_leverage: Maximum leverage allowed
2595
+
2596
+ Returns:
2597
+ List of basis trade opportunities sorted by expected APY
2598
+ """
2599
+ if horizons_days is None:
2600
+ horizons_days = [1, 7]
2601
+
2602
+ # Validate lookback doesn't exceed HL's 5000 candle limit
2603
+ max_hours = 5000
2604
+ max_days = max_hours // 24
2605
+ if lookback_days > max_days:
2606
+ self.logger.warning(
2607
+ f"Lookback {lookback_days}d exceeds limit. Capping at {max_days}d"
2608
+ )
2609
+ lookback_days = max_days
2610
+
2611
+ try:
2612
+ # Get perpetual market data
2613
+ (
2614
+ success,
2615
+ perps_ctx_pack,
2616
+ ) = await self.hyperliquid_adapter.get_meta_and_asset_ctxs()
2617
+ if not success:
2618
+ raise ValueError(f"Failed to fetch perp metadata: {perps_ctx_pack}")
2619
+
2620
+ perps_meta_list = perps_ctx_pack[0]["universe"]
2621
+ perps_ctxs = perps_ctx_pack[1]
2622
+
2623
+ coin_to_ctx: dict[str, Any] = {}
2624
+ coin_to_maxlev: dict[str, int] = {}
2625
+ coin_to_margin_table: dict[str, int | None] = {}
2626
+ coins: list[str] = []
2627
+
2628
+ for meta, ctx in zip(perps_meta_list, perps_ctxs, strict=False):
2629
+ coin = meta["name"]
2630
+ coin_to_ctx[coin] = ctx
2631
+ coin_to_maxlev[coin] = int(meta.get("maxLeverage", 10))
2632
+ coin_to_margin_table[coin] = meta.get("marginTableId")
2633
+ coins.append(coin)
2634
+
2635
+ perps_set = set(coins)
2636
+
2637
+ # Get spot market data
2638
+ success, spot_meta = await self.hyperliquid_adapter.get_spot_meta()
2639
+ if not success:
2640
+ raise ValueError(f"Failed to fetch spot metadata: {spot_meta}")
2641
+
2642
+ tokens = spot_meta.get("tokens", [])
2643
+ spot_pairs = spot_meta.get("universe", [])
2644
+ idx_to_token = {t["index"]: t["name"] for t in tokens}
2645
+
2646
+ # Find candidate basis pairs
2647
+ candidates = self._find_basis_candidates(
2648
+ spot_pairs, idx_to_token, perps_set
2649
+ )
2650
+ self.logger.info(f"Found {len(candidates)} spot-perp candidate pairs")
2651
+
2652
+ # Get perp asset ID mapping
2653
+ perp_coin_to_asset_id = {
2654
+ k: v
2655
+ for k, v in self.hyperliquid_adapter.coin_to_asset.items()
2656
+ if v < 10000
2657
+ }
2658
+
2659
+ # Filter by liquidity
2660
+ liquid = await self._filter_by_liquidity(
2661
+ candidates=candidates,
2662
+ coin_to_ctx=coin_to_ctx,
2663
+ coin_to_maxlev=coin_to_maxlev,
2664
+ coin_to_margin_table=coin_to_margin_table,
2665
+ deposit_usdc=deposit_usdc,
2666
+ max_leverage=max_leverage,
2667
+ oi_floor=oi_floor,
2668
+ day_vlm_floor=day_vlm_floor,
2669
+ perp_coin_to_asset_id=perp_coin_to_asset_id,
2670
+ )
2671
+ self.logger.info(
2672
+ f"After liquidity filter: {len(liquid)} candidates "
2673
+ f"(OI >= ${oi_floor}, volume >= ${day_vlm_floor:,.0f})"
2674
+ )
2675
+
2676
+ # Analyze each candidate
2677
+ results = await self._analyze_candidates(
2678
+ liquid,
2679
+ deposit_usdc,
2680
+ lookback_days,
2681
+ confidence,
2682
+ fee_eps,
2683
+ horizons_days,
2684
+ )
2685
+ self.logger.info(f"After historical analysis: {len(results)} opportunities")
2686
+
2687
+ # Sort by expected APY
2688
+ results.sort(key=self._get_safe_apy_key, reverse=True)
2689
+ return results
2690
+
2691
+ except Exception as e:
2692
+ self.logger.error(f"Error finding basis trades: {e}")
2693
+ raise
2694
+
2695
+ def _find_basis_candidates(
2696
+ self,
2697
+ spot_pairs: list[dict],
2698
+ idx_to_token: dict[int, str],
2699
+ perps_set: set[str],
2700
+ ) -> list[tuple[str, str, int]]:
2701
+ """Find spot-perp pairs that can form basis trades."""
2702
+ candidates: list[tuple[str, str, int]] = []
2703
+
2704
+ for pe in spot_pairs:
2705
+ base_idx = pe["tokens"][0]
2706
+ quote_idx = pe["tokens"][1]
2707
+ base = idx_to_token.get(base_idx)
2708
+ quote = idx_to_token.get(quote_idx)
2709
+
2710
+ if quote != "USDC":
2711
+ continue
2712
+
2713
+ if not base or not quote:
2714
+ continue
2715
+
2716
+ spot_pair_name = f"{base}/{quote}"
2717
+ spot_asset_id = pe["index"] + 10000
2718
+
2719
+ # Handle USDT prefixed tokens (UPUMP -> PUMP)
2720
+ base_norm = (
2721
+ base[1:] if (base.startswith("U") and base[1:] in perps_set) else base
2722
+ )
2723
+ if base_norm in perps_set:
2724
+ candidates.append((spot_pair_name, base_norm, spot_asset_id))
2725
+
2726
+ return candidates
2727
+
2728
+ async def _filter_by_liquidity(
2729
+ self,
2730
+ candidates: list[tuple[str, str, int]],
2731
+ coin_to_ctx: dict[str, Any],
2732
+ coin_to_maxlev: dict[str, int],
2733
+ coin_to_margin_table: dict[str, int | None],
2734
+ deposit_usdc: float,
2735
+ max_leverage: int,
2736
+ oi_floor: float,
2737
+ day_vlm_floor: float,
2738
+ perp_coin_to_asset_id: dict[str, int],
2739
+ depth_params: dict[str, Any] | None = None,
2740
+ ) -> list[BasisCandidate]:
2741
+ """Filter candidates by liquidity and venue depth, returning structured data."""
2742
+ liquid: list[BasisCandidate] = []
2743
+
2744
+ if deposit_usdc <= 0:
2745
+ return liquid
2746
+
2747
+ for spot_sym, coin, spot_asset_id in candidates:
2748
+ ctx = coin_to_ctx.get(coin, {})
2749
+ oi_base = float(ctx.get("openInterest") or 0.0)
2750
+ mark_px = float(ctx.get("markPx") or 0.0)
2751
+
2752
+ if mark_px <= 0:
2753
+ continue
2754
+
2755
+ perp_asset_id = perp_coin_to_asset_id.get(coin)
2756
+ if perp_asset_id is None:
2757
+ continue
2758
+
2759
+ margin_table_id = coin_to_margin_table.get(coin)
2760
+ oi_usd = oi_base * mark_px
2761
+ day_ntl_usd = float(ctx.get("dayNtlVlm") or 0.0)
2762
+
2763
+ # Apply liquidity filters
2764
+ if oi_usd < oi_floor or day_ntl_usd < day_vlm_floor:
2765
+ continue
2766
+
2767
+ raw_max_lev = coin_to_maxlev.get(coin, max_leverage)
2768
+ coin_max_lev = int(raw_max_lev) if raw_max_lev else max_leverage
2769
+ target_leverage = max(1, min(max_leverage, coin_max_lev))
2770
+ order_usd = deposit_usdc * (target_leverage / (target_leverage + 1))
2771
+
2772
+ if order_usd <= 0:
2773
+ continue
2774
+
2775
+ # Get spot order book
2776
+ try:
2777
+ book_snapshot = await self._l2_book_spot(
2778
+ spot_asset_id,
2779
+ fallback_mid=mark_px,
2780
+ spot_symbol=spot_sym,
2781
+ )
2782
+ except Exception as exc:
2783
+ self.logger.warning(f"Skipping {spot_sym}: L2 fetch error: {exc}")
2784
+ continue
2785
+
2786
+ buy_check = await self.check_spot_depth_ok(
2787
+ spot_asset_id,
2788
+ order_usd,
2789
+ "buy",
2790
+ day_ntl_usd=day_ntl_usd,
2791
+ params=depth_params,
2792
+ book=book_snapshot,
2793
+ )
2794
+ sell_check = await self.check_spot_depth_ok(
2795
+ spot_asset_id,
2796
+ order_usd,
2797
+ "sell",
2798
+ day_ntl_usd=day_ntl_usd,
2799
+ params=depth_params,
2800
+ book=book_snapshot,
2801
+ )
2802
+
2803
+ if not (buy_check.get("pass") and sell_check.get("pass")):
2804
+ continue
2805
+
2806
+ depth_checks = {"buy": buy_check, "sell": sell_check}
2807
+
2808
+ liquid.append(
2809
+ BasisCandidate(
2810
+ coin=coin,
2811
+ spot_pair=spot_sym,
2812
+ spot_asset_id=spot_asset_id,
2813
+ perp_asset_id=perp_asset_id,
2814
+ mark_price=mark_px,
2815
+ target_leverage=target_leverage,
2816
+ ctx=ctx,
2817
+ spot_book=book_snapshot,
2818
+ open_interest_base=oi_base,
2819
+ open_interest_usd=oi_usd,
2820
+ day_notional_usd=day_ntl_usd,
2821
+ order_usd=order_usd,
2822
+ depth_checks=depth_checks,
2823
+ margin_table_id=margin_table_id,
2824
+ )
2825
+ )
2826
+
2827
+ return liquid
2828
+
2829
+ async def _analyze_candidates(
2830
+ self,
2831
+ candidates: list[BasisCandidate],
2832
+ deposit_usdc: float,
2833
+ lookback_days: int,
2834
+ confidence: float,
2835
+ fee_eps: float,
2836
+ horizons_days: list[int],
2837
+ ) -> list[dict[str, Any]]:
2838
+ """Analyze each liquid candidate for basis trading metrics."""
2839
+ ms_now = int(time.time() * 1000)
2840
+ start_ms = ms_now - int(lookback_days * 24 * 3600 * 1000)
2841
+ z = self._z_from_conf(confidence)
2842
+
2843
+ results: list[dict[str, Any]] = []
2844
+
2845
+ required_hours = lookback_days * 24
2846
+ skipped_reasons: dict[str, list[str]] = {
2847
+ "no_funding": [],
2848
+ "no_candles": [],
2849
+ "insufficient_funding": [],
2850
+ "insufficient_candles": [],
2851
+ }
2852
+
2853
+ for candidate in candidates:
2854
+ coin = candidate.coin
2855
+ spot_sym = candidate.spot_pair
2856
+
2857
+ # Fetch funding history with chunking for longer lookbacks
2858
+ success, funding_data = await self._fetch_funding_history_chunked(
2859
+ coin, start_ms, ms_now
2860
+ )
2861
+ if not success or not funding_data:
2862
+ skipped_reasons["no_funding"].append(coin)
2863
+ continue
2864
+
2865
+ hourly_funding = [float(x.get("fundingRate", 0.0)) for x in funding_data]
2866
+
2867
+ # Fetch candle data with chunking for longer lookbacks
2868
+ success, candle_data = await self._fetch_candles_chunked(
2869
+ coin, "1h", start_ms, ms_now
2870
+ )
2871
+ if not success or not candle_data:
2872
+ skipped_reasons["no_candles"].append(coin)
2873
+ continue
2874
+
2875
+ closes = [float(c.get("c", 0)) for c in candle_data if c.get("c")]
2876
+ highs = [float(c.get("h", 0)) for c in candle_data if c.get("h")]
2877
+
2878
+ # Require at least 7 days of data minimum, or 50% of lookback for longer periods
2879
+ min_required = max(7 * 24, required_hours // 2)
2880
+ if len(hourly_funding) < min_required:
2881
+ skipped_reasons["insufficient_funding"].append(
2882
+ f"{coin}({len(hourly_funding)}/{min_required})"
2883
+ )
2884
+ continue
2885
+ if len(closes) < min_required or len(highs) < min_required:
2886
+ skipped_reasons["insufficient_candles"].append(
2887
+ f"{coin}(closes={len(closes)},highs={len(highs)}/{min_required})"
2888
+ )
2889
+ continue
2890
+
2891
+ # Calculate price volatility
2892
+ sigma_hourly = (
2893
+ pstdev(
2894
+ [(closes[i] / closes[i - 1] - 1.0) for i in range(1, len(closes))]
2895
+ )
2896
+ if len(closes) > 1
2897
+ else 0.005
2898
+ )
2899
+
2900
+ # Calculate funding statistics
2901
+ funding_stats = self._calculate_funding_stats(hourly_funding)
2902
+
2903
+ # Calculate safe leverages
2904
+ max_lev = candidate.target_leverage
2905
+ m_maint = self.maintenance_rate_from_max_leverage(max_lev)
2906
+
2907
+ safe = self._calculate_safe_leverages(
2908
+ hourly_funding=hourly_funding,
2909
+ closes=closes,
2910
+ highs=highs,
2911
+ z=z,
2912
+ m_maint=m_maint,
2913
+ fee_eps=fee_eps,
2914
+ max_lev=max_lev,
2915
+ deposit_usdc=deposit_usdc,
2916
+ horizons_days=horizons_days,
2917
+ )
2918
+
2919
+ result = {
2920
+ "coin": coin,
2921
+ "spot_pair": spot_sym,
2922
+ "spot_asset_id": candidate.spot_asset_id,
2923
+ "perp_asset_id": candidate.perp_asset_id,
2924
+ "maxLeverage": max_lev,
2925
+ "maintenance_rate_est": m_maint,
2926
+ "openInterest": candidate.open_interest_base,
2927
+ "day_notional_volume": candidate.day_notional_usd,
2928
+ "funding_stats": funding_stats,
2929
+ "price_stats": {
2930
+ "sigma_hourly": sigma_hourly,
2931
+ "z_for_confidence": z,
2932
+ "confidence": confidence,
2933
+ },
2934
+ "safe": safe,
2935
+ "depth_checks": candidate.depth_checks,
2936
+ }
2937
+
2938
+ results.append(result)
2939
+
2940
+ # Log skip reasons
2941
+ for reason, coins in skipped_reasons.items():
2942
+ if coins:
2943
+ self.logger.debug(
2944
+ f"Skipped ({reason}): {', '.join(coins[:5])}{'...' if len(coins) > 5 else ''}"
2945
+ )
2946
+
2947
+ return results
2948
+
2949
+ def _calculate_funding_stats(self, hourly_funding: list[float]) -> dict[str, Any]:
2950
+ """Calculate comprehensive funding rate statistics."""
2951
+ if not hourly_funding:
2952
+ return {
2953
+ "mean_hourly": 0.0,
2954
+ "neg_hour_fraction": 0.0,
2955
+ "hourly_vol": 0.0,
2956
+ "worst_24h_sum": 0.0,
2957
+ "worst_7d_sum": 0.0,
2958
+ "points": 0,
2959
+ }
2960
+
2961
+ mean_hourly = mean(hourly_funding)
2962
+ neg_hour_frac = sum(1 for r in hourly_funding if r < 0.0) / len(hourly_funding)
2963
+ hourly_vol = pstdev(hourly_funding) if len(hourly_funding) > 1 else 0.0
2964
+ worst_24h = self._rolling_min_sum(hourly_funding, 24)
2965
+ worst_7d = self._rolling_min_sum(hourly_funding, 24 * 7)
2966
+
2967
+ return {
2968
+ "mean_hourly": mean_hourly,
2969
+ "neg_hour_fraction": neg_hour_frac,
2970
+ "hourly_vol": hourly_vol,
2971
+ "worst_24h_sum": worst_24h,
2972
+ "worst_7d_sum": worst_7d,
2973
+ "points": len(hourly_funding),
2974
+ }
2975
+
2976
+ def _calculate_safe_leverages(
2977
+ self,
2978
+ hourly_funding: list[float],
2979
+ closes: list[float],
2980
+ highs: list[float],
2981
+ z: float,
2982
+ m_maint: float,
2983
+ fee_eps: float,
2984
+ max_lev: int,
2985
+ deposit_usdc: float,
2986
+ horizons_days: list[int],
2987
+ ) -> dict[str, Any]:
2988
+ """Calculate safe leverage for each time horizon."""
2989
+ results: dict[str, Any] = {}
2990
+
2991
+ for horizon in horizons_days:
2992
+ window = horizon * 24
2993
+ b_star = self._worst_buffer_requirement(
2994
+ closes, highs, hourly_funding, window, m_maint, fee_eps
2995
+ )
2996
+
2997
+ if b_star >= 1.0:
2998
+ results[f"{horizon}d"] = {
2999
+ "pass": False,
3000
+ "leverage": 0,
3001
+ "reason": "Buffer requirement exceeds 100%",
3002
+ }
3003
+ continue
3004
+
3005
+ safe_lev = min(max_lev, int(1.0 / b_star)) if b_star > 0 else max_lev
3006
+
3007
+ # Calculate expected APY
3008
+ mean_funding = mean(hourly_funding) if hourly_funding else 0
3009
+ expected_apy = mean_funding * 24 * 365 * safe_lev
3010
+
3011
+ # Estimate quantities
3012
+ order_usd = deposit_usdc * (safe_lev / (safe_lev + 1))
3013
+ avg_price = mean(closes) if closes else 1.0
3014
+ qty = order_usd / avg_price if avg_price > 0 else 0
3015
+
3016
+ results[f"{horizon}d"] = {
3017
+ "pass": True,
3018
+ "leverage": safe_lev,
3019
+ "buffer_requirement": b_star,
3020
+ "expected_apy_pct": expected_apy,
3021
+ "spot_qty": qty,
3022
+ "perp_qty": qty,
3023
+ "order_usd": order_usd,
3024
+ }
3025
+
3026
+ return results
3027
+
3028
+ def _worst_buffer_requirement(
3029
+ self,
3030
+ closes: list[float],
3031
+ highs: list[float],
3032
+ hourly_funding: list[float],
3033
+ window: int,
3034
+ mmr: float,
3035
+ fee_eps: float,
3036
+ ) -> float:
3037
+ """
3038
+ Calculate worst-case buffer requirement over rolling windows.
3039
+
3040
+ Uses deterministic historical "stress test" approach.
3041
+ """
3042
+ n = min(len(closes), len(highs), len(hourly_funding))
3043
+ if n < window or n == 0:
3044
+ return 1.0
3045
+
3046
+ worst_req = 0.0
3047
+
3048
+ for start in range(n - window + 1):
3049
+ end = start + window
3050
+ entry_price = closes[start]
3051
+ if entry_price <= 0:
3052
+ continue
3053
+
3054
+ cum_f = 0.0
3055
+ runup = 0.0
3056
+
3057
+ for i in range(start, end):
3058
+ peak = highs[i]
3059
+ step_runup = (peak / entry_price - 1.0) if entry_price > 0 else 0.0
3060
+ runup = max(runup, step_runup)
3061
+
3062
+ r = hourly_funding[i] if i < len(hourly_funding) else 0.0
3063
+ if r < 0.0:
3064
+ cum_f += (-r) * (1.0 + runup)
3065
+
3066
+ req = mmr * (1.0 + runup) + runup + cum_f + fee_eps
3067
+ if req > worst_req:
3068
+ worst_req = req
3069
+
3070
+ return worst_req
3071
+
3072
+ # ------------------------------------------------------------------ #
3073
+ # Chunked Data Fetching #
3074
+ # ------------------------------------------------------------------ #
3075
+
3076
+ HOURS_PER_CHUNK = 500 # Hyperliquid API returns max 500 data points per call
3077
+ CHUNK_DELAY_SECONDS = 0.2 # Delay between API chunks to avoid rate limiting
3078
+
3079
+ def _hour_chunks(
3080
+ self, start_ms: int, end_ms: int, step_hours: int = 500
3081
+ ) -> list[tuple[int, int]]:
3082
+ """
3083
+ Generate time chunks for API calls.
3084
+
3085
+ Each chunk is (start_ms, end_ms) tuple representing a time window
3086
+ of up to `step_hours` hours. This allows fetching >500 data points
3087
+ by making multiple API calls.
3088
+
3089
+ Args:
3090
+ start_ms: Start time in milliseconds
3091
+ end_ms: End time in milliseconds
3092
+ step_hours: Hours per chunk (default 500, Hyperliquid API limit)
3093
+
3094
+ Returns:
3095
+ List of (chunk_start_ms, chunk_end_ms) tuples
3096
+ """
3097
+ chunks = []
3098
+ step_ms = step_hours * 3600 * 1000 # Convert hours to milliseconds
3099
+ t0 = start_ms
3100
+
3101
+ while t0 < end_ms:
3102
+ t1 = min(t0 + step_ms, end_ms)
3103
+ chunks.append((t0, t1))
3104
+ t0 = t1
3105
+
3106
+ return chunks
3107
+
3108
+ async def _fetch_funding_history_chunked(
3109
+ self,
3110
+ coin: str,
3111
+ start_ms: int,
3112
+ end_ms: int | None = None,
3113
+ ) -> tuple[bool, list[dict[str, Any]]]:
3114
+ """
3115
+ Fetch funding history with automatic chunking for long time ranges.
3116
+
3117
+ Hyperliquid API returns max ~500 data points per call. This method
3118
+ automatically splits long requests into multiple chunks and merges
3119
+ the results.
3120
+
3121
+ Args:
3122
+ coin: Coin symbol (e.g., "ETH", "BTC")
3123
+ start_ms: Start time in milliseconds
3124
+ end_ms: End time in milliseconds (defaults to now)
3125
+
3126
+ Returns:
3127
+ (success, combined_funding_data)
3128
+ """
3129
+ if end_ms is None:
3130
+ end_ms = int(time.time() * 1000)
3131
+
3132
+ chunks = self._hour_chunks(start_ms, end_ms, self.HOURS_PER_CHUNK)
3133
+ all_funding: list[dict[str, Any]] = []
3134
+ seen_times: set[int] = set() # Dedupe by timestamp
3135
+
3136
+ for i, (chunk_start, chunk_end) in enumerate(chunks):
3137
+ # Add delay between chunks to avoid rate limiting
3138
+ if i > 0:
3139
+ await asyncio.sleep(self.CHUNK_DELAY_SECONDS)
3140
+
3141
+ success, data = await self.hyperliquid_adapter.get_funding_history(
3142
+ coin, chunk_start, chunk_end
3143
+ )
3144
+ if not success:
3145
+ # Log but continue with partial data
3146
+ self.logger.warning(
3147
+ f"Funding chunk failed for {coin} "
3148
+ f"({chunk_start} - {chunk_end}): {data}"
3149
+ )
3150
+ continue
3151
+
3152
+ # Dedupe and merge
3153
+ for record in data:
3154
+ ts = record.get("time", 0)
3155
+ if ts not in seen_times:
3156
+ seen_times.add(ts)
3157
+ all_funding.append(record)
3158
+
3159
+ # Sort by time
3160
+ all_funding.sort(key=lambda x: x.get("time", 0))
3161
+
3162
+ if not all_funding:
3163
+ return False, []
3164
+
3165
+ self.logger.debug(
3166
+ f"Fetched {len(all_funding)} funding points for {coin} "
3167
+ f"via {len(chunks)} chunk(s)"
3168
+ )
3169
+ return True, all_funding
3170
+
3171
+ async def _fetch_candles_chunked(
3172
+ self,
3173
+ coin: str,
3174
+ interval: str,
3175
+ start_ms: int,
3176
+ end_ms: int | None = None,
3177
+ ) -> tuple[bool, list[dict[str, Any]]]:
3178
+ """
3179
+ Fetch candle data with automatic chunking for long time ranges.
3180
+
3181
+ Args:
3182
+ coin: Coin symbol (e.g., "ETH", "BTC")
3183
+ interval: Candle interval (e.g., "1h")
3184
+ start_ms: Start time in milliseconds
3185
+ end_ms: End time in milliseconds (defaults to now)
3186
+
3187
+ Returns:
3188
+ (success, combined_candle_data)
3189
+ """
3190
+ if end_ms is None:
3191
+ end_ms = int(time.time() * 1000)
3192
+
3193
+ chunks = self._hour_chunks(start_ms, end_ms, self.HOURS_PER_CHUNK)
3194
+ all_candles: list[dict[str, Any]] = []
3195
+ seen_times: set[int] = set() # Dedupe by open timestamp
3196
+
3197
+ for i, (chunk_start, chunk_end) in enumerate(chunks):
3198
+ # Add delay between chunks to avoid rate limiting
3199
+ if i > 0:
3200
+ await asyncio.sleep(self.CHUNK_DELAY_SECONDS)
3201
+
3202
+ success, data = await self.hyperliquid_adapter.get_candles(
3203
+ coin, interval, chunk_start, chunk_end
3204
+ )
3205
+ if not success:
3206
+ self.logger.warning(
3207
+ f"Candle chunk failed for {coin} "
3208
+ f"({chunk_start} - {chunk_end}): {data}"
3209
+ )
3210
+ continue
3211
+
3212
+ # Dedupe and merge
3213
+ for candle in data:
3214
+ ts = candle.get("t", 0)
3215
+ if ts not in seen_times:
3216
+ seen_times.add(ts)
3217
+ all_candles.append(candle)
3218
+
3219
+ # Sort by time
3220
+ all_candles.sort(key=lambda x: x.get("t", 0))
3221
+
3222
+ if not all_candles:
3223
+ return False, []
3224
+
3225
+ self.logger.debug(
3226
+ f"Fetched {len(all_candles)} candles for {coin} via {len(chunks)} chunk(s)"
3227
+ )
3228
+ return True, all_candles
3229
+
3230
+ # ------------------------------------------------------------------ #
3231
+ # Net APY Solver + Bootstrap (ported from Django NetApyBasisTradingService)
3232
+ # ------------------------------------------------------------------ #
3233
+
3234
+ def _spot_index_from_asset_id(self, spot_asset_id: int) -> int:
3235
+ return hl_spot_index_from_asset_id(spot_asset_id)
3236
+
3237
+ def _normalize_l2_book(
3238
+ self,
3239
+ raw: dict[str, Any],
3240
+ *,
3241
+ fallback_mid: float | None = None,
3242
+ ) -> dict[str, Any]:
3243
+ """Normalize Hyperliquid L2 into bids/asks lists with floats."""
3244
+ return hl_normalize_l2_book(raw, fallback_mid=fallback_mid)
3245
+
3246
+ async def _l2_book_spot(
3247
+ self,
3248
+ spot_asset_id: int,
3249
+ *,
3250
+ fallback_mid: float | None = None,
3251
+ spot_symbol: str | None = None,
3252
+ ) -> dict[str, Any]:
3253
+ """Fetch and normalize Level-2 order book snapshot for a spot asset."""
3254
+ last_exc: Exception | None = None
3255
+
3256
+ try:
3257
+ success, raw = await self.hyperliquid_adapter.get_spot_l2_book(
3258
+ spot_asset_id
3259
+ )
3260
+ if success and isinstance(raw, dict):
3261
+ return self._normalize_l2_book(raw, fallback_mid=fallback_mid)
3262
+ except Exception as exc: # noqa: BLE001
3263
+ last_exc = exc
3264
+
3265
+ # Fallback: try spot pair naming conventions
3266
+ # - Index 0: use "PURR/USDC"
3267
+ # - Other indices: use "@{index}"
3268
+ spot_index = self._spot_index_from_asset_id(spot_asset_id)
3269
+ if spot_index == 0:
3270
+ candidates = ["PURR/USDC"]
3271
+ else:
3272
+ candidates = [f"@{spot_index}"]
3273
+
3274
+ # Also try the spot_symbol if provided (e.g., "HYPE/USDC")
3275
+ if spot_symbol:
3276
+ candidates.append(spot_symbol)
3277
+
3278
+ seen: set[str] = set()
3279
+ for coin in candidates:
3280
+ if not coin or coin in seen:
3281
+ continue
3282
+ seen.add(coin)
3283
+ try:
3284
+ # Use get_l2_book which accepts spot pair names like "PURR/USDC" or "@107"
3285
+ success, raw = await self.hyperliquid_adapter.get_l2_book(coin)
3286
+ if success and isinstance(raw, dict):
3287
+ return self._normalize_l2_book(raw, fallback_mid=fallback_mid)
3288
+ except Exception as exc: # noqa: BLE001
3289
+ last_exc = exc
3290
+ continue
3291
+
3292
+ if last_exc is not None:
3293
+ raise last_exc
3294
+ raise ValueError(f"Unable to fetch L2 book for spot asset {spot_asset_id}")
3295
+
3296
+ def _usd_depth_in_band(
3297
+ self, book: dict[str, Any], band_bps: int, side: str
3298
+ ) -> tuple[float, float]:
3299
+ return hl_usd_depth_in_band(book, band_bps, side)
3300
+
3301
+ def _depth_band_for_size(
3302
+ self,
3303
+ order_usd: float,
3304
+ *,
3305
+ base_bps: int = 20,
3306
+ max_bps: int = 100,
3307
+ gamma: int = 20,
3308
+ ) -> int:
3309
+ """Widen the depth band slowly with order size."""
3310
+ if order_usd <= 0:
3311
+ return base_bps
3312
+
3313
+ band = base_bps + int(gamma * max(0.0, math.log10(order_usd / 1e4)))
3314
+ band = max(base_bps, band)
3315
+ return min(band, max_bps)
3316
+
3317
+ async def check_spot_depth_ok(
3318
+ self,
3319
+ spot_asset_id: int,
3320
+ order_usd: float,
3321
+ side: str,
3322
+ *,
3323
+ day_ntl_usd: float | None = None,
3324
+ params: dict[str, Any] | None = None,
3325
+ book: dict[str, Any] | None = None,
3326
+ fallback_mid: float | None = None,
3327
+ spot_symbol: str | None = None,
3328
+ ) -> dict[str, Any]:
3329
+ """
3330
+ Heuristic spot book depth gate using USD notionals.
3331
+
3332
+ Returns diagnostics including available depth, thresholds, and pass/fail flags.
3333
+ """
3334
+
3335
+ config: dict[str, Any] = {
3336
+ "base_band_bps": 50,
3337
+ "max_band_bps": 100,
3338
+ "band_gamma": 20,
3339
+ "max_fill_ratio": 0.10,
3340
+ "depth_multiple": 2.0,
3341
+ "min_depth_floor_usd": 10_000.0,
3342
+ "day_frac_cap": 0.005,
3343
+ }
3344
+ if params:
3345
+ config.update(params)
3346
+
3347
+ try:
3348
+ book_snapshot = (
3349
+ book
3350
+ if book is not None
3351
+ else await self._l2_book_spot(
3352
+ spot_asset_id, fallback_mid=fallback_mid, spot_symbol=spot_symbol
3353
+ )
3354
+ )
3355
+ except Exception as exc: # noqa: BLE001
3356
+ dyn_min_depth = max(
3357
+ float(config["min_depth_floor_usd"]),
3358
+ float(config["depth_multiple"]) * float(order_usd),
3359
+ )
3360
+ return {
3361
+ "pass": False,
3362
+ "side": side,
3363
+ "order_usd": float(order_usd),
3364
+ "mid_px": 0.0,
3365
+ "band_bps": int(config["base_band_bps"]),
3366
+ "depth_side_usd": 0.0,
3367
+ "max_fill_ratio": float(config["max_fill_ratio"]),
3368
+ "depth_multiple": float(config["depth_multiple"]),
3369
+ "min_depth_floor_usd": float(config["min_depth_floor_usd"]),
3370
+ "dyn_min_depth_usd": float(dyn_min_depth),
3371
+ "max_allowed_by_depth": 0.0,
3372
+ "day_ntl_usd": day_ntl_usd,
3373
+ "day_frac_cap": float(config["day_frac_cap"]),
3374
+ "max_allowed_by_turnover": None,
3375
+ "reasons": [
3376
+ f"failed to fetch L2 book for spot_asset_id {spot_asset_id}: {exc}"
3377
+ ],
3378
+ }
3379
+
3380
+ band_bps = self._depth_band_for_size(
3381
+ order_usd,
3382
+ base_bps=int(config["base_band_bps"]),
3383
+ max_bps=int(config["max_band_bps"]),
3384
+ gamma=int(config["band_gamma"]),
3385
+ )
3386
+
3387
+ depth_side_usd, mid = self._usd_depth_in_band(book_snapshot, band_bps, side)
3388
+
3389
+ dyn_min_depth = max(
3390
+ float(config["min_depth_floor_usd"]),
3391
+ float(config["depth_multiple"]) * float(order_usd),
3392
+ )
3393
+
3394
+ max_allowed_by_depth = float(config["max_fill_ratio"]) * float(depth_side_usd)
3395
+ depth_ok = (
3396
+ float(depth_side_usd) >= dyn_min_depth
3397
+ and float(order_usd) <= max_allowed_by_depth
3398
+ and float(depth_side_usd) > 0.0
3399
+ )
3400
+
3401
+ turnover_ok = True
3402
+ max_allowed_by_turnover: float | None = None
3403
+ if day_ntl_usd is not None and day_ntl_usd > 0:
3404
+ max_allowed_by_turnover = float(config["day_frac_cap"]) * float(day_ntl_usd)
3405
+ turnover_ok = float(order_usd) <= max_allowed_by_turnover
3406
+
3407
+ reasons: list[str] = []
3408
+ if float(depth_side_usd) < dyn_min_depth:
3409
+ reasons.append(
3410
+ f"insufficient book depth in band (need ≥ {dyn_min_depth:,.2f})"
3411
+ )
3412
+ if float(order_usd) > max_allowed_by_depth:
3413
+ reasons.append("order size exceeds depth-based cap")
3414
+ if not turnover_ok:
3415
+ reasons.append("exceeds daily turnover cap")
3416
+
3417
+ return {
3418
+ "pass": bool(depth_ok and turnover_ok),
3419
+ "side": side,
3420
+ "order_usd": float(order_usd),
3421
+ "mid_px": float(mid),
3422
+ "band_bps": int(band_bps),
3423
+ "depth_side_usd": float(depth_side_usd),
3424
+ "depth_multiple": float(config["depth_multiple"]),
3425
+ "min_depth_floor_usd": float(config["min_depth_floor_usd"]),
3426
+ "dyn_min_depth_usd": float(dyn_min_depth),
3427
+ "max_fill_ratio": float(config["max_fill_ratio"]),
3428
+ "max_allowed_by_depth": float(max_allowed_by_depth),
3429
+ "day_ntl_usd": day_ntl_usd,
3430
+ "day_frac_cap": float(config["day_frac_cap"]),
3431
+ "max_allowed_by_turnover": max_allowed_by_turnover,
3432
+ "reasons": reasons,
3433
+ }
3434
+
3435
+ def _estimate_spot_slippage_usd(
3436
+ self,
3437
+ book: dict[str, Any],
3438
+ order_usd: float,
3439
+ side: str,
3440
+ band_bps: int,
3441
+ ) -> float:
3442
+ depth_usd, _mid = self._usd_depth_in_band(book, band_bps, side)
3443
+ if order_usd <= 0 or depth_usd <= 0:
3444
+ return 0.0
3445
+ fill_fraction = min(1.0, order_usd / depth_usd)
3446
+ return fill_fraction * (band_bps * 0.5 / 1e4) * order_usd
3447
+
3448
+ async def _estimate_cycle_costs(
3449
+ self,
3450
+ *,
3451
+ N_leg_usd: float,
3452
+ spot_asset_id: int,
3453
+ spot_book: dict[str, Any],
3454
+ fee_model: dict[str, float] | None = None,
3455
+ depth_params: dict[str, Any] | None = None,
3456
+ perp_slippage_bps: float = 1.0,
3457
+ day_ntl_usd: float | None = None,
3458
+ spot_symbol: str | None = None,
3459
+ ) -> tuple[float, float, dict[str, float], dict[str, dict[str, Any]]]:
3460
+ """Estimate entry/exit execution costs for a full cycle on both legs."""
3461
+
3462
+ cfg_fees = {"spot_bps": 9.0, "perp_bps": 6.0}
3463
+ if fee_model:
3464
+ cfg_fees.update(fee_model)
3465
+
3466
+ buy_chk = await self.check_spot_depth_ok(
3467
+ spot_asset_id,
3468
+ N_leg_usd,
3469
+ "buy",
3470
+ day_ntl_usd=day_ntl_usd,
3471
+ params=depth_params,
3472
+ book=spot_book,
3473
+ spot_symbol=spot_symbol,
3474
+ )
3475
+ sell_chk = await self.check_spot_depth_ok(
3476
+ spot_asset_id,
3477
+ N_leg_usd,
3478
+ "sell",
3479
+ day_ntl_usd=day_ntl_usd,
3480
+ params=depth_params,
3481
+ book=spot_book,
3482
+ spot_symbol=spot_symbol,
3483
+ )
3484
+
3485
+ band_buy = int(buy_chk.get("band_bps", 50))
3486
+ band_sell = int(sell_chk.get("band_bps", 50))
3487
+
3488
+ spot_slip_entry = 0.5 * (
3489
+ self._estimate_spot_slippage_usd(spot_book, N_leg_usd, "buy", band_buy)
3490
+ + self._estimate_spot_slippage_usd(spot_book, N_leg_usd, "sell", band_sell)
3491
+ )
3492
+ spot_slip_exit = spot_slip_entry
3493
+
3494
+ spot_fee_entry = (cfg_fees["spot_bps"] / 1e4) * N_leg_usd
3495
+ spot_fee_exit = (cfg_fees["spot_bps"] / 1e4) * N_leg_usd
3496
+ perp_fee_entry = (cfg_fees["perp_bps"] / 1e4) * N_leg_usd
3497
+ perp_fee_exit = (cfg_fees["perp_bps"] / 1e4) * N_leg_usd
3498
+
3499
+ perp_slip_entry = (perp_slippage_bps / 1e4) * N_leg_usd
3500
+ perp_slip_exit = (perp_slippage_bps / 1e4) * N_leg_usd
3501
+
3502
+ entry_cost = spot_slip_entry + spot_fee_entry + perp_slip_entry + perp_fee_entry
3503
+ exit_cost = spot_slip_exit + spot_fee_exit + perp_slip_exit + perp_fee_exit
3504
+
3505
+ breakdown = {
3506
+ "spot_slip_entry": spot_slip_entry,
3507
+ "spot_slip_exit": spot_slip_exit,
3508
+ "spot_fee_entry": spot_fee_entry,
3509
+ "spot_fee_exit": spot_fee_exit,
3510
+ "perp_slip_entry": perp_slip_entry,
3511
+ "perp_slip_exit": perp_slip_exit,
3512
+ "perp_fee_entry": perp_fee_entry,
3513
+ "perp_fee_exit": perp_fee_exit,
3514
+ "band_bps_buy": float(band_buy),
3515
+ "band_bps_sell": float(band_sell),
3516
+ "depth_usd_buy": float(buy_chk.get("depth_side_usd", 0.0)),
3517
+ "depth_usd_sell": float(sell_chk.get("depth_side_usd", 0.0)),
3518
+ }
3519
+ return entry_cost, exit_cost, breakdown, {"buy": buy_chk, "sell": sell_chk}
3520
+
3521
+ async def _get_margin_table_tiers(self, table_id: int) -> list[dict[str, float]]:
3522
+ """Fetch and cache margin table tiers with maintenance rates and deductions."""
3523
+ if table_id in self._margin_table_cache:
3524
+ return [dict(t) for t in self._margin_table_cache[table_id]]
3525
+
3526
+ if not hasattr(self.hyperliquid_adapter, "get_margin_table"):
3527
+ self._margin_table_cache[table_id] = []
3528
+ return []
3529
+
3530
+ try:
3531
+ success, response = await self.hyperliquid_adapter.get_margin_table(
3532
+ int(table_id)
3533
+ )
3534
+ except Exception as exc: # noqa: BLE001
3535
+ self.logger.warning(f"Failed to fetch margin table {table_id}: {exc}")
3536
+ self._margin_table_cache[table_id] = []
3537
+ return []
3538
+
3539
+ if not success or not isinstance(response, dict):
3540
+ self._margin_table_cache[table_id] = []
3541
+ return []
3542
+
3543
+ tiers_raw = response.get("marginTiers") or []
3544
+ tiers_sorted = sorted(
3545
+ (
3546
+ {
3547
+ "lowerBound": float(tier.get("lowerBound", 0.0) or 0.0),
3548
+ "maxLeverage": float(tier.get("maxLeverage", 0.0) or 0.0),
3549
+ }
3550
+ for tier in tiers_raw
3551
+ if isinstance(tier, dict)
3552
+ ),
3553
+ key=lambda t: t["lowerBound"],
3554
+ )
3555
+
3556
+ processed: list[dict[str, float]] = []
3557
+ deduction = 0.0
3558
+ prev_rate: float | None = None
3559
+
3560
+ for tier in tiers_sorted:
3561
+ lower = max(0.0, tier["lowerBound"])
3562
+ max_lev = tier["maxLeverage"]
3563
+ if max_lev <= 0.0:
3564
+ continue
3565
+
3566
+ maint_rate = 1.0 / (2.0 * max_lev)
3567
+ if prev_rate is not None:
3568
+ deduction += lower * (maint_rate - prev_rate)
3569
+ processed.append(
3570
+ {
3571
+ "lower_bound": float(lower),
3572
+ "maint_rate": float(maint_rate),
3573
+ "deduction": float(deduction),
3574
+ }
3575
+ )
3576
+ prev_rate = maint_rate
3577
+
3578
+ self._margin_table_cache[table_id] = [dict(t) for t in processed]
3579
+ return [dict(t) for t in processed]
3580
+
3581
+ def maintenance_fraction_for_notional(
3582
+ self,
3583
+ margin_table_id: int | None,
3584
+ notional_usd: float,
3585
+ fallback_max_leverage: int,
3586
+ ) -> float:
3587
+ """Return maintenance margin fraction for a given notional, honoring tiered tables."""
3588
+ fallback_mmr = self.maintenance_rate_from_max_leverage(
3589
+ max(1, int(fallback_max_leverage))
3590
+ )
3591
+ notional = float(notional_usd)
3592
+ if notional <= 0 or not margin_table_id:
3593
+ return fallback_mmr
3594
+
3595
+ tiers = self._margin_table_cache.get(int(margin_table_id)) or []
3596
+ if not tiers:
3597
+ return fallback_mmr
3598
+
3599
+ chosen = tiers[0]
3600
+ for tier in tiers:
3601
+ if notional >= float(tier["lower_bound"]):
3602
+ chosen = tier
3603
+ else:
3604
+ break
3605
+
3606
+ maint_rate = float(chosen["maint_rate"])
3607
+ deduction = float(chosen["deduction"])
3608
+ maintenance_margin = maint_rate * notional - deduction
3609
+ if maintenance_margin <= 0:
3610
+ return max(maint_rate, fallback_mmr)
3611
+
3612
+ fraction = maintenance_margin / notional
3613
+ return max(min(float(fraction), 1.0), 0.0)
3614
+
3615
+ def _first_stop_horizon(
3616
+ self,
3617
+ *,
3618
+ start_idx: int,
3619
+ closes: list[float],
3620
+ highs: list[float],
3621
+ hourly_funding: list[float],
3622
+ leverage: int,
3623
+ stop_frac: float,
3624
+ fee_eps: float,
3625
+ maintenance_fn,
3626
+ base_notional: float,
3627
+ ) -> int:
3628
+ """Return the forward hours until the stop barrier is hit or data is exhausted."""
3629
+ n = min(len(closes), len(highs), len(hourly_funding)) - 1
3630
+ if start_idx >= n:
3631
+ return 0
3632
+
3633
+ entry = closes[start_idx]
3634
+ if entry <= 0:
3635
+ return 1
3636
+
3637
+ peak = entry
3638
+ cum_neg_f = 0.0
3639
+ max_j = n - start_idx
3640
+
3641
+ if not (0.0 < stop_frac <= 1.0):
3642
+ raise ValueError(f"stop_frac must be in (0, 1], got {stop_frac}")
3643
+
3644
+ L = max(1, int(leverage))
3645
+ threshold = stop_frac * (1.0 / float(L))
3646
+
3647
+ for j in range(1, max_j + 1):
3648
+ idx = start_idx + j
3649
+ h = highs[idx]
3650
+ if h > peak:
3651
+ peak = h
3652
+
3653
+ runup = (peak / entry) - 1.0
3654
+ r = hourly_funding[idx]
3655
+ if r < 0.0:
3656
+ cum_neg_f += (-r) * (1.0 + runup)
3657
+
3658
+ notional = base_notional * (1.0 + runup)
3659
+ maintenance_fraction = float(maintenance_fn(notional))
3660
+ req = maintenance_fraction * (1.0 + runup) + runup + cum_neg_f + fee_eps
3661
+ if req >= threshold:
3662
+ return j
3663
+
3664
+ return max_j
3665
+
3666
+ def _simulate_barrier_backtest(
3667
+ self,
3668
+ *,
3669
+ funding: list[float],
3670
+ closes: list[float],
3671
+ highs: list[float],
3672
+ leverage: int,
3673
+ stop_frac: float,
3674
+ fee_eps: float,
3675
+ N_leg_usd: float,
3676
+ entry_cost_usd: float,
3677
+ exit_cost_usd: float,
3678
+ margin_table_id: int | None,
3679
+ fallback_max_leverage: int,
3680
+ cooloff_hours: int = 0,
3681
+ ) -> dict[str, float]:
3682
+ """Simulate repeated entries/exits under a stop barrier and accumulate PnL."""
3683
+ n = min(len(funding), len(closes), len(highs)) - 1
3684
+ if n <= 0:
3685
+ return {
3686
+ "net_pnl_usd": 0.0,
3687
+ "gross_funding_usd": 0.0,
3688
+ "cycles": 0,
3689
+ "hours": 0,
3690
+ "hours_in_market": 0,
3691
+ }
3692
+
3693
+ pnl = 0.0
3694
+ gross_funding = 0.0
3695
+ cycles = 0
3696
+ t = 0
3697
+ hours_in_market = 0
3698
+
3699
+ def maintenance_fn(notional: float) -> float:
3700
+ return self.maintenance_fraction_for_notional(
3701
+ margin_table_id,
3702
+ notional,
3703
+ fallback_max_leverage,
3704
+ )
3705
+
3706
+ while t < n:
3707
+ pnl -= entry_cost_usd
3708
+ cycles += 1
3709
+
3710
+ j = self._first_stop_horizon(
3711
+ start_idx=t,
3712
+ closes=closes,
3713
+ highs=highs,
3714
+ hourly_funding=funding,
3715
+ leverage=leverage,
3716
+ stop_frac=stop_frac,
3717
+ fee_eps=fee_eps,
3718
+ maintenance_fn=maintenance_fn,
3719
+ base_notional=N_leg_usd,
3720
+ )
3721
+ j = max(1, min(j, n - t))
3722
+
3723
+ entry_px = closes[t] if 0 <= t < len(closes) else 0.0
3724
+ funding_sum = 0.0
3725
+ for k in range(1, j + 1):
3726
+ idx = t + k
3727
+ funding_rate = funding[idx] if idx < len(funding) else 0.0
3728
+ if entry_px > 0:
3729
+ px = closes[idx] if idx < len(closes) else entry_px
3730
+ px_ratio = px / entry_px
3731
+ else:
3732
+ px_ratio = 1.0
3733
+ funding_sum += funding_rate * px_ratio
3734
+
3735
+ funding_usd = N_leg_usd * funding_sum
3736
+ pnl += funding_usd
3737
+ gross_funding += funding_usd
3738
+ hours_in_market += j
3739
+
3740
+ t += j
3741
+ if t >= n:
3742
+ break
3743
+
3744
+ pnl -= exit_cost_usd
3745
+ if cooloff_hours > 0:
3746
+ t += cooloff_hours
3747
+
3748
+ return {
3749
+ "net_pnl_usd": float(pnl),
3750
+ "gross_funding_usd": float(gross_funding),
3751
+ "cycles": float(cycles),
3752
+ "hours": float(n),
3753
+ "hours_in_market": float(hours_in_market),
3754
+ }
3755
+
3756
+ @staticmethod
3757
+ def _percentile(sorted_values: list[float], pct: float) -> float:
3758
+ """Inclusive percentile on a pre-sorted list."""
3759
+ return analytics_percentile(sorted_values, pct)
3760
+
3761
+ def _block_bootstrap_paths(
3762
+ self,
3763
+ *,
3764
+ funding: list[float],
3765
+ closes: list[float],
3766
+ highs: list[float],
3767
+ block_hours: int,
3768
+ sims: int,
3769
+ rng: random.Random,
3770
+ ) -> list[tuple[list[float], list[float], list[float]]]:
3771
+ """Return block-bootstrap resampled series for funding/close/high paths."""
3772
+ paths = analytics_block_bootstrap_paths(
3773
+ funding,
3774
+ closes,
3775
+ highs,
3776
+ block_hours=block_hours,
3777
+ sims=sims,
3778
+ rng=rng,
3779
+ )
3780
+ return [(f, c, h) for (f, c, h) in paths]
3781
+
3782
+ def _bootstrap_churn_metrics(
3783
+ self,
3784
+ *,
3785
+ funding: list[float],
3786
+ closes: list[float],
3787
+ highs: list[float],
3788
+ leverage: int,
3789
+ stop_frac: float,
3790
+ fee_eps: float,
3791
+ N_leg_usd: float,
3792
+ entry_cost_usd: float,
3793
+ exit_cost_usd: float,
3794
+ margin_table_id: int | None,
3795
+ fallback_max_leverage: int,
3796
+ cooloff_hours: int,
3797
+ deposit_usdc: float,
3798
+ sims: int,
3799
+ block_hours: int,
3800
+ seed: int | None,
3801
+ ) -> dict[str, Any] | None:
3802
+ """Run block-bootstrap replays and summarize churn metrics."""
3803
+ if sims <= 0 or deposit_usdc <= 0:
3804
+ return None
3805
+
3806
+ base_len = min(len(funding), len(closes), len(highs))
3807
+ if base_len <= 1:
3808
+ return None
3809
+
3810
+ rng_seed = seed if seed is not None else random.randrange(1 << 30)
3811
+ rng = random.Random(rng_seed)
3812
+
3813
+ paths = self._block_bootstrap_paths(
3814
+ funding=funding,
3815
+ closes=closes,
3816
+ highs=highs,
3817
+ block_hours=block_hours,
3818
+ sims=sims,
3819
+ rng=rng,
3820
+ )
3821
+ if not paths:
3822
+ return None
3823
+
3824
+ net_apy_samples: list[float] = []
3825
+ gross_apy_samples: list[float] = []
3826
+ time_in_market_samples: list[float] = []
3827
+ hit_rate_samples: list[float] = []
3828
+ avg_hold_samples: list[float] = []
3829
+ cycles_samples: list[float] = []
3830
+
3831
+ for f_boot, c_boot, h_boot in paths:
3832
+ sim_res = self._simulate_barrier_backtest(
3833
+ funding=f_boot,
3834
+ closes=c_boot,
3835
+ highs=h_boot,
3836
+ leverage=leverage,
3837
+ stop_frac=stop_frac,
3838
+ fee_eps=fee_eps,
3839
+ N_leg_usd=N_leg_usd,
3840
+ entry_cost_usd=entry_cost_usd,
3841
+ exit_cost_usd=exit_cost_usd,
3842
+ margin_table_id=margin_table_id,
3843
+ fallback_max_leverage=fallback_max_leverage,
3844
+ cooloff_hours=cooloff_hours,
3845
+ )
3846
+
3847
+ hours = max(1.0, float(sim_res["hours"]))
3848
+ years = hours / (24.0 * 365.0)
3849
+ net_apy = (float(sim_res["net_pnl_usd"]) / max(1e-9, deposit_usdc)) / years
3850
+ gross_apy = (
3851
+ float(sim_res["gross_funding_usd"]) / max(1e-9, deposit_usdc)
3852
+ ) / years
3853
+ hit_rate_per_day = (
3854
+ float(sim_res["cycles"]) / (hours / 24.0) if hours > 0 else 0.0
3855
+ )
3856
+ avg_hold_hours = (
3857
+ float(sim_res["hours_in_market"]) / max(1.0, float(sim_res["cycles"]))
3858
+ if float(sim_res["cycles"]) > 0
3859
+ else hours
3860
+ )
3861
+ time_in_market = float(sim_res["hours_in_market"]) / hours
3862
+
3863
+ net_apy_samples.append(net_apy)
3864
+ gross_apy_samples.append(gross_apy)
3865
+ time_in_market_samples.append(time_in_market)
3866
+ hit_rate_samples.append(hit_rate_per_day)
3867
+ avg_hold_samples.append(avg_hold_hours)
3868
+ cycles_samples.append(float(sim_res["cycles"]))
3869
+
3870
+ if not net_apy_samples:
3871
+ return None
3872
+
3873
+ def summarize(values: list[float]) -> dict[str, float]:
3874
+ ordered = sorted(values)
3875
+ return {
3876
+ "mean": float(fmean(ordered)),
3877
+ "p05": self._percentile(ordered, 0.05),
3878
+ "p25": self._percentile(ordered, 0.25),
3879
+ "p50": self._percentile(ordered, 0.50),
3880
+ "p75": self._percentile(ordered, 0.75),
3881
+ "p95": self._percentile(ordered, 0.95),
3882
+ }
3883
+
3884
+ return {
3885
+ "samples": len(net_apy_samples),
3886
+ "block_hours": int(block_hours),
3887
+ "seed": int(rng_seed),
3888
+ "net_apy": summarize(net_apy_samples),
3889
+ "gross_funding_apy": summarize(gross_apy_samples),
3890
+ "time_in_market_frac": summarize(time_in_market_samples),
3891
+ "hit_rate_per_day": summarize(hit_rate_samples),
3892
+ "avg_hold_hours": summarize(avg_hold_samples),
3893
+ "cycles": summarize(cycles_samples),
3894
+ }
3895
+
3896
+ def _buffer_requirement_tiered(
3897
+ self,
3898
+ *,
3899
+ closes: list[float],
3900
+ highs: list[float],
3901
+ hourly_funding: list[float],
3902
+ window: int,
3903
+ margin_table_id: int | None,
3904
+ base_notional: float,
3905
+ fallback_max_leverage: int,
3906
+ fee_eps: float,
3907
+ require_full_window: bool = True,
3908
+ ) -> float:
3909
+ """Worst-case buffer requirement accounting for tiered maintenance margin."""
3910
+
3911
+ fallback_mmr = self.maintenance_rate_from_max_leverage(
3912
+ max(1, int(fallback_max_leverage))
3913
+ )
3914
+ if base_notional <= 0:
3915
+ return float(fallback_mmr + fee_eps)
3916
+
3917
+ n = min(len(closes), len(highs), len(hourly_funding))
3918
+ if n == 0 or window <= 0:
3919
+ return float(fallback_mmr + fee_eps)
3920
+
3921
+ i_max = (n - 1 - window) if require_full_window else (n - 2)
3922
+ if i_max < 0:
3923
+ return float(fallback_mmr + fee_eps)
3924
+
3925
+ worst_req = 0.0
3926
+
3927
+ for i in range(0, i_max + 1):
3928
+ entry = closes[i]
3929
+ if entry <= 0:
3930
+ continue
3931
+
3932
+ peak = entry
3933
+ cum_f = 0.0
3934
+
3935
+ for j in range(1, window + 1):
3936
+ idx = i + j
3937
+ h = highs[idx]
3938
+ if h > peak:
3939
+ peak = h
3940
+
3941
+ runup = (peak / entry) - 1.0
3942
+ r = hourly_funding[idx]
3943
+ if r < 0.0:
3944
+ cum_f += (-r) * (1.0 + runup)
3945
+
3946
+ notional = base_notional * (1.0 + runup)
3947
+ maintenance_fraction = self.maintenance_fraction_for_notional(
3948
+ margin_table_id,
3949
+ notional,
3950
+ fallback_max_leverage,
3951
+ )
3952
+ req = maintenance_fraction * (1.0 + runup) + runup + cum_f + fee_eps
3953
+ if req > worst_req:
3954
+ worst_req = req
3955
+
3956
+ return worst_req if worst_req > 0 else float(fallback_mmr + fee_eps)
3957
+
3958
+ def get_sz_decimals_for_hypecore_asset(self, asset_id: int) -> int:
3959
+ try:
3960
+ mapping = self.hyperliquid_adapter.asset_to_sz_decimals
3961
+ except Exception as exc: # noqa: BLE001
3962
+ raise ValueError("Hyperliquid asset_to_sz_decimals not available") from exc
3963
+
3964
+ if not isinstance(mapping, dict):
3965
+ raise ValueError(f"Unknown asset_id {asset_id}: missing szDecimals")
3966
+ return hl_sz_decimals_for_asset(mapping, asset_id)
3967
+
3968
+ def _size_step(self, asset_id: int) -> Decimal:
3969
+ try:
3970
+ mapping = self.hyperliquid_adapter.asset_to_sz_decimals
3971
+ except Exception as exc: # noqa: BLE001
3972
+ raise ValueError("Hyperliquid asset_to_sz_decimals not available") from exc
3973
+
3974
+ if not isinstance(mapping, dict):
3975
+ raise ValueError(f"Unknown asset_id {asset_id}: missing szDecimals")
3976
+ return hl_size_step(mapping, asset_id)
3977
+
3978
+ def round_size_for_hypecore_asset(
3979
+ self, asset_id: int, size: float | Decimal, *, ensure_min_step: bool = False
3980
+ ) -> float:
3981
+ """Floor to step using Decimal to avoid float issues."""
3982
+ try:
3983
+ mapping = self.hyperliquid_adapter.asset_to_sz_decimals
3984
+ except Exception as exc: # noqa: BLE001
3985
+ raise ValueError("Hyperliquid asset_to_sz_decimals not available") from exc
3986
+
3987
+ if not isinstance(mapping, dict):
3988
+ raise ValueError(f"Unknown asset_id {asset_id}: missing szDecimals")
3989
+ return hl_round_size_for_asset(
3990
+ mapping, asset_id, size, ensure_min_step=ensure_min_step
3991
+ )
3992
+
3993
+ def _common_unit_step(
3994
+ self, spot_asset_id: int, perp_asset_id: int | None
3995
+ ) -> Decimal:
3996
+ step_spot = self._size_step(spot_asset_id)
3997
+ step_perp = (
3998
+ self._size_step(perp_asset_id) if perp_asset_id is not None else step_spot
3999
+ )
4000
+ return max(step_spot, step_perp)
4001
+
4002
+ def _min_deposit_needed(
4003
+ self,
4004
+ *,
4005
+ mark_price: float | Decimal,
4006
+ leverage: int,
4007
+ spot_asset_id: int,
4008
+ perp_asset_id: int | None,
4009
+ ) -> float:
4010
+ """
4011
+ Minimum USDC deposit to place at least one lot on both legs at leverage L.
4012
+
4013
+ D_min(L) = N * (1 + 1/L), with N = unit_step * mark_px.
4014
+ """
4015
+ L = max(1, int(leverage))
4016
+ unit_step = self._common_unit_step(spot_asset_id, perp_asset_id)
4017
+ mark = _d(mark_price)
4018
+ N = unit_step * mark
4019
+ Dmin = N * (_d(1) + (_d(1) / _d(L)))
4020
+ return float(Dmin)
4021
+
4022
+ def _depth_upper_bound_usd(
4023
+ self,
4024
+ *,
4025
+ book: dict[str, Any],
4026
+ side: str,
4027
+ day_ntl_usd: float | None,
4028
+ params: dict[str, Any] | None,
4029
+ ) -> float:
4030
+ """
4031
+ Conservative upper bound for order size that could ever pass depth checks.
4032
+
4033
+ Uses depth at max band and turnover cap (if provided).
4034
+ """
4035
+ config: dict[str, Any] = {
4036
+ "max_band_bps": 100,
4037
+ "max_fill_ratio": 0.10,
4038
+ "depth_multiple": 2.0,
4039
+ "min_depth_floor_usd": 10_000.0,
4040
+ "day_frac_cap": 0.005,
4041
+ }
4042
+ if params:
4043
+ config.update(params)
4044
+
4045
+ max_band = int(config["max_band_bps"])
4046
+ depth_side_usd, _mid = self._usd_depth_in_band(book, max_band, side)
4047
+
4048
+ if depth_side_usd <= 0.0 or depth_side_usd < float(
4049
+ config["min_depth_floor_usd"]
4050
+ ):
4051
+ return 0.0
4052
+
4053
+ cap_depth = min(
4054
+ float(config["max_fill_ratio"]) * float(depth_side_usd),
4055
+ float(depth_side_usd) / max(1e-9, float(config["depth_multiple"])),
4056
+ )
4057
+ cap_turnover = (
4058
+ float("inf")
4059
+ if day_ntl_usd is None or float(day_ntl_usd) <= 0.0
4060
+ else float(config["day_frac_cap"]) * float(day_ntl_usd)
4061
+ )
4062
+ return float(max(0.0, min(cap_depth, cap_turnover)))
4063
+
4064
+ @staticmethod
4065
+ def _order_scan_points(upper: float, *, growth: float = 1.8) -> list[float]:
4066
+ if upper <= 0:
4067
+ return []
4068
+ if upper <= 1.0:
4069
+ return [float(upper)]
4070
+ pts: list[float] = []
4071
+ v = 1.0
4072
+ while v < upper:
4073
+ pts.append(float(v))
4074
+ v *= float(growth)
4075
+ if len(pts) > 256:
4076
+ break
4077
+ pts.append(float(upper))
4078
+ # Dedupe + sort
4079
+ return sorted({float(p) for p in pts if p > 0.0})
4080
+
4081
+ async def max_spot_order_usd_for_book(
4082
+ self,
4083
+ *,
4084
+ spot_asset_id: int,
4085
+ spot_symbol: str | None,
4086
+ book: dict[str, Any],
4087
+ day_ntl_usd: float,
4088
+ params: dict[str, Any] | None = None,
4089
+ refine_iters: int = 12,
4090
+ ) -> dict[str, Any]:
4091
+ """
4092
+ Compute the maximum order_usd that passes spot depth checks on both sides.
4093
+
4094
+ This is used for batch precompute so workers can quickly filter candidates
4095
+ by a user's required order size.
4096
+ """
4097
+ upper_buy = self._depth_upper_bound_usd(
4098
+ book=book, side="buy", day_ntl_usd=day_ntl_usd, params=params
4099
+ )
4100
+ upper_sell = self._depth_upper_bound_usd(
4101
+ book=book, side="sell", day_ntl_usd=day_ntl_usd, params=params
4102
+ )
4103
+ upper = min(upper_buy, upper_sell)
4104
+ if upper <= 0.0:
4105
+ return {
4106
+ "max_order_usd": 0.0,
4107
+ "upper_bound_usd": float(upper),
4108
+ "checks": {"buy": None, "sell": None},
4109
+ }
4110
+
4111
+ scan_orders = self._order_scan_points(upper)
4112
+ best = 0.0
4113
+ best_checks: dict[str, Any] | None = None
4114
+
4115
+ for order_usd in scan_orders:
4116
+ buy = await self.check_spot_depth_ok(
4117
+ spot_asset_id,
4118
+ float(order_usd),
4119
+ "buy",
4120
+ day_ntl_usd=day_ntl_usd,
4121
+ params=params,
4122
+ book=book,
4123
+ spot_symbol=spot_symbol,
4124
+ )
4125
+ sell = await self.check_spot_depth_ok(
4126
+ spot_asset_id,
4127
+ float(order_usd),
4128
+ "sell",
4129
+ day_ntl_usd=day_ntl_usd,
4130
+ params=params,
4131
+ book=book,
4132
+ spot_symbol=spot_symbol,
4133
+ )
4134
+ if bool(buy.get("pass")) and bool(sell.get("pass")):
4135
+ best = float(order_usd)
4136
+ best_checks = {"buy": buy, "sell": sell}
4137
+
4138
+ if best <= 0.0:
4139
+ # No scan point passed. Provide a diagnostic at the smallest order tested.
4140
+ first = float(scan_orders[0])
4141
+ buy = await self.check_spot_depth_ok(
4142
+ spot_asset_id,
4143
+ first,
4144
+ "buy",
4145
+ day_ntl_usd=day_ntl_usd,
4146
+ params=params,
4147
+ book=book,
4148
+ spot_symbol=spot_symbol,
4149
+ )
4150
+ sell = await self.check_spot_depth_ok(
4151
+ spot_asset_id,
4152
+ first,
4153
+ "sell",
4154
+ day_ntl_usd=day_ntl_usd,
4155
+ params=params,
4156
+ book=book,
4157
+ spot_symbol=spot_symbol,
4158
+ )
4159
+ return {
4160
+ "max_order_usd": 0.0,
4161
+ "upper_bound_usd": float(upper),
4162
+ "checks": {"buy": buy, "sell": sell},
4163
+ }
4164
+
4165
+ # If the upper bound itself passes, we're done.
4166
+ if best >= float(upper) - 1e-9:
4167
+ return {
4168
+ "max_order_usd": float(upper),
4169
+ "upper_bound_usd": float(upper),
4170
+ "checks": best_checks or {"buy": None, "sell": None},
4171
+ }
4172
+
4173
+ # Find a failing point above best to bracket the threshold.
4174
+ bracket_high = float(upper)
4175
+ for order_usd in scan_orders:
4176
+ if float(order_usd) <= best:
4177
+ continue
4178
+ buy = await self.check_spot_depth_ok(
4179
+ spot_asset_id,
4180
+ float(order_usd),
4181
+ "buy",
4182
+ day_ntl_usd=day_ntl_usd,
4183
+ params=params,
4184
+ book=book,
4185
+ spot_symbol=spot_symbol,
4186
+ )
4187
+ sell = await self.check_spot_depth_ok(
4188
+ spot_asset_id,
4189
+ float(order_usd),
4190
+ "sell",
4191
+ day_ntl_usd=day_ntl_usd,
4192
+ params=params,
4193
+ book=book,
4194
+ spot_symbol=spot_symbol,
4195
+ )
4196
+ if not (bool(buy.get("pass")) and bool(sell.get("pass"))):
4197
+ bracket_high = float(order_usd)
4198
+ break
4199
+
4200
+ low = float(best)
4201
+ high = float(bracket_high)
4202
+ for _ in range(max(0, int(refine_iters))):
4203
+ if high - low <= 1e-6:
4204
+ break
4205
+ mid = (low + high) / 2.0
4206
+ buy = await self.check_spot_depth_ok(
4207
+ spot_asset_id,
4208
+ float(mid),
4209
+ "buy",
4210
+ day_ntl_usd=day_ntl_usd,
4211
+ params=params,
4212
+ book=book,
4213
+ spot_symbol=spot_symbol,
4214
+ )
4215
+ sell = await self.check_spot_depth_ok(
4216
+ spot_asset_id,
4217
+ float(mid),
4218
+ "sell",
4219
+ day_ntl_usd=day_ntl_usd,
4220
+ params=params,
4221
+ book=book,
4222
+ spot_symbol=spot_symbol,
4223
+ )
4224
+ if bool(buy.get("pass")) and bool(sell.get("pass")):
4225
+ low = float(mid)
4226
+ best_checks = {"buy": buy, "sell": sell}
4227
+ else:
4228
+ high = float(mid)
4229
+
4230
+ return {
4231
+ "max_order_usd": float(low),
4232
+ "upper_bound_usd": float(upper),
4233
+ "checks": best_checks or {"buy": None, "sell": None},
4234
+ }
4235
+
4236
+ async def solve_candidates_max_net_apy_with_stop(
4237
+ self,
4238
+ *,
4239
+ deposit_usdc: float,
4240
+ stop_frac: float = 0.75,
4241
+ lookback_days: int = 45,
4242
+ oi_floor: float = 50.0,
4243
+ day_vlm_floor: float = 1e5,
4244
+ max_leverage: int = 3,
4245
+ fee_eps: float = 0.003,
4246
+ fee_model: dict[str, float] | None = None,
4247
+ depth_params: dict[str, Any] | None = None,
4248
+ perp_slippage_bps: float = 1.0,
4249
+ cooloff_hours: int = 0,
4250
+ coin_whitelist: list[str] | None = None,
4251
+ bootstrap_sims: int = DEFAULT_BOOTSTRAP_SIMS,
4252
+ bootstrap_block_hours: int = DEFAULT_BOOTSTRAP_BLOCK_HOURS,
4253
+ bootstrap_seed: int | None = None,
4254
+ ) -> list[dict[str, Any]]:
4255
+ """Rank spot/perp pairs by simulated net APY under stop-driven churn."""
4256
+
4257
+ if deposit_usdc <= 0:
4258
+ return []
4259
+
4260
+ max_hours = 5000
4261
+ lookback_days = min(int(lookback_days), max_hours // 24)
4262
+
4263
+ (
4264
+ success,
4265
+ perps_ctx_pack,
4266
+ ) = await self.hyperliquid_adapter.get_meta_and_asset_ctxs()
4267
+ if not success:
4268
+ raise ValueError(f"Failed to fetch perp metadata: {perps_ctx_pack}")
4269
+
4270
+ perps_meta_list = perps_ctx_pack[0]["universe"]
4271
+ perps_ctxs = perps_ctx_pack[1]
4272
+
4273
+ coin_to_ctx: dict[str, Any] = {}
4274
+ coin_to_maxlev: dict[str, int] = {}
4275
+ coin_to_margin_table: dict[str, int | None] = {}
4276
+ coins: list[str] = []
4277
+ for meta, ctx in zip(perps_meta_list, perps_ctxs, strict=False):
4278
+ coin = meta["name"]
4279
+ coin_to_ctx[coin] = ctx
4280
+ coin_to_maxlev[coin] = int(meta.get("maxLeverage", 10))
4281
+ coin_to_margin_table[coin] = meta.get("marginTableId")
4282
+ coins.append(coin)
4283
+ perps_set = set(coins)
4284
+
4285
+ perp_coin_to_asset_id = {
4286
+ k: v for k, v in self.hyperliquid_adapter.coin_to_asset.items() if v < 10000
4287
+ }
4288
+
4289
+ success, spot_meta = await self.hyperliquid_adapter.get_spot_meta()
4290
+ if not success:
4291
+ raise ValueError(f"Failed to fetch spot metadata: {spot_meta}")
4292
+
4293
+ tokens = spot_meta.get("tokens", [])
4294
+ spot_pairs = spot_meta.get("universe", [])
4295
+ idx_to_token = {t["index"]: t["name"] for t in tokens}
4296
+
4297
+ candidates = self._find_basis_candidates(spot_pairs, idx_to_token, perps_set)
4298
+
4299
+ liquid_candidates = await self._filter_by_liquidity(
4300
+ candidates=candidates,
4301
+ coin_to_ctx=coin_to_ctx,
4302
+ coin_to_maxlev=coin_to_maxlev,
4303
+ coin_to_margin_table=coin_to_margin_table,
4304
+ deposit_usdc=deposit_usdc,
4305
+ max_leverage=max_leverage,
4306
+ oi_floor=oi_floor,
4307
+ day_vlm_floor=day_vlm_floor,
4308
+ perp_coin_to_asset_id=perp_coin_to_asset_id,
4309
+ depth_params=depth_params,
4310
+ )
4311
+
4312
+ whitelist = (
4313
+ {coin.upper() for coin in coin_whitelist} if coin_whitelist else None
4314
+ )
4315
+ if whitelist is not None:
4316
+ liquid_candidates = [
4317
+ candidate
4318
+ for candidate in liquid_candidates
4319
+ if candidate.coin.upper() in whitelist
4320
+ ]
4321
+ if not liquid_candidates:
4322
+ return []
4323
+
4324
+ if not liquid_candidates:
4325
+ return []
4326
+
4327
+ ms_now = int(time.time() * 1000)
4328
+ start_ms = ms_now - int(lookback_days * 24 * 3600 * 1000)
4329
+
4330
+ ranked: list[dict[str, Any]] = []
4331
+
4332
+ for candidate in liquid_candidates:
4333
+ coin = candidate.coin
4334
+ spot_sym = candidate.spot_pair
4335
+ spot_asset_id = candidate.spot_asset_id
4336
+ perp_asset_id = candidate.perp_asset_id
4337
+ spot_book = candidate.spot_book
4338
+ mark_px = float(candidate.mark_price)
4339
+ max_available_lev = max(1, int(candidate.target_leverage))
4340
+ margin_table_id = candidate.margin_table_id
4341
+
4342
+ if margin_table_id:
4343
+ await self._get_margin_table_tiers(int(margin_table_id))
4344
+
4345
+ (
4346
+ (funding_ok, funding_data),
4347
+ (candles_ok, candle_data),
4348
+ ) = await asyncio.gather(
4349
+ self._fetch_funding_history_chunked(coin, start_ms, ms_now),
4350
+ self._fetch_candles_chunked(coin, "1h", start_ms, ms_now),
4351
+ )
4352
+ if not funding_ok or not candles_ok:
4353
+ continue
4354
+
4355
+ hourly_funding = [float(x.get("fundingRate", 0.0)) for x in funding_data]
4356
+ closes = [float(c.get("c", 0)) for c in candle_data if c.get("c")]
4357
+ highs = [float(c.get("h", 0)) for c in candle_data if c.get("h")]
4358
+
4359
+ n_ok = min(len(hourly_funding), len(closes), len(highs))
4360
+ if n_ok < (lookback_days * 24 - 48):
4361
+ continue
4362
+
4363
+ best_choice: dict[str, Any] | None = None
4364
+
4365
+ for L in range(1, max_available_lev + 1):
4366
+ N_leg_usd = deposit_usdc * (float(L) / (float(L) + 1.0))
4367
+ entry_mmr = self.maintenance_fraction_for_notional(
4368
+ margin_table_id,
4369
+ N_leg_usd,
4370
+ max_available_lev,
4371
+ )
4372
+
4373
+ (
4374
+ entry_cost,
4375
+ exit_cost,
4376
+ cost_breakdown,
4377
+ depth_checks,
4378
+ ) = await self._estimate_cycle_costs(
4379
+ N_leg_usd=N_leg_usd,
4380
+ spot_asset_id=spot_asset_id,
4381
+ spot_book=spot_book,
4382
+ fee_model=fee_model,
4383
+ depth_params=depth_params,
4384
+ perp_slippage_bps=perp_slippage_bps,
4385
+ day_ntl_usd=candidate.day_notional_usd,
4386
+ spot_symbol=spot_sym,
4387
+ )
4388
+
4389
+ sim = self._simulate_barrier_backtest(
4390
+ funding=hourly_funding,
4391
+ closes=closes,
4392
+ highs=highs,
4393
+ leverage=L,
4394
+ stop_frac=stop_frac,
4395
+ fee_eps=fee_eps,
4396
+ N_leg_usd=N_leg_usd,
4397
+ entry_cost_usd=entry_cost,
4398
+ exit_cost_usd=exit_cost,
4399
+ margin_table_id=margin_table_id,
4400
+ fallback_max_leverage=max_available_lev,
4401
+ cooloff_hours=cooloff_hours,
4402
+ )
4403
+
4404
+ hours = max(1.0, float(sim["hours"]))
4405
+ years = hours / (24.0 * 365.0)
4406
+ net_apy = (float(sim["net_pnl_usd"]) / max(1e-9, deposit_usdc)) / years
4407
+ gross_apy = (
4408
+ float(sim["gross_funding_usd"]) / max(1e-9, deposit_usdc)
4409
+ ) / years
4410
+ hit_rate_per_day = (
4411
+ float(sim["cycles"]) / (hours / 24.0) if hours > 0 else 0.0
4412
+ )
4413
+ avg_hold_hours = (
4414
+ float(sim["hours_in_market"]) / max(1.0, float(sim["cycles"]))
4415
+ if float(sim["cycles"]) > 0
4416
+ else hours
4417
+ )
4418
+ time_in_market = float(sim["hours_in_market"]) / hours
4419
+
4420
+ bootstrap_stats = self._bootstrap_churn_metrics(
4421
+ funding=hourly_funding,
4422
+ closes=closes,
4423
+ highs=highs,
4424
+ leverage=L,
4425
+ stop_frac=stop_frac,
4426
+ fee_eps=fee_eps,
4427
+ N_leg_usd=N_leg_usd,
4428
+ entry_cost_usd=entry_cost,
4429
+ exit_cost_usd=exit_cost,
4430
+ margin_table_id=margin_table_id,
4431
+ fallback_max_leverage=max_available_lev,
4432
+ cooloff_hours=cooloff_hours,
4433
+ deposit_usdc=deposit_usdc,
4434
+ sims=bootstrap_sims,
4435
+ block_hours=bootstrap_block_hours,
4436
+ seed=None
4437
+ if bootstrap_seed is None
4438
+ else hash((bootstrap_seed, coin, L)),
4439
+ )
4440
+
4441
+ choice: dict[str, Any] = {
4442
+ "coin": coin,
4443
+ "spot_pair": spot_sym,
4444
+ "spot_asset_id": spot_asset_id,
4445
+ "best_L": int(L),
4446
+ "net_apy": float(net_apy),
4447
+ "gross_funding_apy": float(gross_apy),
4448
+ "entry_cost_usd": float(entry_cost),
4449
+ "exit_cost_usd": float(exit_cost),
4450
+ "cycles": float(sim["cycles"]),
4451
+ "hit_rate_per_day": float(hit_rate_per_day),
4452
+ "avg_hold_hours": float(avg_hold_hours),
4453
+ "time_in_market_frac": float(time_in_market),
4454
+ "stop_frac": float(stop_frac),
4455
+ "cost_breakdown": cost_breakdown,
4456
+ "depth_checks": depth_checks,
4457
+ "mark_price": float(mark_px),
4458
+ "perp_asset_id": int(perp_asset_id),
4459
+ "mmr": float(entry_mmr),
4460
+ "margin_table_id": margin_table_id,
4461
+ "max_coin_leverage": int(max_available_lev),
4462
+ }
4463
+
4464
+ if bootstrap_stats is not None:
4465
+ choice["bootstrap_metrics"] = bootstrap_stats
4466
+
4467
+ if best_choice is None or choice["net_apy"] > best_choice["net_apy"]:
4468
+ best_choice = choice
4469
+
4470
+ if best_choice and best_choice["net_apy"] > float("-inf"):
4471
+ ranked.append(best_choice)
4472
+
4473
+ ranked.sort(key=lambda x: float(x.get("net_apy", float("-inf"))), reverse=True)
4474
+ return ranked
4475
+
4476
+ # ------------------------------------------------------------------ #
4477
+ # Utility Methods #
4478
+ # ------------------------------------------------------------------ #
4479
+
4480
+ def _z_from_conf(self, confidence: float) -> float:
4481
+ """Get z-score for given confidence level."""
4482
+ return analytics_z_from_conf(confidence)
4483
+
4484
+ def _rolling_min_sum(self, arr: list[float], window: int) -> float:
4485
+ """Calculate minimum rolling sum over window."""
4486
+ return analytics_rolling_min_sum(arr, window)
4487
+
4488
+ @staticmethod
4489
+ def maintenance_rate_from_max_leverage(max_lev: int) -> float:
4490
+ """Estimate maintenance margin rate from max leverage."""
4491
+ if max_lev <= 0:
4492
+ return 0.5
4493
+ return 0.5 / max_lev
4494
+
4495
+ @staticmethod
4496
+ def _get_safe_apy_key(result: dict[str, Any]) -> float:
4497
+ """Sort key for results by 7d expected APY."""
4498
+ safe = result.get("safe", {})
4499
+ safe_7d = safe.get("7d", {})
4500
+ if not safe_7d.get("pass", False):
4501
+ return -999.0
4502
+ return safe_7d.get("expected_apy_pct", 0.0)
4503
+
4504
+ def _get_strategy_wallet_address(self) -> str:
4505
+ """Get strategy wallet address from config."""
4506
+ strategy_wallet = self.config.get("strategy_wallet")
4507
+ if not strategy_wallet or not isinstance(strategy_wallet, dict):
4508
+ raise ValueError("strategy_wallet not configured")
4509
+ address = strategy_wallet.get("address")
4510
+ if not address:
4511
+ raise ValueError("strategy_wallet address not found")
4512
+ return str(address)
4513
+
4514
+ def _get_main_wallet_address(self) -> str:
4515
+ """Get main wallet address from config."""
4516
+ main_wallet = self.config.get("main_wallet")
4517
+ if not main_wallet or not isinstance(main_wallet, dict):
4518
+ raise ValueError("main_wallet not configured")
4519
+ address = main_wallet.get("address")
4520
+ if not address:
4521
+ raise ValueError("main_wallet address not found")
4522
+ return str(address)