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