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,2270 @@
1
+ import asyncio
2
+ import math
3
+ import time
4
+ import unicodedata
5
+ from datetime import UTC, datetime, timedelta, timezone
6
+ from decimal import ROUND_DOWN, ROUND_UP, Decimal
7
+ from typing import Any, Literal
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ from loguru import logger
12
+ from web3 import Web3
13
+
14
+ from wayfinder_paths.adapters.balance_adapter.adapter import BalanceAdapter
15
+ from wayfinder_paths.adapters.brap_adapter.adapter import BRAPAdapter
16
+ from wayfinder_paths.adapters.hyperlend_adapter.adapter import HyperlendAdapter
17
+ from wayfinder_paths.adapters.ledger_adapter.adapter import LedgerAdapter
18
+ from wayfinder_paths.adapters.token_adapter.adapter import TokenAdapter
19
+ from wayfinder_paths.core.constants.base import DEFAULT_SLIPPAGE
20
+ from wayfinder_paths.core.services.base import Web3Service
21
+ from wayfinder_paths.core.services.local_token_txn import (
22
+ LocalTokenTxnService,
23
+ )
24
+ from wayfinder_paths.core.services.web3_service import DefaultWeb3Service
25
+ from wayfinder_paths.core.strategies.descriptors import (
26
+ Complexity,
27
+ Directionality,
28
+ Frequency,
29
+ StratDescriptor,
30
+ TokenExposure,
31
+ Volatility,
32
+ )
33
+ from wayfinder_paths.core.strategies.Strategy import StatusDict, StatusTuple, Strategy
34
+ from wayfinder_paths.core.wallets.WalletManager import WalletManager
35
+ from wayfinder_paths.policies.enso import ENSO_ROUTER, enso_swap
36
+ from wayfinder_paths.policies.erc20 import erc20_spender_for_any_token
37
+ from wayfinder_paths.policies.hyper_evm import (
38
+ hypecore_sentinel_deposit,
39
+ whype_deposit_and_withdraw,
40
+ )
41
+ from wayfinder_paths.policies.hyperlend import (
42
+ HYPERLEND_POOL,
43
+ hyperlend_supply_and_withdraw,
44
+ )
45
+ from wayfinder_paths.policies.hyperliquid import (
46
+ any_hyperliquid_l1_payload,
47
+ any_hyperliquid_user_payload,
48
+ )
49
+ from wayfinder_paths.policies.prjx import PRJX_ROUTER, prjx_swap
50
+
51
+ SYMBOL_TRANSLATION_TABLE = str.maketrans(
52
+ {
53
+ "₮": "T",
54
+ "₿": "B",
55
+ "Ξ": "X",
56
+ }
57
+ )
58
+ WRAPPED_HYPE_ADDRESS = "0x5555555555555555555555555555555555555555"
59
+
60
+
61
+ class HyperlendStableYieldStrategy(Strategy):
62
+ name = "HyperLend Stable Optimizer"
63
+
64
+ # Strategy parameters
65
+ APY_SHORT_CIRCUIT_THRESHOLD = None
66
+ MIN_USDT0_DEPOSIT_AMOUNT = 1
67
+ HORIZON_HOURS = 6
68
+ BLOCK_LEN = 6
69
+ TRIALS = 4000
70
+ HALFLIFE_DAYS = 7
71
+ SEED = 7
72
+ HYSTERESIS_DWELL_HOURS = 168
73
+ HYSTERESIS_Z = 1.15
74
+ GAS_MAXIMUM = 0.1
75
+ ROTATION_POLICY = "hysteresis"
76
+ ROTATION_TX_COST = 0.002
77
+ SUPPLY_CAP_BUFFER_BPS = 50
78
+ SUPPLY_CAP_MIN_BUFFER_TOKENS = 0.5
79
+ ASSETS_SNAPSHOT_TTL_SECONDS = 20.0
80
+ DEFAULT_LOOKBACK_HOURS = 24 * 7
81
+ APY_REBALANCE_THRESHOLD = 0.0035
82
+ TOURNAMENT_MODE = "joint" # "joint" or "independent"
83
+ ROTATION_COOLDOWN = timedelta(hours=168)
84
+ P_BEST_ROTATION_THRESHOLD = 0.4
85
+ MAX_CANDIDATES = 5
86
+ MIN_STABLE_SWAP_TOKENS = 1e-3
87
+ MAX_GAS = 0.1 # hype float
88
+
89
+ INFO = StratDescriptor(
90
+ description=f"""Multi-strategy allocator that converts USDT0 into the most consistently rewarding HyperLend stablecoin and continuously checks if a rotation is justified.
91
+ **What it does:** Pulls USDT0 from the main wallet, ensures a small HYPE safety buffer for gas, swaps the remaining stable balance into candidate markets, and supplies
92
+ liquidity to HyperLend. Hourly rate histories are aggregated into a 7-day panel and routed through a block-bootstrap tournament (horizon {HORIZON_HOURS}h, block length {BLOCK_LEN}, {TRIALS:,}
93
+ trials, {HALFLIFE_DAYS}-day half-life weighting) to estimate which asset has the highest probability of outperforming peers. USDT0 is the LayerZero bridgable stablecoin for USDT.
94
+ **Exposure type:** Market-neutral stablecoin lending on HyperEVM with automated rotation into whichever pool offers the strongest risk-adjusted lending yield.
95
+ **Chains:** HyperEVM only (HyperLend pool suite).
96
+ **Deposit/Withdrawal:** Deposits move USDT0 from the main wallet into the strategy wallet, top up a minimal HYPE gas buffer, rotate into the selected stable, and lend it via HyperLend.
97
+ Withdrawals unwind the lend position, convert balances back to USDT, and return funds (plus residual HYPE) to the main wallet.
98
+ **Gas**: Requires HYPE on HypeEVM. Arbitrary amount of funding gas is accepted via strategy wallet transfers.
99
+ """,
100
+ summary=(
101
+ "Recency-weighted HyperLend stablecoin optimizer that bootstraps 7-day rate history "
102
+ f"(horizon {HORIZON_HOURS}h, {BLOCK_LEN}-hour blocks, {TRIALS:,} simulations) to pick the top "
103
+ "performer, funds with USDT0, tops up a small HYPE gas buffer, and defaults to a hysteresis "
104
+ f"rotation band (dwell={HYSTERESIS_DWELL_HOURS}h, z={HYSTERESIS_Z:.2f}) to avoid churn while still "
105
+ "short-circuiting when yield gaps are extreme."
106
+ ),
107
+ gas_token_symbol="HYPE",
108
+ gas_token_id="hyperliquid-hyperevm",
109
+ deposit_token_id="usdt0-hyperevm",
110
+ minimum_net_deposit=10,
111
+ gas_maximum=MAX_GAS, # hype float
112
+ gas_threshold=MAX_GAS / 3,
113
+ # risk indicators
114
+ volatility=Volatility.LOW,
115
+ volatility_description_short=(
116
+ "Pure HyperLend stablecoin lending keeps NAV steady aside from rate drift."
117
+ ),
118
+ directionality=Directionality.MARKET_NEUTRAL,
119
+ directionality_description=(
120
+ "Rotates capital between USD stables so exposure stays market neutral."
121
+ ),
122
+ complexity=Complexity.LOW,
123
+ complexity_description="Agent handles optimal pool finding, swaps, and lend transactions automatically.",
124
+ token_exposure=TokenExposure.STABLECOINS,
125
+ token_exposure_description=(
126
+ "Only HyperEVM USD stables (USDT0 and peers), no volatile tokens."
127
+ ),
128
+ frequency=Frequency.LOW,
129
+ frequency_description=(
130
+ "Updates every 2 hours; rotations infrequent (weekly cooldowns)."
131
+ ),
132
+ return_drivers=["lend APY", "pool yield"],
133
+ config={
134
+ "deposit": {
135
+ "description": "Move USDT0 into the strategy, ensure a small HYPE gas buffer, and supply the best HyperLend stable.",
136
+ "parameters": {
137
+ "main_token_amount": {
138
+ "type": "float",
139
+ "unit": "USDT0 tokens",
140
+ "description": "Amount of USDT0 to allocate to HyperLend.",
141
+ "minimum": 1.0, # TODO: 10
142
+ "examples": ["100.0", "250.5"],
143
+ },
144
+ "gas_token_amount": {
145
+ "type": "float",
146
+ "unit": "HYPE tokens",
147
+ "description": "Amount of HYPE to top up into the strategy wallet to cover gas costs.",
148
+ "minimum": 0.0,
149
+ "maximum": GAS_MAXIMUM,
150
+ "recommended": GAS_MAXIMUM,
151
+ },
152
+ },
153
+ "result": "USDT0 converted into the top-performing HyperLend stablecoin and supplied on-chain.",
154
+ },
155
+ "withdraw": {
156
+ "description": "Unwinds the position, converts balances to USDT0, and returns funds (plus HYPE buffer) to the main wallet.",
157
+ "parameters": {},
158
+ "result": "Principal and accrued gains returned in USDT0; residual HYPE buffer swept home.",
159
+ },
160
+ "update": {
161
+ "description": (
162
+ "Evaluates tournament projections and rotates when the hysteresis band is breached "
163
+ f"(dwell={HYSTERESIS_DWELL_HOURS}h, z={HYSTERESIS_Z:.2f}) or when a short-circuit gap is hit "
164
+ "(set HYPERLEND_ROTATION_POLICY=cooldown to restore the legacy threshold/cooldown rule)."
165
+ ),
166
+ "parameters": {},
167
+ },
168
+ "status": {
169
+ "description": "Summarises current lend position, APY, and chosen asset.",
170
+ "provides": [
171
+ "lent_asset",
172
+ "lent_balance",
173
+ "current_apy",
174
+ "best_candidate",
175
+ "best_candidate_apy",
176
+ ],
177
+ },
178
+ "points": {
179
+ "description": "Fetch the HyperLend points account snapshot for this strategy wallet using a signed login.",
180
+ "parameters": {},
181
+ "result": "Returns the HyperLend points API payload for the strategy wallet.",
182
+ },
183
+ "technical_details": {
184
+ "rotation_policy": ROTATION_POLICY.lower(),
185
+ "hysteresis_dwell_hours": HYSTERESIS_DWELL_HOURS,
186
+ "hysteresis_z": HYSTERESIS_Z,
187
+ "rotation_tx_cost": ROTATION_TX_COST,
188
+ },
189
+ },
190
+ )
191
+
192
+ def __init__(
193
+ self,
194
+ config: dict[str, Any] | None = None,
195
+ *,
196
+ main_wallet: dict[str, Any] | None = None,
197
+ strategy_wallet: dict[str, Any] | None = None,
198
+ simulation: bool = False,
199
+ web3_service: Web3Service = None,
200
+ api_key: str | None = None,
201
+ ):
202
+ super().__init__(api_key=api_key)
203
+ merged_config: dict[str, Any] = dict(config or {})
204
+ if main_wallet is not None:
205
+ merged_config["main_wallet"] = main_wallet
206
+ if strategy_wallet is not None:
207
+ merged_config["strategy_wallet"] = strategy_wallet
208
+
209
+ self.config = merged_config
210
+ self.simulation = simulation
211
+ self.balance_adapter = None
212
+ self.tx_adapter = None
213
+ self.token_adapter = None
214
+ self.evm_transaction_adapter = None
215
+ self.web3_service = web3_service
216
+ self.pool_adapter = None
217
+ self.brap_adapter = None
218
+ self.hyperlend_adapter = None
219
+
220
+ try:
221
+ main_wallet_cfg = self.config.get("main_wallet")
222
+ strategy_wallet_cfg = self.config.get("strategy_wallet")
223
+
224
+ # Validate wallets are configured
225
+ if not strategy_wallet_cfg or not strategy_wallet_cfg.get("address"):
226
+ raise ValueError(
227
+ "strategy_wallet not configured. Provide strategy_wallet address in config or ensure wallet is properly configured for your wallet provider"
228
+ )
229
+
230
+ adapter_config = {
231
+ "main_wallet": main_wallet_cfg or None,
232
+ "strategy_wallet": strategy_wallet_cfg or None,
233
+ "strategy": self.config,
234
+ }
235
+
236
+ if self.web3_service is None:
237
+ wallet_provider = WalletManager.get_provider(adapter_config)
238
+ token_transaction_service = LocalTokenTxnService(
239
+ adapter_config,
240
+ wallet_provider=wallet_provider,
241
+ simulation=self.simulation,
242
+ )
243
+ web3_service = DefaultWeb3Service(
244
+ wallet_provider=wallet_provider,
245
+ evm_transactions=token_transaction_service,
246
+ )
247
+ else:
248
+ web3_service = self.web3_service
249
+ token_transaction_service = web3_service.token_transactions
250
+ balance = BalanceAdapter(adapter_config, web3_service=web3_service)
251
+ token_adapter = TokenAdapter()
252
+ ledger_adapter = LedgerAdapter() # here
253
+ brap_adapter = BRAPAdapter(
254
+ web3_service=web3_service, simulation=self.simulation
255
+ )
256
+ hyperlend_adapter = HyperlendAdapter(
257
+ adapter_config,
258
+ simulation=self.simulation,
259
+ web3_service=web3_service,
260
+ )
261
+
262
+ self.register_adapters(
263
+ [
264
+ balance,
265
+ token_adapter,
266
+ ledger_adapter,
267
+ brap_adapter,
268
+ hyperlend_adapter,
269
+ token_transaction_service,
270
+ ]
271
+ )
272
+ self.balance_adapter = balance
273
+ self.evm_transaction_adapter = token_transaction_service
274
+ self.web3_service = web3_service
275
+ self.token_adapter = token_adapter
276
+ self.ledger_adapter = ledger_adapter
277
+ self.brap_adapter = brap_adapter
278
+ self.hyperlend_adapter = hyperlend_adapter
279
+
280
+ self._assets_snapshot = None
281
+ self._assets_snapshot_at = None
282
+ self._assets_snapshot_lock = asyncio.Lock()
283
+ self.symbol_display_map = {}
284
+
285
+ except Exception as e:
286
+ logger.error(f"Failed to initialize strategy adapters: {e}")
287
+ raise
288
+
289
+ async def setup(self):
290
+ if self.token_adapter is None:
291
+ raise RuntimeError(
292
+ "Token adapter not initialized. Strategy initialization may have failed."
293
+ )
294
+ try:
295
+ success, self.usdt_token_info = await self.token_adapter.get_token(
296
+ "usdt0-hyperevm"
297
+ )
298
+ if not success:
299
+ self.usdt_token_info = {}
300
+
301
+ success, self.hype_token_info = await self.token_adapter.get_token(
302
+ "hype-hyperevm"
303
+ )
304
+ if not success:
305
+ self.hype_token_info = {}
306
+ except Exception:
307
+ self.usdt_token_info = {}
308
+ self.hype_token_info = {}
309
+
310
+ self.current_token = None
311
+ self.current_symbol = None
312
+ self.current_avg_apy = 0.0
313
+ self.kept_hype_tokens = 0.0
314
+
315
+ self.last_summary: pd.DataFrame | None = None
316
+ self.last_dominance: pd.DataFrame | None = None
317
+ self.last_samples: np.ndarray | None = None
318
+
319
+ self.rotation_policy = self.ROTATION_POLICY
320
+ if self.rotation_policy not in {"hysteresis", "cooldown"}:
321
+ self.rotation_policy = "hysteresis"
322
+ self.hys_dwell_hours: int = max(1, self.HYSTERESIS_DWELL_HOURS)
323
+ self.hys_z: float = self.HYSTERESIS_Z
324
+ self.rotation_tx_cost: float = self.ROTATION_TX_COST
325
+
326
+ async def deposit(self) -> StatusTuple:
327
+ self._invalidate_assets_snapshot()
328
+ await self._hydrate_position_from_chain()
329
+
330
+ return (True, "hydrated positions")
331
+
332
+ async def _estimate_redeploy_tokens(self) -> float:
333
+ positions = await self._get_lent_positions()
334
+ total_tokens = 0.0
335
+
336
+ for entry in positions.values():
337
+ token = entry.get("token")
338
+ amount_wei = entry.get("amount_wei", 0)
339
+ if not token or amount_wei <= 0:
340
+ continue
341
+ try:
342
+ total_tokens += float(amount_wei) / 10 ** token.get("decimals")
343
+ except Exception:
344
+ continue
345
+
346
+ return total_tokens
347
+
348
+ def _amount_to_wei(self, token: dict[str, Any], amount: Decimal) -> int:
349
+ """Convert ``amount`` tokens into base units using existing helpers."""
350
+
351
+ if amount <= 0:
352
+ return 0
353
+
354
+ try:
355
+ return int(amount * (10 ** token.get("decimals")))
356
+ except Exception:
357
+ try:
358
+ decimals = int(getattr(token, "decimals", 18))
359
+ except (TypeError, ValueError):
360
+ decimals = 18
361
+ scale = Decimal(10) ** decimals
362
+ return int((amount * scale).to_integral_value(rounding=ROUND_UP))
363
+
364
+ def _display_symbol(self, symbol: str | None) -> str:
365
+ if not symbol:
366
+ return ""
367
+ display = self.symbol_display_map.get(symbol)
368
+ if display:
369
+ return str(display)
370
+ return str(symbol).upper()
371
+
372
+ async def _hydrate_position_from_chain(self) -> None:
373
+ snapshot = await self._get_assets_snapshot()
374
+ asset_map = (
375
+ snapshot.get("_by_underlying", {}) if isinstance(snapshot, dict) else {}
376
+ )
377
+
378
+ if self.current_token:
379
+ checksum = self._token_checksum(self.current_token)
380
+ asset = asset_map.get(checksum) if checksum else None
381
+ supply = float(asset.get("supply", 0.0)) if asset else 0.0
382
+ if supply > 0.0:
383
+ symbol = self.current_token.get("symbol", None)
384
+ display = asset.get("symbol_display") if asset else symbol
385
+ if symbol and display:
386
+ self.symbol_display_map.setdefault(str(symbol), display)
387
+ self.current_avg_apy = float(asset.get("supply_apy") or 0.0)
388
+ return True
389
+ self.current_token = None
390
+ self.current_symbol = None
391
+ self.current_avg_apy = 0.0
392
+
393
+ positions = await self._get_lent_positions(snapshot)
394
+ if not positions:
395
+ return False
396
+
397
+ top_entry = max(positions.values(), key=lambda entry: entry["amount_wei"])
398
+ if top_entry.get("amount_wei") <= 0:
399
+ return False
400
+
401
+ token = top_entry.get("token")
402
+ if not token.get("address"):
403
+ token["address"] = top_entry.get("asset").get("underlying")
404
+ self.current_token = token
405
+ symbol = token.get("symbol", None)
406
+ checksum = self._token_checksum(token)
407
+ asset = asset_map.get(checksum) if checksum else None
408
+ if not symbol and asset:
409
+ symbol = asset.get("symbol") or asset.get("symbol_display")
410
+ self.current_symbol = symbol
411
+ display_symbol = asset.get("symbol_display") if asset else None
412
+ if symbol:
413
+ self.symbol_display_map.setdefault(
414
+ str(symbol), display_symbol or symbol.upper()
415
+ )
416
+ self.current_avg_apy = float(asset.get("supply_apy") or 0.0) if asset else 0.0
417
+ return True
418
+
419
+ async def _get_assets_snapshot(self, force_refresh: bool = False) -> dict[str, Any]:
420
+ now = time.time()
421
+ if (
422
+ not force_refresh
423
+ and self._assets_snapshot is not None
424
+ and self._assets_snapshot_at is not None
425
+ and now - self._assets_snapshot_at <= self.ASSETS_SNAPSHOT_TTL_SECONDS
426
+ ):
427
+ return self._assets_snapshot
428
+
429
+ async with self._assets_snapshot_lock:
430
+ now = time.time()
431
+ if (
432
+ not force_refresh
433
+ and self._assets_snapshot is not None
434
+ and self._assets_snapshot_at is not None
435
+ and now - self._assets_snapshot_at <= self.ASSETS_SNAPSHOT_TTL_SECONDS
436
+ ):
437
+ return self._assets_snapshot
438
+
439
+ _, snapshot = await self.hyperlend_adapter.get_assets_view(
440
+ chain_id=self.hype_token_info.get("chain").get("id"),
441
+ user_address=self._get_strategy_wallet_address(),
442
+ )
443
+
444
+ assets = snapshot.get("assets_view", {}).get("assets", [])
445
+ asset_map = {}
446
+
447
+ for asset in assets:
448
+ underlying = asset.get("underlying")
449
+ try:
450
+ checksum = Web3.to_checksum_address(underlying)
451
+ except Exception:
452
+ continue
453
+
454
+ asset["underlying_checksum"] = checksum
455
+ symbol_raw = asset.get("symbol")
456
+ canonical = asset.get("symbol_canonical")
457
+ if not canonical:
458
+ canonical = (
459
+ self._normalize_symbol(symbol_raw)
460
+ if symbol_raw
461
+ else self._normalize_symbol(checksum)
462
+ )
463
+ asset["symbol_canonical"] = canonical
464
+ display_symbol = asset.get("symbol_display")
465
+ if not display_symbol:
466
+ display_symbol = symbol_raw or (
467
+ canonical.upper() if canonical else checksum
468
+ )
469
+ asset["symbol_display"] = display_symbol
470
+ key = symbol_raw or canonical
471
+ if key:
472
+ self.symbol_display_map.setdefault(str(key), display_symbol)
473
+ if canonical:
474
+ self.symbol_display_map.setdefault(canonical, display_symbol)
475
+ asset_map[checksum] = asset
476
+
477
+ snapshot["_by_underlying"] = asset_map
478
+ self._assets_snapshot = snapshot
479
+ self._assets_snapshot_at = time.time()
480
+
481
+ return snapshot
482
+
483
+ async def _has_supply_cap_headroom(
484
+ self, token: dict[str, Any], required_tokens: float
485
+ ) -> bool:
486
+ checksum = self._token_checksum(token)
487
+ if not checksum:
488
+ return False
489
+
490
+ try:
491
+ _, data = await self.hyperlend_adapter.get_stable_markets(
492
+ chain_id=self.hype_token_info.get("chain").get("id"),
493
+ required_underlying_tokens=required_tokens,
494
+ buffer_bps=self.SUPPLY_CAP_BUFFER_BPS,
495
+ min_buffer_tokens=self.SUPPLY_CAP_MIN_BUFFER_TOKENS,
496
+ is_stable_symbol=True,
497
+ )
498
+ markets = data.get("markets", {}) if isinstance(data, dict) else {}
499
+ except Exception:
500
+ return True
501
+
502
+ try:
503
+ target_lower = Web3.to_checksum_address(checksum).lower()
504
+ except Exception:
505
+ target_lower = str(checksum).lower()
506
+
507
+ for addr in markets.keys():
508
+ try:
509
+ if Web3.to_checksum_address(addr).lower() == target_lower:
510
+ return True
511
+ except Exception:
512
+ if str(addr).lower() == target_lower:
513
+ return True
514
+ return False
515
+
516
+ async def _get_lent_positions(self, snapshot=None) -> dict[str, dict[str, Any]]:
517
+ if not snapshot:
518
+ snapshot = await self._get_assets_snapshot()
519
+ assets = snapshot.get("assets_view", {}).get("assets", None)
520
+
521
+ if not assets:
522
+ return {}
523
+
524
+ positions = {}
525
+ for asset in assets:
526
+ try:
527
+ checksum = asset.get("underlying_checksum") or Web3.to_checksum_address(
528
+ asset.get("underlying")
529
+ )
530
+ except Exception:
531
+ logger.info(f"Error getting checksum for asset: {asset}")
532
+ continue
533
+
534
+ supply = float(asset.get("supply", 0.0) or 0.0)
535
+ if supply <= 0.0:
536
+ logger.info(f"Supply is 0 for asset: {asset}")
537
+ continue
538
+
539
+ try:
540
+ success, token = await self.token_adapter.get_token(checksum)
541
+ if not success or not isinstance(token, dict):
542
+ logger.info(f"Error getting token for asset: {asset}")
543
+ continue
544
+ except Exception:
545
+ logger.info(f"Error getting token for asset: {asset}")
546
+ continue
547
+
548
+ amount_wei = supply * (10 ** token.get("decimals", 0))
549
+ if amount_wei <= 0:
550
+ logger.info(f"Amount wei is 0 for asset: {asset}")
551
+ continue
552
+
553
+ positions[checksum] = {
554
+ "token": token,
555
+ "amount_wei": amount_wei,
556
+ "asset": asset,
557
+ }
558
+ return positions
559
+
560
+ def _normalize_symbol(self, symbol: str) -> str:
561
+ if symbol is None:
562
+ return ""
563
+
564
+ normalized = unicodedata.normalize("NFKD", str(symbol)).translate(
565
+ SYMBOL_TRANSLATION_TABLE
566
+ )
567
+ ascii_only = normalized.encode("ascii", "ignore").decode("ascii")
568
+ filtered = "".join(ch for ch in ascii_only if ch.isalnum())
569
+ if filtered:
570
+ return filtered.lower()
571
+ return str(symbol).lower()
572
+
573
+ def _is_stable_symbol(self, symbol: str) -> bool:
574
+ if not symbol:
575
+ return False
576
+ symbol_upper = symbol.upper()
577
+ stable_keywords = ["USD", "USDC", "USDT", "USDP", "USDD", "USDS", "DAI", "USKB"]
578
+ return any(keyword in symbol_upper for keyword in stable_keywords)
579
+
580
+ def _invalidate_assets_snapshot(self) -> None:
581
+ self._assets_snapshot = None
582
+ self._assets_snapshot_at = None
583
+
584
+ async def _execute_swap(
585
+ self,
586
+ from_token_info: dict[str, Any],
587
+ to_token_info: dict[str, Any],
588
+ amount_wei: int,
589
+ *,
590
+ slippage: float = DEFAULT_SLIPPAGE,
591
+ ) -> str | None:
592
+ if amount_wei <= 0:
593
+ return None
594
+
595
+ from_token_id = (
596
+ from_token_info.get("token_id")
597
+ or f"{from_token_info.get('asset_id')}-{self.hype_token_info.get('chain').get('code')}"
598
+ )
599
+ to_token_id = (
600
+ to_token_info.get("token_id")
601
+ or f"{to_token_info.get('asset_id')}-{self.hype_token_info.get('chain').get('code')}"
602
+ )
603
+ if not from_token_id or not to_token_id:
604
+ return None
605
+
606
+ from_address = self._get_token_address(from_token_info, chain_code="hyperevm")
607
+ to_address = self._get_token_address(to_token_info, chain_code="hyperevm")
608
+ if not from_address or not to_address:
609
+ return None
610
+
611
+ from_symbol = from_token_info.get("symbol")
612
+ to_symbol = to_token_info.get("symbol")
613
+
614
+ strategy_address = self._get_strategy_wallet_address()
615
+
616
+ retries = 7
617
+ while retries > 0:
618
+ try:
619
+ from_decimals = from_token_info.get("decimals") or 18
620
+ amount_wei_str = str(amount_wei)
621
+ # TODO: await favourable fees
622
+ (
623
+ result,
624
+ tx_data,
625
+ ) = await self.brap_adapter.swap_from_token_ids(
626
+ from_token_id=from_token_id,
627
+ to_token_id=to_token_id,
628
+ from_address=strategy_address,
629
+ amount=amount_wei_str,
630
+ slippage=slippage,
631
+ strategy_name=self.name,
632
+ )
633
+
634
+ if not result:
635
+ error_msg = str(tx_data) if isinstance(tx_data, str) else ""
636
+
637
+ if (
638
+ "Transaction did not land" in error_msg
639
+ or "Broadcast fail" in error_msg.lower()
640
+ or "broadcast" in error_msg.lower()
641
+ and "fail" in error_msg.lower()
642
+ ):
643
+ retries -= 1
644
+ await asyncio.sleep(3.0)
645
+ continue
646
+ else:
647
+ return None
648
+
649
+ self._invalidate_assets_snapshot()
650
+ human = float(amount_wei) / (10**from_decimals)
651
+ return f"Swapped {human:.4f} {from_symbol} → {to_symbol}"
652
+
653
+ except Exception:
654
+ retries -= 1
655
+ if retries > 0:
656
+ await asyncio.sleep(3.0)
657
+
658
+ return None
659
+
660
+ def _get_token_address(
661
+ self, token: dict[str, Any] | None, chain_code: str = "hyperevm"
662
+ ) -> str | None:
663
+ """
664
+ Extract token address from various token data structures.
665
+
666
+ Handles:
667
+ 1. Top-level 'address' field (e.g., hype_token_info)
668
+ 2. 'addresses' dict with chain_code key (e.g., addresses: {'hyperevm': '0x...'})
669
+ 3. 'chain_addresses' dict with chain_code key (e.g., chain_addresses: {'hyperevm': {'address': '0x...'}})
670
+
671
+ Args:
672
+ token: Token dictionary with address information
673
+ chain_code: Chain code to look up in nested structures (default: 'hyperevm')
674
+
675
+ Returns:
676
+ Token address string or None if not found
677
+ """
678
+ if not token:
679
+ return None
680
+
681
+ address = token.get("address")
682
+ if address:
683
+ return str(address)
684
+
685
+ addresses = token.get("addresses")
686
+ if isinstance(addresses, dict):
687
+ address = addresses.get(chain_code)
688
+ if address:
689
+ return str(address)
690
+ if addresses:
691
+ first_address = next(iter(addresses.values()), None)
692
+ if first_address:
693
+ return str(first_address)
694
+
695
+ chain_addresses = token.get("chain_addresses")
696
+ if isinstance(chain_addresses, dict):
697
+ chain_info = chain_addresses.get(chain_code)
698
+ if isinstance(chain_info, dict):
699
+ address = chain_info.get("address")
700
+ if address:
701
+ return str(address)
702
+ if chain_addresses:
703
+ first_chain_info = next(iter(chain_addresses.values()), None)
704
+ if isinstance(first_chain_info, dict):
705
+ address = first_chain_info.get("address")
706
+ if address:
707
+ return str(address)
708
+
709
+ return None
710
+
711
+ def _token_checksum(self, token: dict[str, Any] | None) -> str | None:
712
+ address = self._get_token_address(token)
713
+ if not address:
714
+ return None
715
+ try:
716
+ return Web3.to_checksum_address(address)
717
+ except Exception:
718
+ return None
719
+
720
+ async def withdraw(self, amount: float | None = None) -> StatusTuple:
721
+ messages = []
722
+
723
+ active_token = self.current_token
724
+ if not active_token:
725
+ await self._hydrate_position_from_chain()
726
+ active_token = self.current_token
727
+
728
+ amount_wei = 0
729
+ snapshot = await self._get_assets_snapshot(force_refresh=True)
730
+ asset_map = (
731
+ snapshot.get("_by_underlying", {}) if isinstance(snapshot, dict) else {}
732
+ )
733
+ if active_token:
734
+ checksum = self._token_checksum(active_token)
735
+ asset = asset_map.get(checksum) if checksum else None
736
+ lent_balance = float(asset.get("supply", 0.0)) if asset else 0.0
737
+ if lent_balance > 0:
738
+ amount_wei = float(lent_balance) * (10 ** active_token.get("decimals"))
739
+ chain_code = self.hype_token_info.get("chain", {}).get(
740
+ "code", "hyperevm"
741
+ )
742
+ underlying_token_address = self._get_token_address(
743
+ active_token, chain_code
744
+ )
745
+ if not underlying_token_address:
746
+ messages.append(
747
+ f"Failed to resolve token address for {active_token.get('symbol', 'unknown')} on {chain_code}; skipping unlend"
748
+ )
749
+ else:
750
+ # TODO: await favourable fees
751
+ status, message = await self.hyperlend_adapter.unlend(
752
+ underlying_token=underlying_token_address,
753
+ qty=int(amount_wei),
754
+ chain_id=int(self.hype_token_info.get("chain").get("id")),
755
+ native=False,
756
+ )
757
+ self._invalidate_assets_snapshot()
758
+ self._invalidate_assets_snapshot()
759
+ else:
760
+ messages.append(
761
+ "No active HyperLend position found; sweeping idle balances."
762
+ )
763
+ else:
764
+ messages.append("No HyperLend position detected; sweeping idle balances.")
765
+
766
+ sweep_actions = await self._swap_residual_balances_to_token(
767
+ self.usdt_token_info
768
+ )
769
+
770
+ try:
771
+ _, total_usdt_wei = await self.balance_adapter.get_balance(
772
+ token_id=self.usdt_token_info.get("token_id"),
773
+ wallet_address=self._get_strategy_wallet_address(),
774
+ )
775
+ except Exception:
776
+ total_usdt_wei = 0
777
+
778
+ if total_usdt_wei and total_usdt_wei > 0:
779
+ total_usdt = float(total_usdt_wei) / (
780
+ 10 ** self.usdt_token_info.get("decimals", 18)
781
+ )
782
+ (
783
+ transfer_success,
784
+ transfer_message,
785
+ ) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
786
+ self.usdt_token_info.get("token_id"),
787
+ total_usdt,
788
+ strategy_name=self.name,
789
+ )
790
+ if transfer_success:
791
+ messages.append(
792
+ f"Returned {total_usdt:.2f} {self.usdt_token_info.get('symbol')} from strategy wallet to main wallet"
793
+ )
794
+ else:
795
+ messages.append(
796
+ "Returned USDT0 to ledger but on-chain transfer failed; treating as withdrawn for simulation"
797
+ )
798
+
799
+ try:
800
+ _, total_hype_wei = await self.balance_adapter.get_balance(
801
+ token_id=self.hype_token_info.get("token_id"),
802
+ wallet_address=self._get_strategy_wallet_address(),
803
+ )
804
+ except Exception:
805
+ total_hype_wei = 0
806
+
807
+ if total_hype_wei and total_hype_wei > 0:
808
+ total_hype = float(total_hype_wei) / (
809
+ 10 ** self.hype_token_info.get("decimals", 18)
810
+ )
811
+ total_hype = total_hype * 0.9
812
+ (
813
+ transfer_success,
814
+ transfer_message,
815
+ ) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
816
+ self.hype_token_info.get("token_id"),
817
+ total_hype,
818
+ strategy_name=self.name,
819
+ )
820
+ if transfer_success:
821
+ messages.append(
822
+ f"Returned {total_hype:.2f} {self.hype_token_info.get('symbol')} from strategy wallet to main wallet"
823
+ )
824
+ else:
825
+ messages.append(
826
+ "Returned HYPE to ledger but on-chain transfer failed; treating as withdrawn for simulation"
827
+ )
828
+
829
+ if sweep_actions:
830
+ messages.append(f"Residual sweeps: {'; '.join(sweep_actions)}.")
831
+
832
+ if not messages:
833
+ messages.append("Withdrawal complete; no balances detected to transfer")
834
+
835
+ self.current_token = None
836
+ self.current_symbol = None
837
+ self.current_avg_apy = 0.0
838
+ self.kept_hype_tokens = 0.0
839
+
840
+ return (True, ". ".join(messages))
841
+
842
+ async def _swap_residual_balances_to_token(
843
+ self, token_info: dict[str, Any], include_native: bool = False
844
+ ) -> list[str]:
845
+ snapshot = await self._get_assets_snapshot(force_refresh=True)
846
+ balances = await self._wallet_balances_from_snapshot(snapshot)
847
+ if not balances:
848
+ return []
849
+ actions = []
850
+ target_checksum = self._token_checksum(token_info)
851
+ for checksum, entry in balances.items():
852
+ if checksum == "native":
853
+ if not include_native:
854
+ continue
855
+ else:
856
+ if checksum == target_checksum:
857
+ continue
858
+ asset = entry.get("asset")
859
+ if not asset or not asset.get("is_stablecoin"):
860
+ continue
861
+ balance_wei = int(entry.get("wei") or 0)
862
+ if balance_wei <= 0:
863
+ continue
864
+ token = entry.get("token")
865
+ if not token:
866
+ continue
867
+ token["address"] = checksum
868
+ min_amount = self._amount_to_wei(
869
+ token, Decimal(str(self.MIN_STABLE_SWAP_TOKENS))
870
+ )
871
+ if balance_wei <= min_amount:
872
+ continue
873
+
874
+ try:
875
+ swap_action = await self._execute_swap(
876
+ from_token_info=token,
877
+ to_token_info=token_info,
878
+ amount_wei=balance_wei,
879
+ )
880
+ if swap_action:
881
+ actions.append(swap_action)
882
+ continue
883
+ except Exception:
884
+ continue
885
+
886
+ # TODO: untested past this point
887
+ try:
888
+ try:
889
+ decimals = int(token.get("decimals", 18))
890
+ except (TypeError, ValueError):
891
+ decimals = 18
892
+ amount_tokens = float(balance_wei) / (10**decimals)
893
+ (
894
+ success,
895
+ message,
896
+ ) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
897
+ token_id=token_info.get("token_id"),
898
+ amount=amount_tokens,
899
+ strategy_name=self.name,
900
+ )
901
+ except Exception:
902
+ continue
903
+
904
+ if success:
905
+ actions.append(
906
+ f"Transferred {amount_tokens:.4f} {token.symbol} to main wallet"
907
+ )
908
+ if actions:
909
+ self._invalidate_assets_snapshot()
910
+
911
+ return actions
912
+
913
+ async def update(self) -> StatusTuple:
914
+ """Rebalance or update positions."""
915
+
916
+ await self._hydrate_position_from_chain()
917
+
918
+ redeploy_tokens = await self._estimate_redeploy_tokens()
919
+ idle_tokens = await self._get_idle_tokens()
920
+
921
+ if idle_tokens > 0.1:
922
+ total_required = (redeploy_tokens or 0.0) + idle_tokens
923
+ best_candidate = await self._select_best_stable_asset(
924
+ required_underlying_tokens=total_required,
925
+ operation="deposit",
926
+ allow_rotation_without_current=False,
927
+ )
928
+ if best_candidate is None:
929
+ token_symbol = (
930
+ self._display_symbol(getattr(self, "current_symbol", None))
931
+ if self.current_token
932
+ else "idle"
933
+ )
934
+ return (
935
+ True,
936
+ f"Idle balance ({idle_tokens:.4f} {token_symbol}) remains; no HyperLend market has sufficient capacity.",
937
+ False,
938
+ )
939
+ best_token, best_symbol, best_hourly = best_candidate
940
+ target_apy = self._hourly_to_apy(best_hourly)
941
+ reserve_wei = self.GAS_MAXIMUM * (
942
+ 10 ** self.hype_token_info.get("decimals")
943
+ )
944
+ display_symbol = self._display_symbol(best_symbol)
945
+
946
+ (
947
+ actions,
948
+ total_target,
949
+ _,
950
+ kept_hype,
951
+ ) = await self._allocate_to_target(
952
+ best_token,
953
+ target_symbol=display_symbol,
954
+ target_apy=target_apy,
955
+ hype_reserve_wei=reserve_wei,
956
+ lend_operation="deposit",
957
+ )
958
+ message = (
959
+ f"Redeployed idle funds into {display_symbol} (~{target_apy:.2%} APY). "
960
+ f"Current lent balance {total_target:.4f} {display_symbol}."
961
+ )
962
+ if actions:
963
+ message = f"{message} Actions: {'; '.join(actions)}."
964
+ message = f"{message} HYPE buffer at {self.kept_hype_tokens:.2f} tokens."
965
+ return (True, message, True)
966
+
967
+ required_tokens = (
968
+ redeploy_tokens if redeploy_tokens and redeploy_tokens > 0 else None
969
+ )
970
+
971
+ best_candidate = await self._select_best_stable_asset(
972
+ required_underlying_tokens=required_tokens,
973
+ operation="update",
974
+ allow_rotation_without_current=True,
975
+ )
976
+ if best_candidate is None:
977
+ return (True, "No optimal HyperLend market identified.", False)
978
+
979
+ best_token, best_symbol, best_hourly = best_candidate
980
+ target_apy = self._hourly_to_apy(best_hourly)
981
+ current_checksum = self._token_checksum(self.current_token)
982
+ best_checksum = self._token_checksum(best_token)
983
+ display_symbol = self._display_symbol(best_symbol)
984
+ self.symbol_display_map.setdefault(best_symbol, display_symbol)
985
+
986
+ if current_checksum and best_checksum and current_checksum == best_checksum:
987
+ message = (
988
+ f"Maintained allocation in {display_symbol} (~{target_apy:.2%} APY)."
989
+ )
990
+ if redeploy_tokens:
991
+ message = (
992
+ f"{message} Existing position remains optimal versus alternatives."
993
+ )
994
+ return (True, message, False)
995
+
996
+ reserve_wei = self.GAS_MAXIMUM * (10 ** self.hype_token_info.get("decimals"))
997
+ previous_apy = float(self.current_avg_apy or 0.0)
998
+ delta_apy = target_apy - previous_apy if previous_apy else target_apy
999
+
1000
+ policy_mode = (
1001
+ self.rotation_policy
1002
+ if self.rotation_policy
1003
+ else self.ROTATION_POLICY.lower()
1004
+ )
1005
+ summary_df = (
1006
+ self.last_summary
1007
+ if isinstance(self.last_summary, pd.DataFrame)
1008
+ else pd.DataFrame()
1009
+ )
1010
+ hys_hours = max(
1011
+ 1,
1012
+ int(
1013
+ self.hys_dwell_hours
1014
+ if self.hys_dwell_hours
1015
+ else self.HYSTERESIS_DWELL_HOURS
1016
+ ),
1017
+ )
1018
+ hys_z = float(self.hys_z if self.hys_z else self.HYSTERESIS_Z)
1019
+ rotation_tx_cost = float(
1020
+ self.rotation_tx_cost if self.rotation_tx_cost else self.ROTATION_TX_COST
1021
+ )
1022
+
1023
+ short_circuit_triggered = (
1024
+ self.APY_SHORT_CIRCUIT_THRESHOLD is not None
1025
+ and delta_apy > self.APY_SHORT_CIRCUIT_THRESHOLD
1026
+ )
1027
+ deny_reasons = []
1028
+ rotation_reason = None
1029
+ should_rotate = False
1030
+
1031
+ if short_circuit_triggered:
1032
+ should_rotate = True
1033
+ rotation_reason = f"Short-circuit triggered by {delta_apy:.2%} APY edge."
1034
+ elif policy_mode == "hysteresis":
1035
+ if summary_df.empty:
1036
+ deny_reasons.append(
1037
+ "Hysteresis check skipped: no tournament summary available."
1038
+ )
1039
+ else:
1040
+ best_row_df = summary_df.loc[summary_df["asset"] == best_symbol]
1041
+ cur_row_df = (
1042
+ summary_df.loc[summary_df["asset"] == self.current_symbol]
1043
+ if self.current_symbol
1044
+ else pd.DataFrame()
1045
+ )
1046
+ if best_row_df.empty:
1047
+ deny_reasons.append(
1048
+ f"Unable to locate {display_symbol} in tournament summary."
1049
+ )
1050
+ else:
1051
+ best_row = best_row_df.iloc[0]
1052
+ best_E = float(best_row.get("mean", 0.0))
1053
+ best_SD = float(best_row.get("std", 0.0))
1054
+
1055
+ if not cur_row_df.empty:
1056
+ cur_row = cur_row_df.iloc[0]
1057
+ cur_E = float(cur_row.get("mean", 0.0))
1058
+ cur_SD = float(cur_row.get("std", 0.0))
1059
+ else:
1060
+ cur_hourly = float(
1061
+ self._apy_to_hourly(previous_apy)
1062
+ if previous_apy
1063
+ else best_hourly
1064
+ )
1065
+ cur_E = math.log1p(cur_hourly) * self.HORIZON_HOURS
1066
+ cur_SD = 0.0
1067
+
1068
+ edge_cum_log = best_E - cur_E
1069
+ sigma_delta = math.sqrt(
1070
+ max(0.0, best_SD * best_SD + cur_SD * cur_SD)
1071
+ )
1072
+ cost_log_mag = abs(math.log1p(-rotation_tx_cost))
1073
+ amortized_cost = cost_log_mag * (
1074
+ self.HORIZON_HOURS / max(1.0, float(hys_hours))
1075
+ )
1076
+ hurdle = amortized_cost + hys_z * sigma_delta
1077
+
1078
+ if edge_cum_log > hurdle:
1079
+ should_rotate = True
1080
+ rotation_reason = (
1081
+ f"Hysteresis edge {edge_cum_log:.4f} > hurdle {hurdle:.4f}."
1082
+ )
1083
+ else:
1084
+ deny_reasons.append(
1085
+ f"Hysteresis band holds: edge {edge_cum_log:.4f} ≤ hurdle {hurdle:.4f}."
1086
+ )
1087
+ else:
1088
+ rotation_allowed = True
1089
+ if previous_apy and delta_apy <= self.APY_REBALANCE_THRESHOLD:
1090
+ rotation_allowed = False
1091
+ deny_reasons.append(
1092
+ f"APY improvement ({delta_apy:.2%}) below {self.APY_REBALANCE_THRESHOLD:.2%} threshold."
1093
+ )
1094
+
1095
+ last_rotation = await self._get_last_rotation_time(
1096
+ wallet_address=self._get_strategy_wallet_address(),
1097
+ )
1098
+ cooldown_notice = None
1099
+ if rotation_allowed and last_rotation is not None:
1100
+ elapsed = timezone.now() - last_rotation
1101
+ if elapsed < self.ROTATION_COOLDOWN:
1102
+ rotation_allowed = False
1103
+ remaining_hours = max(
1104
+ 0, (self.ROTATION_COOLDOWN - elapsed).total_seconds() / 3600
1105
+ )
1106
+ cooldown_notice = (
1107
+ f"Rotation cooldown active; ~{remaining_hours:.1f}h remaining."
1108
+ )
1109
+
1110
+ if rotation_allowed:
1111
+ should_rotate = True
1112
+ if previous_apy:
1113
+ rotation_reason = (
1114
+ f"APY edge {delta_apy:.2%} cleared threshold and cooldown."
1115
+ )
1116
+ else:
1117
+ rotation_reason = "Initial deployment into best-performing asset."
1118
+ else:
1119
+ if cooldown_notice:
1120
+ deny_reasons.append(cooldown_notice)
1121
+
1122
+ if not should_rotate:
1123
+ current_display = (
1124
+ self._display_symbol(self.current_symbol)
1125
+ if self.current_symbol
1126
+ else display_symbol
1127
+ )
1128
+ baseline_apy = previous_apy if previous_apy else target_apy
1129
+ message_parts = []
1130
+ if deny_reasons:
1131
+ message_parts.append("NO UPDATE was performed.")
1132
+ message_parts.append(" ".join(deny_reasons))
1133
+
1134
+ if current_checksum:
1135
+ message_parts.append(
1136
+ f"Maintained allocation in {current_display} (~{baseline_apy:.2%} APY)."
1137
+ )
1138
+
1139
+ if best_checksum:
1140
+ message_parts.append(
1141
+ f"The best symbol would be: {display_symbol} at ~{target_apy:.2%} APY."
1142
+ )
1143
+ if policy_mode == "hysteresis":
1144
+ message_parts.append(
1145
+ f"Hysteresis parameters: dwell={hys_hours}h, z={hys_z:.2f}."
1146
+ )
1147
+ return (
1148
+ True,
1149
+ " ".join(part for part in message_parts if part).strip(),
1150
+ False,
1151
+ )
1152
+
1153
+ actions, total_target, _, kept_hype = await self._allocate_to_target(
1154
+ best_token,
1155
+ target_symbol=display_symbol,
1156
+ target_apy=target_apy,
1157
+ hype_reserve_wei=reserve_wei,
1158
+ lend_operation="update",
1159
+ )
1160
+ self.kept_hype_tokens = kept_hype
1161
+
1162
+ base_message = (
1163
+ f"Aligned supplies into {display_symbol} (~{target_apy:.2%} APY). "
1164
+ f"Current lent balance {total_target:.4f} {display_symbol}."
1165
+ )
1166
+ if rotation_reason:
1167
+ base_message = f"{base_message} {rotation_reason}"
1168
+ elif policy_mode == "hysteresis":
1169
+ base_message = f"{base_message} Hysteresis rotation with dwell={hys_hours}h, z={hys_z:.2f}."
1170
+
1171
+ should_notify_user = False
1172
+ if actions:
1173
+ base_message = f"{base_message} Actions: {'; '.join(actions)}."
1174
+ should_notify_user = True
1175
+ else:
1176
+ base_message = f"{base_message} No rebalancing required."
1177
+
1178
+ base_message = (
1179
+ f"{base_message} HYPE buffer at {self.kept_hype_tokens:.2f} tokens."
1180
+ )
1181
+
1182
+ return (True, base_message, should_notify_user)
1183
+
1184
+ async def _allocate_to_target(
1185
+ self,
1186
+ target_token: dict[str, Any],
1187
+ *,
1188
+ target_symbol: str,
1189
+ target_apy: float,
1190
+ hype_reserve_wei: int,
1191
+ lend_operation: Literal["deposit", "update"] = "update",
1192
+ ) -> tuple[list[dict[str, Any]], float, float, float]:
1193
+ actions = []
1194
+ actions.extend(await self._unwind_other_lends(target_token))
1195
+
1196
+ align_actions, kept_hype = await self._align_wallet_balances(
1197
+ target_token, hype_reserve_wei=hype_reserve_wei
1198
+ )
1199
+ actions.extend(align_actions)
1200
+
1201
+ lent_tokens = await self._lend_available_balance(
1202
+ target_token, operation=lend_operation
1203
+ )
1204
+ if lent_tokens > 0:
1205
+ actions.append(f"Lent {lent_tokens:.4f} {target_symbol}")
1206
+
1207
+ try:
1208
+ target_checksum = Web3.to_checksum_address(
1209
+ self._get_token_address(target_token)
1210
+ )
1211
+ except Exception:
1212
+ target_checksum = None
1213
+
1214
+ total_target = 0.0
1215
+ if target_checksum:
1216
+ new_positions = await self._get_lent_positions()
1217
+ total_target_wei = sum(
1218
+ entry["amount_wei"]
1219
+ for entry in new_positions.values()
1220
+ if Web3.to_checksum_address(entry["asset"]["underlying"])
1221
+ == target_checksum
1222
+ )
1223
+ total_target = float(total_target_wei) / (
1224
+ 10 ** target_token.get("decimals", 18)
1225
+ )
1226
+
1227
+ self.current_token = target_token
1228
+ self.current_symbol = target_token.get("symbol")
1229
+ self.current_avg_apy = target_apy
1230
+ self.kept_hype_tokens = kept_hype
1231
+
1232
+ await self._hydrate_position_from_chain()
1233
+
1234
+ return actions, total_target, lent_tokens, kept_hype
1235
+
1236
+ async def _lend_available_balance(
1237
+ self,
1238
+ target_token: dict[str, Any],
1239
+ *,
1240
+ operation: Literal["deposit", "update"] = "update",
1241
+ ) -> float:
1242
+ if not self._get_token_address(target_token):
1243
+ return 0.0
1244
+
1245
+ snapshot = await self._get_assets_snapshot(force_refresh=True)
1246
+ balances = await self._wallet_balances_from_snapshot(snapshot)
1247
+
1248
+ target_checksum = self._token_checksum(target_token)
1249
+ if not target_checksum:
1250
+ return 0.0
1251
+
1252
+ entry = balances.get(target_checksum)
1253
+ original_amount_wei = int(entry.get("wei") or 0) if entry else 0
1254
+ if original_amount_wei <= 0:
1255
+ return 0.0
1256
+
1257
+ amount_wei = int(original_amount_wei)
1258
+ if operation == "deposit":
1259
+ amount_tokens = float(amount_wei) / (10 ** target_token.get("decimals"))
1260
+ has_headroom = await self._has_supply_cap_headroom(
1261
+ target_token, amount_tokens
1262
+ )
1263
+ if not has_headroom:
1264
+ return 0.0
1265
+
1266
+ # TODO: await favourable fees
1267
+ max_attempts = 3
1268
+ for attempt in range(max_attempts):
1269
+ try:
1270
+ result, message = await self.hyperlend_adapter.lend(
1271
+ underlying_token=target_token.get("address"),
1272
+ chain_id=int(self.hype_token_info.get("chain").get("id")),
1273
+ qty=amount_wei,
1274
+ native=False,
1275
+ )
1276
+ if result:
1277
+ self._invalidate_assets_snapshot()
1278
+ amount_lent = amount_wei
1279
+ break
1280
+ except Exception as e:
1281
+ message = str(e)
1282
+ if (
1283
+ "panic code 0x11" in message
1284
+ and amount_wei > 0
1285
+ and attempt < max_attempts - 1
1286
+ ):
1287
+ reduction = max(amount_wei // 10, 1)
1288
+ amount_wei -= reduction
1289
+ continue
1290
+
1291
+ return 0.0
1292
+
1293
+ if amount_lent <= 0:
1294
+ return 0.0
1295
+
1296
+ return float(amount_lent) / (10 ** target_token.get("decimals"))
1297
+
1298
+ async def _align_wallet_balances(
1299
+ self, target_token: dict[str, Any], *, hype_reserve_wei: int
1300
+ ) -> tuple[list[dict[str, Any]], float]:
1301
+ snapshot = await self._get_assets_snapshot(force_refresh=True)
1302
+ balances = await self._wallet_balances_from_snapshot(snapshot)
1303
+ if not balances:
1304
+ return [], 0.0
1305
+
1306
+ actions = []
1307
+ target_checksum = self._token_checksum(target_token)
1308
+ hype_checksum = self._token_checksum(self.hype_token_info)
1309
+
1310
+ for checksum, entry in balances.items():
1311
+ if checksum == "native":
1312
+ continue
1313
+ if checksum == target_checksum or checksum == hype_checksum:
1314
+ continue
1315
+ asset = entry.get("asset")
1316
+ if not asset or not asset.get("is_stablecoin"):
1317
+ continue
1318
+ token = entry.get("token")
1319
+ if not token or not isinstance(token, dict):
1320
+ continue
1321
+ token["address"] = checksum
1322
+
1323
+ entry_decimals = entry.get("decimals")
1324
+ if entry_decimals is not None:
1325
+ token["decimals"] = entry_decimals
1326
+ balance_wei = int(entry.get("wei") or 0)
1327
+ if balance_wei <= 0:
1328
+ continue
1329
+
1330
+ min_token_swap_wei = self._amount_to_wei(
1331
+ token, Decimal(str(self.MIN_STABLE_SWAP_TOKENS))
1332
+ )
1333
+ if balance_wei <= min_token_swap_wei:
1334
+ continue
1335
+
1336
+ swap_action = await self._execute_swap(
1337
+ from_token_info=token,
1338
+ to_token_info=target_token,
1339
+ amount_wei=balance_wei,
1340
+ slippage=DEFAULT_SLIPPAGE,
1341
+ )
1342
+ if swap_action:
1343
+ actions.append(swap_action)
1344
+ continue
1345
+
1346
+ balance_tokens = float(entry.get("tokens") or 0)
1347
+ if balance_tokens <= 0:
1348
+ continue
1349
+
1350
+ try:
1351
+ (
1352
+ transfer_success,
1353
+ _,
1354
+ ) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
1355
+ token_id=token.get("token_id"),
1356
+ amount=balance_tokens,
1357
+ strategy_name=self.name,
1358
+ )
1359
+ except Exception:
1360
+ continue
1361
+
1362
+ if transfer_success:
1363
+ actions.append(
1364
+ f"Transferred {balance_tokens:.4f} {token.get('symbol')} to main wallet"
1365
+ )
1366
+ self._invalidate_assets_snapshot()
1367
+
1368
+ kept_tokens = float(balances.get("native", {}).get("tokens") or 0)
1369
+
1370
+ return actions, kept_tokens
1371
+
1372
+ async def _unwind_other_lends(
1373
+ self, target_token: dict[str, Any]
1374
+ ) -> list[dict[str, Any]]:
1375
+ positions = await self._get_lent_positions()
1376
+ if not positions:
1377
+ return []
1378
+
1379
+ actions = []
1380
+ try:
1381
+ target_checksum = self._token_checksum(target_token)
1382
+ except Exception:
1383
+ return actions
1384
+
1385
+ for address, entry in positions.items():
1386
+ token = entry.get("token")
1387
+ amount_wei = entry.get("amount_wei", 0)
1388
+ if not token or amount_wei <= 0:
1389
+ continue
1390
+
1391
+ try:
1392
+ checksum = Web3.to_checksum_address(address)
1393
+ except Exception:
1394
+ continue
1395
+ if checksum == target_checksum:
1396
+ continue
1397
+ try:
1398
+ chain_code = self.hype_token_info.get("chain", {}).get(
1399
+ "code", "hyperevm"
1400
+ )
1401
+ underlying_token_address = self._get_token_address(token, chain_code)
1402
+ if not underlying_token_address:
1403
+ continue
1404
+ # TODO: await favourable fees
1405
+ await self.hyperlend_adapter.unlend(
1406
+ underlying_token=underlying_token_address,
1407
+ qty=int(amount_wei),
1408
+ chain_id=int(self.hype_token_info.get("chain").get("id")),
1409
+ native=False,
1410
+ )
1411
+ self._invalidate_assets_snapshot()
1412
+ human = float(amount_wei) / (10 ** token.get("decimals"))
1413
+ actions.append(
1414
+ f"Unwound {human:.4f} {token.get('symbol')} from HyperLend"
1415
+ )
1416
+ except Exception:
1417
+ continue
1418
+
1419
+ return actions
1420
+
1421
+ async def _get_last_rotation_time(self, wallet_address: str) -> datetime | None:
1422
+ success, data = await self.ledger_adapter.get_strategy_latest_transactions(
1423
+ wallet_address=self._get_strategy_wallet_address(),
1424
+ )
1425
+ if success is False:
1426
+ return None
1427
+ for transaction in data.get("transactions", []):
1428
+ op_data = transaction.get("op_data", {})
1429
+ if op_data.get("type") in {"LEND", "SWAP"}:
1430
+ created_str = transaction.get("created")
1431
+ if not created_str:
1432
+ continue
1433
+ try:
1434
+ dt = datetime.fromisoformat(created_str.replace("Z", "+00:00"))
1435
+ if dt.tzinfo is None:
1436
+ dt = dt.replace(tzinfo=UTC)
1437
+ return dt
1438
+ except (ValueError, AttributeError):
1439
+ continue
1440
+ return None
1441
+
1442
+ async def _select_best_stable_asset(
1443
+ self,
1444
+ lookback_hours: int = DEFAULT_LOOKBACK_HOURS,
1445
+ *,
1446
+ required_underlying_tokens=None,
1447
+ operation: Literal["deposit", "update", "quote"] = "update",
1448
+ exclude_addresses=None,
1449
+ allow_rotation_without_current=False,
1450
+ ) -> dict[str, Any] | None:
1451
+ excluded = (
1452
+ {addr.lower() for addr in exclude_addresses}
1453
+ if exclude_addresses
1454
+ else set[Any]()
1455
+ )
1456
+ current_token = self.current_token
1457
+ current_symbol = self.current_symbol
1458
+
1459
+ current_checksum_value = (
1460
+ self._token_checksum(current_token) if current_token else None
1461
+ )
1462
+ current_checksum_lower = (
1463
+ current_checksum_value.lower() if current_checksum_value else None
1464
+ )
1465
+ current_excluded = current_checksum_lower and current_checksum_lower in excluded
1466
+ allow_current_fallback = (
1467
+ operation != "deposit"
1468
+ and current_token is not None
1469
+ and not current_excluded
1470
+ )
1471
+
1472
+ _, stable_markets = await self.hyperlend_adapter.get_stable_markets(
1473
+ chain_id=self.hype_token_info.get("chain").get("id"),
1474
+ required_underlying_tokens=required_underlying_tokens,
1475
+ buffer_bps=self.SUPPLY_CAP_BUFFER_BPS,
1476
+ min_buffer_tokens=self.SUPPLY_CAP_MIN_BUFFER_TOKENS,
1477
+ is_stable_symbol=True,
1478
+ )
1479
+ filtered_notes = stable_markets.get("notes", [])
1480
+ filtered_map = stable_markets.get("markets", {})
1481
+
1482
+ if excluded:
1483
+ pruned = {}
1484
+ for addr, entry in filtered_map.items():
1485
+ try:
1486
+ checksum = Web3.to_checksum_address(addr)
1487
+ except Exception:
1488
+ checksum = addr
1489
+ if str(checksum).lower() in excluded:
1490
+ continue
1491
+ pruned[addr] = entry
1492
+ filtered_map = pruned
1493
+
1494
+ if (
1495
+ allow_rotation_without_current
1496
+ and current_token
1497
+ and current_checksum_lower
1498
+ and not current_excluded
1499
+ ):
1500
+ existing_addresses = set[str]()
1501
+ for addr in filtered_map.keys():
1502
+ try:
1503
+ existing_addresses.add(Web3.to_checksum_address(addr).lower())
1504
+ except Exception:
1505
+ existing_addresses.add(str(addr).lower())
1506
+ if current_checksum_lower not in existing_addresses:
1507
+ try:
1508
+ _, current_entry = await self.hyperlend_adapter.get_market_entry(
1509
+ chain_id=self.hype_token_info.get("chain").get("id"),
1510
+ token_address=current_checksum_value,
1511
+ )
1512
+ except Exception:
1513
+ current_entry = None
1514
+ if current_entry:
1515
+ filtered_map[current_token.get("address")] = current_entry
1516
+ filtered_notes.append(
1517
+ f"Included capped market {current_symbol or current_token.get('symbol') or 'unknown'} for rotation comparison."
1518
+ )
1519
+
1520
+ if not filtered_map:
1521
+ if filtered_notes:
1522
+ truncated = "; ".join(filtered_notes[:3])
1523
+ if len(filtered_notes) > 3:
1524
+ truncated += f"{truncated} ..."
1525
+ if allow_current_fallback and current_token:
1526
+ fallback_symbol = current_symbol or current_token.get("symbol")
1527
+ fallback_hourly = self._apy_to_hourly(
1528
+ float(self.current_avg_apy or 0.0)
1529
+ )
1530
+ if not current_token.get("address"):
1531
+ if not current_checksum_value:
1532
+ return None
1533
+ current_token["address"] = current_checksum_value
1534
+ return (current_token, fallback_symbol, fallback_hourly)
1535
+ return None
1536
+
1537
+ self.symbol_display_map = {}
1538
+ filtered = []
1539
+ for addr, entry in filtered_map.items():
1540
+ symbol_canonical = entry.get("symbol_canonical")
1541
+ if not symbol_canonical:
1542
+ raw_symbol = entry.get("symbol") or entry.get("display_symbol")
1543
+ symbol_canonical = (
1544
+ self._normalize_symbol(raw_symbol) if raw_symbol else None
1545
+ )
1546
+ if not symbol_canonical:
1547
+ continue
1548
+ display_symbol = (
1549
+ entry.get("display_symbol") or entry.get("symbol") or symbol_canonical
1550
+ )
1551
+ self.symbol_display_map[symbol_canonical] = str(display_symbol)
1552
+ filtered.append((addr, symbol_canonical))
1553
+
1554
+ histories = await asyncio.gather(
1555
+ *[
1556
+ self.hyperlend_adapter.get_lend_rate_history(
1557
+ chain_id=self.hype_token_info.get("chain").get("id"),
1558
+ token_address=addr,
1559
+ lookback_hours=lookback_hours,
1560
+ )
1561
+ for addr, _ in filtered
1562
+ ],
1563
+ return_exceptions=True,
1564
+ )
1565
+
1566
+ records = []
1567
+ symbol_map = {}
1568
+ for (addr, symbol), history in zip(filtered, histories, strict=False):
1569
+ label = symbol or addr
1570
+ symbol_map[label] = addr
1571
+ if isinstance(history, Exception):
1572
+ self.logger.warning(
1573
+ f"Exception fetching rate history for {label} ({addr}): {history}"
1574
+ )
1575
+ continue
1576
+ history_status = history[0]
1577
+ if not history_status:
1578
+ continue
1579
+ history_data = history[1]
1580
+ for row in history_data.get("rate_history", []):
1581
+ ts_ms = row.get("timestamp_ms")
1582
+ if ts_ms is None:
1583
+ continue
1584
+ apr = row.get("supply_apr")
1585
+ apy = row.get("supply_apy")
1586
+ rate_hourly = None
1587
+ if isinstance(apr, (int, float)) and not math.isnan(apr):
1588
+ rate_hourly = np.expm1(np.log1p(apr) / (365 * 24))
1589
+ elif isinstance(apy, (int, float)) and not math.isnan(apy):
1590
+ rate_hourly = (1.0 + apy) ** (1 / (365 * 24)) - 1.0
1591
+ records.append(
1592
+ {
1593
+ "timestamp": pd.to_datetime(ts_ms, unit="ms", utc=True),
1594
+ "asset": label,
1595
+ "supplyAPR": apr,
1596
+ "rate_hourly": rate_hourly,
1597
+ }
1598
+ )
1599
+ if not records:
1600
+ self.last_summary = None
1601
+ self.last_dominance = None
1602
+ self.last_samples = None
1603
+ return None
1604
+
1605
+ rates_df = pd.DataFrame(records)
1606
+ try:
1607
+ wide = self._prep_rates(rates_df)
1608
+ except Exception as e:
1609
+ self.logger.error(f"Error preparing rates: {e}")
1610
+ self.last_summary = None
1611
+ self.last_dominance = None
1612
+ self.last_samples = None
1613
+ return None
1614
+
1615
+ if wide.empty or wide.shape[1] == 0:
1616
+ self.last_summary = None
1617
+ self.last_dominance = None
1618
+ self.last_samples = None
1619
+ return None
1620
+
1621
+ if self.TOURNAMENT_MODE == "joint":
1622
+ summary, dominance, samples = self._tournament(
1623
+ wide,
1624
+ horizon_h=self.HORIZON_HOURS,
1625
+ block_len=self.BLOCK_LEN,
1626
+ trials=self.TRIALS,
1627
+ halflife_days=self.HALFLIFE_DAYS,
1628
+ seed=self.SEED,
1629
+ )
1630
+ else:
1631
+ summary, dominance, samples = self._tournament_independent(
1632
+ wide,
1633
+ horizon_h=self.HORIZON_HOURS,
1634
+ block_len=self.BLOCK_LEN,
1635
+ trials=self.TRIALS,
1636
+ halflife_days=self.HALFLIFE_DAYS,
1637
+ seed=self.SEED,
1638
+ )
1639
+
1640
+ self.last_summary = summary
1641
+ self.last_dominance = dominance
1642
+ self.last_samples = samples
1643
+
1644
+ if summary.empty:
1645
+ if allow_current_fallback and current_token:
1646
+ fallback_hourly = self._apy_to_hourly(
1647
+ float(self.current_avg_apy or 0.0)
1648
+ )
1649
+ fallback_symbol = current_symbol or current_token.get("symbol")
1650
+ fallback_address = (
1651
+ current_token.get("address") or current_checksum_value
1652
+ )
1653
+ if not fallback_address:
1654
+ return None
1655
+ if not current_token.get("address"):
1656
+ current_token["address"] = fallback_address
1657
+ return (current_token, fallback_symbol, fallback_hourly)
1658
+ return None
1659
+
1660
+ max_candidates = min(self.MAX_CANDIDATES, len(summary))
1661
+ for i in range(max_candidates):
1662
+ top_row = summary.iloc[i]
1663
+ top_symbol = top_row["asset"]
1664
+
1665
+ current_candidate = None
1666
+ if current_symbol:
1667
+ current_row_df = summary.loc[summary["asset"] == current_symbol]
1668
+ if not current_row_df.empty:
1669
+ current_candidate = await self._make_candidate(
1670
+ current_symbol,
1671
+ current_row_df.iloc[0],
1672
+ symbol_map,
1673
+ current_token,
1674
+ current_symbol,
1675
+ )
1676
+ elif allow_current_fallback and current_token:
1677
+ hourly = self._apy_to_hourly(float(self.current_avg_apy or 0.0))
1678
+ if not current_token.get("address"):
1679
+ if not current_checksum_value:
1680
+ current_candidate = None
1681
+ else:
1682
+ current_token["address"] = current_checksum_value
1683
+ current_candidate = (current_token, current_symbol, hourly)
1684
+ else:
1685
+ current_candidate = (current_token, current_symbol, hourly)
1686
+
1687
+ can_rotate = True
1688
+ if (
1689
+ required_underlying_tokens is not None
1690
+ and required_underlying_tokens > 0
1691
+ and current_symbol
1692
+ and current_candidate is not None
1693
+ ):
1694
+ current_row_df = summary.loc[summary["asset"] == current_symbol]
1695
+ if current_row_df.empty:
1696
+ if allow_current_fallback and (
1697
+ current_excluded or allow_rotation_without_current
1698
+ ):
1699
+ current_p = 0.0
1700
+ else:
1701
+ can_rotate = False
1702
+ current_p = 0.0
1703
+ else:
1704
+ current_p = float(current_row_df.iloc[0].get("p_best", 0.0) or 0.0)
1705
+ if can_rotate:
1706
+ top_p = float(top_row.get("p_best", 0.0) or 0.0)
1707
+ if current_p > 0:
1708
+ can_rotate = top_p > max(
1709
+ current_p, self.P_BEST_ROTATION_THRESHOLD
1710
+ )
1711
+ else:
1712
+ can_rotate = top_p > self.P_BEST_ROTATION_THRESHOLD
1713
+ if not can_rotate:
1714
+ return current_candidate
1715
+
1716
+ candidate = await self._make_candidate(
1717
+ top_symbol, top_row, symbol_map, current_token, current_symbol
1718
+ )
1719
+
1720
+ if candidate:
1721
+ return candidate
1722
+ return current_candidate
1723
+
1724
+ async def _make_candidate(
1725
+ self,
1726
+ symbol: str,
1727
+ row: pd.Series,
1728
+ symbol_map: dict[str, str],
1729
+ current_token: dict[str, Any] | None = None,
1730
+ current_symbol: str | None = None,
1731
+ ):
1732
+ address = symbol_map.get(symbol)
1733
+ token = None
1734
+ if address:
1735
+ try:
1736
+ success, token = await self.token_adapter.get_token(address.lower())
1737
+ except Exception:
1738
+ token = None
1739
+ if not success:
1740
+ token = None
1741
+ if token is None and current_token is None and symbol == current_symbol:
1742
+ token = current_token
1743
+ if not token:
1744
+ return None
1745
+ if not address:
1746
+ address = token.get("address") if token else None
1747
+ if not address:
1748
+ return None
1749
+ token["address"] = address
1750
+ hourly_rate = self._log_yield_to_hourly(float(row.get("mean", 0.0) or 0.0))
1751
+ return (token, symbol, hourly_rate)
1752
+
1753
+ def _prep_rates(self, df: pd.DataFrame) -> pd.DataFrame:
1754
+ df = self._coerce_rates_df(df).copy()
1755
+ df["ts"] = pd.to_datetime(df["timestamp"]).dt.floor("h")
1756
+ wide = (
1757
+ df.pivot_table(
1758
+ index="ts", columns="asset", values="rate_hourly", aggfunc="mean"
1759
+ )
1760
+ .sort_index()
1761
+ .dropna(axis=0, how="any")
1762
+ )
1763
+ return wide
1764
+
1765
+ def _coerce_rates_df(self, df: pd.DataFrame) -> pd.DataFrame:
1766
+ g = df.copy()
1767
+ if "timestamp" not in g.columns:
1768
+ if "ts" in g.columns:
1769
+ g = g.rename(columns={"ts": "timestamp"})
1770
+ else:
1771
+ raise KeyError("Expected a 'timestamp' column.")
1772
+ if "asset" not in g.columns:
1773
+ if "symbol" in g.columns:
1774
+ g = g.rename(columns={"symbol": "asset"})
1775
+ else:
1776
+ raise KeyError("Expected an 'asset' (or 'symbol') column.")
1777
+ if "rate_hourly" not in g.columns:
1778
+ if "supplyAPR" in g.columns:
1779
+ g["rate_hourly"] = np.expm1(np.log1p(g["supplyAPR"]) / (365 * 24))
1780
+ elif "supplyAPY" in g.columns:
1781
+ g["rate_hourly"] = (1.0 + g["supplyAPY"]) ** (1 / (365 * 24)) - 1.0
1782
+ else:
1783
+ raise KeyError(
1784
+ "Need 'rate_hourly' or one of 'supplyAPR'/'supplyAPY' to derive it."
1785
+ )
1786
+ g["timestamp"] = pd.to_datetime(g["timestamp"], utc=True)
1787
+ return g
1788
+
1789
+ def _tournament(
1790
+ self,
1791
+ wide: pd.DataFrame,
1792
+ horizon_h: int = None,
1793
+ block_len: int | None = None,
1794
+ trials: int | None = None,
1795
+ halflife_days: float | None = None,
1796
+ seed: int | None = None,
1797
+ ):
1798
+ if horizon_h is None:
1799
+ horizon_h = self.HORIZON_HOURS
1800
+ if block_len is None:
1801
+ block_len = self.BLOCK_LEN
1802
+ if trials is None:
1803
+ trials = self.TRIALS
1804
+ if halflife_days is None:
1805
+ halflife_days = self.HALFLIFE_DAYS
1806
+ if seed is None:
1807
+ seed = self.SEED
1808
+
1809
+ wide = wide.copy()
1810
+ assets = wide.columns.to_list()
1811
+ arr = wide.values
1812
+
1813
+ w = self.recency_weights(wide.index, halflife_days=halflife_days)
1814
+ rng = np.random.default_rng(seed=seed)
1815
+ A = arr.shape[1]
1816
+
1817
+ wins = np.zeros(A, dtype=int)
1818
+ all_trial_log_returns = np.empty((trials, A))
1819
+ for t in range(trials):
1820
+ horizon_log_returns = self.sample_sequence_block_bootstrap(
1821
+ arr, horizon_h, block_len, start_weights=w, rng=rng
1822
+ )
1823
+ all_trial_log_returns[t] = horizon_log_returns
1824
+ wins[np.argmax(horizon_log_returns)] += 1
1825
+
1826
+ p_best = wins / trials
1827
+ q05 = np.quantile(all_trial_log_returns, 0.05, axis=0)
1828
+ mean = all_trial_log_returns.mean(axis=0)
1829
+ std = all_trial_log_returns.std(axis=0)
1830
+
1831
+ dom = np.zeros((A, A))
1832
+ for i in range(A):
1833
+ for j in range(A):
1834
+ if i == j:
1835
+ continue
1836
+ dom[i, j] = np.mean(
1837
+ all_trial_log_returns[:, i] > all_trial_log_returns[:, j]
1838
+ )
1839
+
1840
+ summary = pd.DataFrame(
1841
+ {
1842
+ "asset": assets,
1843
+ "p_best": p_best,
1844
+ "q05": q05,
1845
+ "mean": mean,
1846
+ "std": std,
1847
+ }
1848
+ ).sort_values(
1849
+ ["p_best", "q05", "mean"],
1850
+ ascending=[False, False, False],
1851
+ )
1852
+
1853
+ dominance = pd.DataFrame(dom, index=assets, columns=assets)
1854
+ return summary, dominance, all_trial_log_returns
1855
+
1856
+ def _tournament_independent(
1857
+ self,
1858
+ wide: pd.DataFrame,
1859
+ horizon_h: int = None,
1860
+ block_len: int | None = None,
1861
+ trials: int | None = None,
1862
+ halflife_days: float | None = None,
1863
+ seed: int | None = None,
1864
+ ):
1865
+ if horizon_h is None:
1866
+ horizon_h = self.HORIZON_HOURS
1867
+ if block_len is None:
1868
+ block_len = self.BLOCK_LEN
1869
+ if trials is None:
1870
+ trials = self.TRIALS
1871
+ if halflife_days is None:
1872
+ halflife_days = self.HALFLIFE_DAYS
1873
+ if seed is None:
1874
+ seed = self.SEED
1875
+
1876
+ wide = wide.copy()
1877
+ assets = wide.columns.to_list()
1878
+ arr = wide.values
1879
+ w = self.recency_weights(wide.index, halflife_days=halflife_days)
1880
+ rng = np.random.default_rng(seed=seed)
1881
+ A = arr.shape[1]
1882
+
1883
+ wins = np.zeros(A, dtype=int)
1884
+ all_trial_log_returns = np.empty((trials, A), dtype=float)
1885
+
1886
+ for t in range(trials):
1887
+ for i in range(A):
1888
+ all_trial_log_returns[t, i] = self.sample_seq_independent(
1889
+ arr[:, i], horizon_h, block_len, start_weights=w, rng=rng
1890
+ )
1891
+ wins[np.argmax(all_trial_log_returns[t])] += 1
1892
+
1893
+ p_best = wins / trials
1894
+ q05 = np.quantile(all_trial_log_returns, 0.05, axis=0)
1895
+ mean = all_trial_log_returns.mean(axis=0)
1896
+ std = all_trial_log_returns.std(axis=0)
1897
+
1898
+ dom = np.zeros((A, A), dtype=float)
1899
+ for i in range(A):
1900
+ for j in range(A):
1901
+ if i == j:
1902
+ continue
1903
+ dom[i, j] = float(
1904
+ np.mean(all_trial_log_returns[:, i] > all_trial_log_returns[:, j])
1905
+ )
1906
+
1907
+ summary = pd.DataFrame(
1908
+ {
1909
+ "asset": assets,
1910
+ "p_best": p_best,
1911
+ "q05": q05,
1912
+ "mean": mean,
1913
+ "std": std,
1914
+ }
1915
+ ).sort_values(
1916
+ ["p_best", "q05", "mean"],
1917
+ ascending=[False, False, False],
1918
+ )
1919
+
1920
+ dominance = pd.DataFrame(dom, index=assets, columns=assets)
1921
+ return summary, dominance, all_trial_log_returns
1922
+
1923
+ @staticmethod
1924
+ def sample_sequence_block_bootstrap(
1925
+ arr: np.ndarray,
1926
+ horizon_h: int = HORIZON_HOURS,
1927
+ block_len: int = BLOCK_LEN,
1928
+ start_weights: np.ndarray | None = None,
1929
+ rng: np.random.Generator | None = None,
1930
+ ) -> np.ndarray:
1931
+ if rng is None:
1932
+ rng = np.random.default_rng()
1933
+
1934
+ T, _ = arr.shape
1935
+ max_start = T - block_len
1936
+ if max_start < 0:
1937
+ raise ValueError("Not enough rows for chosen block length.")
1938
+
1939
+ starts = np.arange(max_start + 1)
1940
+ if start_weights is None:
1941
+ probs = np.ones_like(starts, dtype=float) / (max_start + 1)
1942
+ else:
1943
+ probs = start_weights[: max_start + 1].astype(float)
1944
+ probs /= probs.sum()
1945
+
1946
+ picked = []
1947
+ need = horizon_h
1948
+ while need > 0:
1949
+ s = rng.choice(starts, p=probs)
1950
+ picked.append(arr[s : s + block_len])
1951
+ need -= block_len
1952
+
1953
+ seq = np.vstack(picked)[:horizon_h]
1954
+ return np.sum(np.log1p(seq), axis=0)
1955
+
1956
+ @staticmethod
1957
+ def sample_seq_independent(
1958
+ col: np.ndarray,
1959
+ horizon_h: int = HORIZON_HOURS,
1960
+ block_len: int = BLOCK_LEN,
1961
+ start_weights: np.ndarray | None = None,
1962
+ rng: np.random.Generator | None = None,
1963
+ ) -> np.ndarray:
1964
+ T = len(col)
1965
+ max_start = T - block_len
1966
+ if max_start < 0:
1967
+ raise ValueError("Not enough rows for chosen BLOCK_LEN.")
1968
+
1969
+ starts = np.arange(max_start + 1)
1970
+ probs = start_weights[: max_start + 1].astype(float)
1971
+ probs /= probs.sum()
1972
+
1973
+ picked: list[np.ndarray] = []
1974
+ need = horizon_h
1975
+ while need > 0:
1976
+ s = rng.choice(starts, p=probs)
1977
+ picked.append(col[s : s + block_len])
1978
+ need -= block_len
1979
+ seq = np.concatenate(picked)[:horizon_h]
1980
+ return float(np.sum(np.log1p(seq)))
1981
+
1982
+ def recency_weights(self, index: pd.DatetimeIndex, halflife_days) -> np.ndarray:
1983
+ ages_h = ((index.max() - index) / pd.Timedelta(hours=1)).to_numpy()
1984
+ lam = math.log(2) / (halflife_days * 24.0)
1985
+ w = np.exp(-lam * ages_h)
1986
+ total = w.sum()
1987
+
1988
+ if total == 0:
1989
+ return np.ones_like(w) / len(w)
1990
+ return w / total
1991
+
1992
+ @staticmethod
1993
+ def _apy_to_hourly(apy: float) -> float:
1994
+ return (1.0 + apy) ** (1 / (365 * 24)) - 1 if apy is not None else 0.0
1995
+
1996
+ @staticmethod
1997
+ def _hourly_to_apy(rate_hourly: float) -> float:
1998
+ return (1.0 + rate_hourly) ** (365 * 24) - 1.0
1999
+
2000
+ @staticmethod
2001
+ def _log_yield_to_hourly(log_yield: float, horizon_h: int = 6) -> float:
2002
+ return float(np.expm1(log_yield / max(horizon_h, 1)))
2003
+
2004
+ async def _get_idle_tokens(self) -> float:
2005
+ snapshot = await self._get_assets_snapshot()
2006
+ balances = await self._wallet_balances_from_snapshot(snapshot)
2007
+ if not balances:
2008
+ return 0.0
2009
+
2010
+ idle_total = 0.0
2011
+ token = self.current_token
2012
+ current_checksum = None
2013
+ if token and token.get("address"):
2014
+ current_checksum = self._token_checksum(token)
2015
+
2016
+ for checksum, entry in balances.items():
2017
+ if checksum == "native":
2018
+ continue
2019
+ include_balance = False
2020
+ if current_checksum and checksum == current_checksum:
2021
+ include_balance = True
2022
+ else:
2023
+ asset = entry.get("asset") or {}
2024
+ symbol = (
2025
+ (asset or {}).get("symbol")
2026
+ or (asset or {}).get("symbol_display")
2027
+ or ""
2028
+ )
2029
+ if self._is_stable_symbol(symbol):
2030
+ include_balance = True
2031
+ if not include_balance:
2032
+ continue
2033
+ idle_total += float(entry.get("tokens") or 0.0)
2034
+ return idle_total
2035
+
2036
+ async def _wallet_balances_from_snapshot(
2037
+ self, snapshot: dict[str, Any]
2038
+ ) -> dict[str, Any]:
2039
+ balances = {}
2040
+ assets = snapshot.get("assets_view", {}).get("assets", [])
2041
+ if assets:
2042
+ for asset in assets:
2043
+ checksum = asset.get("underlying_checksum") or asset.get("underlying")
2044
+ if not checksum:
2045
+ continue
2046
+ try:
2047
+ checksum = Web3.to_checksum_address(checksum)
2048
+ except Exception:
2049
+ continue
2050
+
2051
+ try:
2052
+ success, token = await self.token_adapter.get_token(checksum)
2053
+ if not success or not isinstance(token, dict):
2054
+ continue
2055
+ except Exception:
2056
+ continue
2057
+
2058
+ raw_balance_wei = asset.get("underlying_wallet_balance_wei")
2059
+ try:
2060
+ # Handle both "decimals" (plural) and "decimal" (singular) from API
2061
+ token_decimals = token.get("decimals") or token.get("decimal")
2062
+ asset_decimals = asset.get("decimals") or asset.get("decimal")
2063
+ if token_decimals is not None:
2064
+ decimals = int(token_decimals)
2065
+ elif asset_decimals is not None:
2066
+ decimals = int(asset_decimals)
2067
+ else:
2068
+ decimals = 18
2069
+ except (TypeError, ValueError):
2070
+ decimals = 18
2071
+ scale = 10**decimals
2072
+ if raw_balance_wei is not None:
2073
+ try:
2074
+ balance_wei = int(raw_balance_wei)
2075
+ except (TypeError, ValueError):
2076
+ balance_wei = None
2077
+ else:
2078
+ balance_wei = None
2079
+ if balance_wei is None:
2080
+ balance_decimal_input = Decimal(
2081
+ str(asset.get("underlying_wallet_balance") or 0.0)
2082
+ )
2083
+ balance_wei = int(balance_decimal_input * scale).to_integral_value(
2084
+ rounding=ROUND_DOWN
2085
+ )
2086
+ if balance_wei < 0:
2087
+ balance_wei = 0
2088
+ balance_decimal = (
2089
+ Decimal(balance_wei) / scale if balance_wei else Decimal(0.0)
2090
+ )
2091
+ balance_tokens = float(balance_decimal) if balance_decimal else 0.0
2092
+ if balance_tokens > 0.0:
2093
+ float_decimal = Decimal.from_float(balance_tokens)
2094
+ if float_decimal > balance_decimal:
2095
+ balance_tokens = math.nextafter(balance_tokens, 0.0)
2096
+ while (
2097
+ balance_tokens > 0.0
2098
+ and Decimal.from_float(balance_tokens) > balance_decimal
2099
+ ):
2100
+ balance_tokens = math.nextafter(balance_tokens, 0.0)
2101
+ price = float(asset.get("price_usd") or 0.0)
2102
+ balances[checksum] = {
2103
+ "token": token,
2104
+ "address": checksum,
2105
+ "wei": int(balance_wei),
2106
+ "tokens": balance_tokens,
2107
+ "usd": balance_tokens * price,
2108
+ "asset": asset,
2109
+ "decimals": decimals,
2110
+ }
2111
+ return balances
2112
+
2113
+ async def _status(self) -> StatusDict:
2114
+ if not self.current_token:
2115
+ await self._hydrate_position_from_chain()
2116
+ _, net_deposit = await self.ledger_adapter.get_strategy_net_deposit(
2117
+ wallet_address=self._get_strategy_wallet_address()
2118
+ )
2119
+ snapshot = await self._get_assets_snapshot()
2120
+ lent_positions = await self._get_lent_positions(snapshot)
2121
+ asset_map = (
2122
+ snapshot.get("_by_underlying", {}) if isinstance(snapshot, dict) else {}
2123
+ )
2124
+ wallet_balances = await self._wallet_balances_from_snapshot(snapshot)
2125
+ position_rows = []
2126
+ total_usd = 0.0
2127
+ for entry in lent_positions.values():
2128
+ token = entry.get("token")
2129
+ amount_wei = entry.get("amount_wei", 0)
2130
+ amount = float(amount_wei) / (10 ** token.get("decimals"))
2131
+ asset = entry.get("asset")
2132
+ if not asset:
2133
+ checksum = self._token_checksum(token)
2134
+ asset = asset_map.get(checksum) if checksum else None
2135
+ if asset and asset.get("price_usd") is not None:
2136
+ try:
2137
+ price = float(asset.get("price_usd"))
2138
+ except (TypeError, ValueError):
2139
+ price = 0.0
2140
+ apy = float(asset.get("supply_apy") or 0.0) if asset else 0.0
2141
+ display_symbol = asset.get("symbol_display") if asset else token.symbol
2142
+ if asset and asset.get("supply_usd") is not None:
2143
+ try:
2144
+ balance_usd = float(asset.get("supply_usd"))
2145
+ except (TypeError, ValueError):
2146
+ balance_usd = amount * price
2147
+ else:
2148
+ balance_usd = amount * price
2149
+ total_usd += balance_usd
2150
+ position_rows.append(
2151
+ {
2152
+ "asset": display_symbol,
2153
+ "balance": amount,
2154
+ "apy": apy,
2155
+ "balance_usd": balance_usd,
2156
+ }
2157
+ )
2158
+
2159
+ (
2160
+ success,
2161
+ strategy_hype_balance_wei,
2162
+ ) = await self.balance_adapter.get_balance(
2163
+ token_id=self.hype_token_info.get("token_id"),
2164
+ wallet_address=self._get_strategy_wallet_address(),
2165
+ )
2166
+ hype_price = asset_map.get(WRAPPED_HYPE_ADDRESS, {}).get("price_usd") or 0.0
2167
+ hype_value = 0.0
2168
+ if hype_price and success:
2169
+ hype_value = (
2170
+ strategy_hype_balance_wei
2171
+ / (10 ** self.hype_token_info.get("decimals"))
2172
+ * hype_price
2173
+ )
2174
+
2175
+ idle_value = 0.0
2176
+ idle_tokens = 0.0
2177
+ if self.current_token:
2178
+ current_checksum = self._token_checksum(self.current_token)
2179
+ entry = wallet_balances.get(current_checksum) if current_checksum else None
2180
+ if entry:
2181
+ idle_tokens = entry.get("tokens") or 0.0
2182
+ asset = asset_map.get(current_checksum) if current_checksum else None
2183
+ if asset and asset.get("price_usd") is not None:
2184
+ try:
2185
+ idle_price = float(asset.get("price_usd"))
2186
+ except (TypeError, ValueError):
2187
+ idle_price = 1.0
2188
+ idle_value = idle_tokens * idle_price
2189
+ excludes = {
2190
+ self._token_checksum(entry["token"]) for entry in lent_positions.values()
2191
+ }
2192
+ if self.current_token:
2193
+ excludes.add(self._token_checksum(self.current_token))
2194
+ remaining_tokens = [
2195
+ (value["token"].get("asset_id"), value["usd"])
2196
+ for addr, value in wallet_balances.items()
2197
+ if addr not in excludes
2198
+ ]
2199
+ remaining_usd = sum([usd for _, usd in remaining_tokens])
2200
+ total_portfolio_value = total_usd + idle_value + remaining_usd
2201
+
2202
+ status_payload: dict[str, Any] = {
2203
+ "lent_asset": self.current_symbol,
2204
+ "lent_balance": 0.0,
2205
+ "current_apy": float(self.current_avg_apy or 0.0),
2206
+ "positions": position_rows,
2207
+ "hype_buffer_tokens": strategy_hype_balance_wei
2208
+ / (10 ** self.hype_token_info.get("decimals")),
2209
+ "hype_buffer_usd": hype_value,
2210
+ "idle_tokens": idle_tokens,
2211
+ "idle_usd": idle_value,
2212
+ "other_tokens": remaining_tokens,
2213
+ "other_balance_usd": remaining_usd,
2214
+ "rebalance_threshold": self.APY_REBALANCE_THRESHOLD,
2215
+ "short_circuit_threshold": self.APY_SHORT_CIRCUIT_THRESHOLD,
2216
+ "rotation_cooldown_hours": self.ROTATION_COOLDOWN.total_seconds() / 3600,
2217
+ }
2218
+
2219
+ if position_rows:
2220
+ current_row = next(
2221
+ (row for row in position_rows if row["asset"] == self.current_symbol),
2222
+ position_rows[0],
2223
+ )
2224
+ status_payload["lent_asset"] = self._display_symbol(
2225
+ self.current_symbol or current_row["asset"]
2226
+ )
2227
+ status_payload["lent_balance"] = current_row["balance"]
2228
+ status_payload["current_apy"] = current_row["apy"]
2229
+
2230
+ if self.current_token:
2231
+ status_payload["current_asset_address"] = self.current_token.get("address")
2232
+
2233
+ if self.last_summary is not None and not self.last_summary.empty:
2234
+ top = self.last_summary.iloc[0]
2235
+ best_asset = str(top.get("asset"))
2236
+ expected_hourly = self._log_yield_to_hourly(
2237
+ float(top.get("E_cum_log_yield", 0.0))
2238
+ )
2239
+ status_payload.update(
2240
+ {
2241
+ "best_candidate": self._display_symbol(best_asset),
2242
+ "best_candidate_expected_apy": self._hourly_to_apy(expected_hourly),
2243
+ }
2244
+ )
2245
+
2246
+ return {
2247
+ "portfolio_value": total_portfolio_value,
2248
+ "net_deposit": net_deposit or 0.0,
2249
+ "strategy_status": status_payload,
2250
+ "gas_available": strategy_hype_balance_wei
2251
+ / (10 ** self.hype_token_info.get("decimals")),
2252
+ "gassed_up": self.GAS_MAXIMUM / 3
2253
+ <= strategy_hype_balance_wei / (10 ** self.hype_token_info.get("decimals")),
2254
+ }
2255
+
2256
+ @staticmethod
2257
+ async def policies() -> list[str]:
2258
+ """Return policy strings used to scope on-chain permissions."""
2259
+ return [
2260
+ any_hyperliquid_l1_payload(),
2261
+ any_hyperliquid_user_payload(),
2262
+ hypecore_sentinel_deposit(),
2263
+ await whype_deposit_and_withdraw(),
2264
+ erc20_spender_for_any_token(HYPERLEND_POOL),
2265
+ await hyperlend_supply_and_withdraw(),
2266
+ erc20_spender_for_any_token(ENSO_ROUTER),
2267
+ await enso_swap(),
2268
+ erc20_spender_for_any_token(PRJX_ROUTER),
2269
+ await prjx_swap(),
2270
+ ]