wayfinder-paths 0.1.23__py3-none-any.whl → 0.1.25__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 (124) hide show
  1. wayfinder_paths/__init__.py +2 -0
  2. wayfinder_paths/adapters/balance_adapter/adapter.py +250 -0
  3. wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
  4. wayfinder_paths/adapters/balance_adapter/test_adapter.py +0 -11
  5. wayfinder_paths/adapters/boros_adapter/__init__.py +17 -0
  6. wayfinder_paths/adapters/boros_adapter/adapter.py +1574 -0
  7. wayfinder_paths/adapters/boros_adapter/client.py +476 -0
  8. wayfinder_paths/adapters/boros_adapter/manifest.yaml +10 -0
  9. wayfinder_paths/adapters/boros_adapter/parsers.py +88 -0
  10. wayfinder_paths/adapters/boros_adapter/test_adapter.py +460 -0
  11. wayfinder_paths/adapters/boros_adapter/test_golden.py +156 -0
  12. wayfinder_paths/adapters/boros_adapter/types.py +70 -0
  13. wayfinder_paths/adapters/boros_adapter/utils.py +85 -0
  14. wayfinder_paths/adapters/brap_adapter/adapter.py +1 -1
  15. wayfinder_paths/adapters/brap_adapter/manifest.yaml +9 -0
  16. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +161 -26
  17. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +9 -0
  18. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +77 -13
  19. wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +2 -9
  20. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +585 -61
  21. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +47 -68
  22. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +14 -0
  23. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +2 -3
  24. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +17 -21
  25. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +3 -6
  26. wayfinder_paths/adapters/hyperliquid_adapter/test_executor.py +4 -8
  27. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +2 -2
  28. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +7 -0
  29. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +1 -2
  30. wayfinder_paths/adapters/moonwell_adapter/adapter.py +592 -400
  31. wayfinder_paths/adapters/moonwell_adapter/manifest.yaml +14 -0
  32. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +126 -219
  33. wayfinder_paths/adapters/multicall_adapter/__init__.py +7 -0
  34. wayfinder_paths/adapters/multicall_adapter/adapter.py +166 -0
  35. wayfinder_paths/adapters/multicall_adapter/manifest.yaml +5 -0
  36. wayfinder_paths/adapters/multicall_adapter/test_adapter.py +97 -0
  37. wayfinder_paths/adapters/pendle_adapter/README.md +102 -0
  38. wayfinder_paths/adapters/pendle_adapter/__init__.py +7 -0
  39. wayfinder_paths/adapters/pendle_adapter/adapter.py +1992 -0
  40. wayfinder_paths/adapters/pendle_adapter/examples.json +11 -0
  41. wayfinder_paths/adapters/pendle_adapter/manifest.yaml +21 -0
  42. wayfinder_paths/adapters/pendle_adapter/test_adapter.py +666 -0
  43. wayfinder_paths/adapters/pool_adapter/manifest.yaml +6 -0
  44. wayfinder_paths/adapters/token_adapter/examples.json +0 -4
  45. wayfinder_paths/adapters/token_adapter/manifest.yaml +7 -0
  46. wayfinder_paths/conftest.py +24 -17
  47. wayfinder_paths/core/__init__.py +2 -0
  48. wayfinder_paths/core/adapters/BaseAdapter.py +0 -25
  49. wayfinder_paths/core/adapters/models.py +17 -7
  50. wayfinder_paths/core/clients/BRAPClient.py +1 -1
  51. wayfinder_paths/core/clients/TokenClient.py +47 -1
  52. wayfinder_paths/core/clients/WayfinderClient.py +1 -2
  53. wayfinder_paths/core/clients/protocols.py +21 -22
  54. wayfinder_paths/core/clients/test_ledger_client.py +448 -0
  55. wayfinder_paths/core/config.py +12 -0
  56. wayfinder_paths/core/constants/__init__.py +15 -0
  57. wayfinder_paths/core/constants/base.py +6 -1
  58. wayfinder_paths/core/constants/contracts.py +39 -26
  59. wayfinder_paths/core/constants/erc20_abi.py +0 -1
  60. wayfinder_paths/core/constants/hyperlend_abi.py +0 -4
  61. wayfinder_paths/core/constants/hyperliquid.py +16 -0
  62. wayfinder_paths/core/constants/moonwell_abi.py +0 -15
  63. wayfinder_paths/core/engine/manifest.py +66 -0
  64. wayfinder_paths/core/strategies/Strategy.py +0 -61
  65. wayfinder_paths/core/strategies/__init__.py +10 -1
  66. wayfinder_paths/core/strategies/opa_loop.py +167 -0
  67. wayfinder_paths/core/utils/test_transaction.py +289 -0
  68. wayfinder_paths/core/utils/transaction.py +44 -1
  69. wayfinder_paths/core/utils/web3.py +3 -0
  70. wayfinder_paths/mcp/__init__.py +5 -0
  71. wayfinder_paths/mcp/preview.py +185 -0
  72. wayfinder_paths/mcp/scripting.py +84 -0
  73. wayfinder_paths/mcp/server.py +52 -0
  74. wayfinder_paths/mcp/state/profile_store.py +195 -0
  75. wayfinder_paths/mcp/state/store.py +89 -0
  76. wayfinder_paths/mcp/test_scripting.py +267 -0
  77. wayfinder_paths/mcp/tools/__init__.py +0 -0
  78. wayfinder_paths/mcp/tools/balances.py +290 -0
  79. wayfinder_paths/mcp/tools/discovery.py +158 -0
  80. wayfinder_paths/mcp/tools/execute.py +770 -0
  81. wayfinder_paths/mcp/tools/hyperliquid.py +931 -0
  82. wayfinder_paths/mcp/tools/quotes.py +288 -0
  83. wayfinder_paths/mcp/tools/run_script.py +286 -0
  84. wayfinder_paths/mcp/tools/strategies.py +188 -0
  85. wayfinder_paths/mcp/tools/tokens.py +46 -0
  86. wayfinder_paths/mcp/tools/wallets.py +354 -0
  87. wayfinder_paths/mcp/utils.py +129 -0
  88. wayfinder_paths/policies/hyperliquid.py +1 -1
  89. wayfinder_paths/policies/lifi.py +18 -0
  90. wayfinder_paths/policies/util.py +8 -2
  91. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +28 -119
  92. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +24 -53
  93. wayfinder_paths/strategies/boros_hype_strategy/__init__.py +3 -0
  94. wayfinder_paths/strategies/boros_hype_strategy/boros_ops_mixin.py +450 -0
  95. wayfinder_paths/strategies/boros_hype_strategy/constants.py +255 -0
  96. wayfinder_paths/strategies/boros_hype_strategy/examples.json +37 -0
  97. wayfinder_paths/strategies/boros_hype_strategy/hyperevm_ops_mixin.py +114 -0
  98. wayfinder_paths/strategies/boros_hype_strategy/hyperliquid_ops_mixin.py +642 -0
  99. wayfinder_paths/strategies/boros_hype_strategy/manifest.yaml +36 -0
  100. wayfinder_paths/strategies/boros_hype_strategy/planner.py +460 -0
  101. wayfinder_paths/strategies/boros_hype_strategy/risk_ops_mixin.py +886 -0
  102. wayfinder_paths/strategies/boros_hype_strategy/snapshot_mixin.py +494 -0
  103. wayfinder_paths/strategies/boros_hype_strategy/strategy.py +1194 -0
  104. wayfinder_paths/strategies/boros_hype_strategy/test_planner_golden.py +374 -0
  105. wayfinder_paths/strategies/boros_hype_strategy/test_strategy.py +202 -0
  106. wayfinder_paths/strategies/boros_hype_strategy/types.py +365 -0
  107. wayfinder_paths/strategies/boros_hype_strategy/withdraw_mixin.py +997 -0
  108. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +3 -12
  109. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +7 -29
  110. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +63 -40
  111. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +5 -15
  112. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +0 -34
  113. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +11 -34
  114. wayfinder_paths/tests/test_mcp_quote_swap.py +165 -0
  115. wayfinder_paths/tests/test_test_coverage.py +1 -4
  116. wayfinder_paths-0.1.25.dist-info/METADATA +377 -0
  117. wayfinder_paths-0.1.25.dist-info/RECORD +185 -0
  118. wayfinder_paths/scripts/create_strategy.py +0 -139
  119. wayfinder_paths/scripts/make_wallets.py +0 -142
  120. wayfinder_paths-0.1.23.dist-info/METADATA +0 -354
  121. wayfinder_paths-0.1.23.dist-info/RECORD +0 -120
  122. /wayfinder_paths/{scripts → mcp/state}/__init__.py +0 -0
  123. {wayfinder_paths-0.1.23.dist-info → wayfinder_paths-0.1.25.dist-info}/LICENSE +0 -0
  124. {wayfinder_paths-0.1.23.dist-info → wayfinder_paths-0.1.25.dist-info}/WHEEL +0 -0
@@ -0,0 +1,1194 @@
1
+ """
2
+ Boros HYPE Strategy - Delta-neutral HYPE yield with rate lock.
3
+
4
+ This strategy combines three legs:
5
+ 1. Spot leg: kHYPE + looped HYPE yield on HyperEVM
6
+ 2. Hedge leg: Hyperliquid HYPE perp short
7
+ 3. Rate lock leg: Boros fixed-rate markets
8
+
9
+ The strategy uses an Observe→Plan→Act (OPA) control loop pattern where:
10
+ - observe() builds a full inventory snapshot
11
+ - plan() is a mostly-pure planner outputting prioritized PlanOp steps
12
+ - execute_step() dispatches to concrete adapter calls
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ from datetime import datetime
19
+ from typing import Any
20
+
21
+ from eth_account import Account
22
+ from loguru import logger
23
+
24
+ from wayfinder_paths.adapters.balance_adapter.adapter import BalanceAdapter
25
+ from wayfinder_paths.adapters.boros_adapter import BorosAdapter, BorosMarketQuote
26
+ from wayfinder_paths.adapters.boros_adapter.client import BorosClient
27
+ from wayfinder_paths.adapters.brap_adapter.adapter import BRAPAdapter
28
+ from wayfinder_paths.adapters.hyperliquid_adapter.adapter import (
29
+ HyperliquidAdapter,
30
+ )
31
+ from wayfinder_paths.adapters.hyperliquid_adapter.executor import (
32
+ LocalHyperliquidExecutor,
33
+ )
34
+ from wayfinder_paths.adapters.ledger_adapter.adapter import LedgerAdapter
35
+ from wayfinder_paths.core.constants.hyperliquid import (
36
+ DEFAULT_HYPERLIQUID_BUILDER_FEE_TENTHS_BP,
37
+ HYPE_FEE_WALLET,
38
+ )
39
+ from wayfinder_paths.core.strategies import (
40
+ OPAConfig,
41
+ OPALoopMixin,
42
+ StatusDict,
43
+ StatusTuple,
44
+ Strategy,
45
+ )
46
+ from wayfinder_paths.core.strategies import (
47
+ Plan as OPAPlan,
48
+ )
49
+ from wayfinder_paths.core.strategies import (
50
+ PlanStep as OPAPlanStep,
51
+ )
52
+ from wayfinder_paths.core.strategies.descriptors import (
53
+ Complexity,
54
+ Directionality,
55
+ Frequency,
56
+ StratDescriptor,
57
+ TokenExposure,
58
+ Volatility,
59
+ )
60
+
61
+ from .boros_ops_mixin import BorosHypeBorosOpsMixin
62
+ from .constants import (
63
+ BOROS_HYPE_TOKEN_ID,
64
+ ETH_ARB,
65
+ MIN_HYPE_GAS,
66
+ MIN_NET_DEPOSIT,
67
+ USDC_ARB,
68
+ USDT_ARB,
69
+ )
70
+ from .hyperevm_ops_mixin import BorosHypeHyperEvmOpsMixin
71
+ from .hyperliquid_ops_mixin import BorosHypeHyperliquidOpsMixin
72
+ from .planner import build_plan
73
+ from .risk_ops_mixin import BorosHypeRiskOpsMixin
74
+ from .snapshot_mixin import BorosHypeSnapshotMixin, fetch_khype_apy, fetch_lhype_apy
75
+ from .types import (
76
+ INVENTORY_CHANGING_OPS,
77
+ AllocationStatus,
78
+ HedgeConfig,
79
+ Inventory,
80
+ PlannerConfig,
81
+ PlannerRuntime,
82
+ PlanOp,
83
+ YieldInfo,
84
+ )
85
+ from .withdraw_mixin import BorosHypeWithdrawMixin
86
+
87
+
88
+ class BorosHypeStrategy(
89
+ BorosHypeSnapshotMixin,
90
+ BorosHypeWithdrawMixin,
91
+ BorosHypeHyperEvmOpsMixin,
92
+ BorosHypeBorosOpsMixin,
93
+ BorosHypeHyperliquidOpsMixin,
94
+ BorosHypeRiskOpsMixin,
95
+ OPALoopMixin[Inventory, PlanOp],
96
+ Strategy,
97
+ ):
98
+ name = "HYPE Spot + Hyperliquid + Boros Strategy"
99
+
100
+ INFO = StratDescriptor(
101
+ description=(
102
+ "Delta-neutral HYPE yield strategy combining three legs: "
103
+ "1) Spot yield from kHYPE and looped HYPE on HyperEVM, "
104
+ "2) Hyperliquid HYPE perp short for delta hedging, "
105
+ "3) Boros fixed-rate markets for rate locking. "
106
+ "Deposits are Arbitrum USDC + ETH (for gas). "
107
+ "The strategy routes capital to all venues automatically."
108
+ ),
109
+ summary=(
110
+ "Earns yield from HYPE spot (kHYPE/lHYPE) while hedging price exposure "
111
+ "via Hyperliquid perp shorts and locking in rates on Boros."
112
+ ),
113
+ risk_description=(
114
+ "Higher risk than pure funding rate strategies due to lack of limit orders "
115
+ "on spot assets - entries and exits occur at market prices which can result "
116
+ "in slippage during volatile conditions. Additional smart contract risk across "
117
+ "multiple protocols (HyperEVM staking, Hyperliquid perps, Boros fixed-rate markets). "
118
+ "Liquidation risk on the perp short if funding diverges significantly. "
119
+ "Bridge risk when moving assets between chains."
120
+ ),
121
+ gas_token_symbol="ETH",
122
+ gas_token_id="ethereum-arbitrum",
123
+ deposit_token_id="usd-coin-arbitrum",
124
+ minimum_net_deposit=MIN_NET_DEPOSIT,
125
+ gas_maximum=0.1,
126
+ gas_threshold=0.03,
127
+ volatility=Volatility.LOW,
128
+ volatility_description="Delta-neutral strategy minimizes price exposure.",
129
+ directionality=Directionality.DELTA_NEUTRAL,
130
+ directionality_description=(
131
+ "Long HYPE spot (kHYPE/lHYPE) hedged by short HYPE perp on Hyperliquid."
132
+ ),
133
+ complexity=Complexity.HIGH,
134
+ complexity_description=(
135
+ "Complex multi-chain, multi-venue strategy requiring careful orchestration."
136
+ ),
137
+ token_exposure=TokenExposure.ALTS,
138
+ token_exposure_description="Exposed to HYPE through hedged yield positions.",
139
+ frequency=Frequency.LOW,
140
+ frequency_description="Positions held for weeks to capture yield and funding.",
141
+ return_drivers=[
142
+ "kHYPE staking yield",
143
+ "lHYPE loop yield",
144
+ "funding rate",
145
+ "Boros fixed rate",
146
+ ],
147
+ config={
148
+ "deposit": {
149
+ "description": "Deposit USDC and ETH to fund the strategy.",
150
+ "parameters": {
151
+ "main_token_amount": {
152
+ "type": "float",
153
+ "unit": "USDC",
154
+ "description": "Amount of USDC (Arbitrum) to deposit.",
155
+ "minimum": MIN_NET_DEPOSIT,
156
+ },
157
+ "gas_token_amount": {
158
+ "type": "float",
159
+ "unit": "ETH",
160
+ "description": "Amount of ETH (Arbitrum) for gas on multiple chains.",
161
+ "minimum": 0.0,
162
+ "maximum": 0.1,
163
+ },
164
+ },
165
+ },
166
+ "update": {
167
+ "description": "Run the OPA control loop to manage positions.",
168
+ },
169
+ "withdraw": {
170
+ "description": "Close all positions and return funds to main wallet.",
171
+ },
172
+ },
173
+ )
174
+
175
+ def __init__(
176
+ self,
177
+ config: dict[str, Any] | None = None,
178
+ *,
179
+ main_wallet: dict[str, Any] | None = None,
180
+ strategy_wallet: dict[str, Any] | None = None,
181
+ simulation: bool = False,
182
+ **kwargs,
183
+ ) -> None:
184
+ super().__init__(
185
+ config=config,
186
+ main_wallet=main_wallet,
187
+ strategy_wallet=strategy_wallet,
188
+ **kwargs,
189
+ )
190
+
191
+ self.simulation = simulation
192
+ self._config = config or {}
193
+
194
+ # Configuration
195
+ self.hedge_cfg = HedgeConfig.default()
196
+ self._planner_config = PlannerConfig.default()
197
+ self._planner_runtime = PlannerRuntime()
198
+ # Hyperliquid builder attribution is mandatory and fixed to HYPE_FEE_WALLET.
199
+ expected_builder = HYPE_FEE_WALLET.lower()
200
+ cfg_builder_fee = self._config.get("builder_fee")
201
+ fee = None
202
+ if isinstance(cfg_builder_fee, dict):
203
+ cfg_builder = str(cfg_builder_fee.get("b") or "").strip()
204
+ if cfg_builder and cfg_builder.lower() != expected_builder:
205
+ raise ValueError(
206
+ f"builder_fee.b must be {expected_builder} (got {cfg_builder})"
207
+ )
208
+ if cfg_builder_fee.get("f") is not None:
209
+ fee = cfg_builder_fee.get("f")
210
+
211
+ if fee is None:
212
+ fee = DEFAULT_HYPERLIQUID_BUILDER_FEE_TENTHS_BP
213
+ try:
214
+ fee_i = int(fee)
215
+ except (TypeError, ValueError) as exc:
216
+ raise ValueError("builder_fee.f must be an int (tenths of bp)") from exc
217
+ if fee_i <= 0:
218
+ raise ValueError("builder_fee.f must be > 0 (tenths of bp)")
219
+
220
+ self.builder_fee: dict[str, Any] = {"b": expected_builder, "f": fee_i}
221
+
222
+ # OPA context (populated in observe())
223
+ self._opa_alloc: AllocationStatus | None = None
224
+ self._opa_risk_progress: float = 0.0
225
+ self._opa_boros_quotes: list[BorosMarketQuote] = []
226
+ self._opa_yield_info: YieldInfo | None = None
227
+
228
+ # Pending withdrawal state tracking
229
+ self._opa_pending_withdrawal: bool = False
230
+ self._opa_completed_pending_withdrawal_this_tick: bool = False
231
+
232
+ # Emergency flags (best-effort, per-process)
233
+ self._failsafe_triggered: bool = False
234
+ self._failsafe_message: str | None = None
235
+
236
+ # Adapters (initialized in setup)
237
+ self.boros_adapter: BorosAdapter | None = None
238
+ self.hyperliquid_adapter: HyperliquidAdapter | None = None
239
+ self.balance_adapter: BalanceAdapter | None = None
240
+ self.brap_adapter: BRAPAdapter | None = None
241
+ self._sign_callback = None
242
+
243
+ def _make_sign_callback(self, private_key: str):
244
+ account = Account.from_key(private_key)
245
+
246
+ async def sign_callback(transaction: dict) -> bytes:
247
+ signed = account.sign_transaction(transaction)
248
+ return signed.raw_transaction
249
+
250
+ return sign_callback
251
+
252
+ async def setup(self) -> None:
253
+ strategy_wallet = self._config.get("strategy_wallet", {})
254
+ main_wallet = self._config.get("main_wallet", {})
255
+ user_address = strategy_wallet.get("address") if strategy_wallet else None
256
+
257
+ # Create signing callbacks from wallet private keys
258
+ strategy_pk = (
259
+ strategy_wallet.get("private_key") or strategy_wallet.get("private_key_hex")
260
+ if strategy_wallet
261
+ else None
262
+ )
263
+ main_pk = (
264
+ main_wallet.get("private_key") or main_wallet.get("private_key_hex")
265
+ if main_wallet
266
+ else None
267
+ )
268
+
269
+ self._sign_callback = (
270
+ self._make_sign_callback(strategy_pk) if strategy_pk else None
271
+ )
272
+ main_sign_callback = self._make_sign_callback(main_pk) if main_pk else None
273
+
274
+ # Initialize Boros adapter
275
+ self.boros_adapter = BorosAdapter(
276
+ config=self._config,
277
+ sign_callback=self._sign_callback,
278
+ simulation=self.simulation,
279
+ user_address=user_address,
280
+ )
281
+
282
+ # Verify Boros connection
283
+ try:
284
+ connected = await self.boros_adapter.connect()
285
+ except Exception as e:
286
+ connected = False
287
+ logger.warning(f"Boros adapter connection failed: {e}")
288
+ if not connected:
289
+ logger.warning(
290
+ "Boros adapter connection failed - some features may not work"
291
+ )
292
+
293
+ # Initialize Hyperliquid adapter for market data and perp trading
294
+ try:
295
+ # Create executor for non-simulation mode (requires private key)
296
+ hl_executor = None
297
+ if not self.simulation:
298
+ try:
299
+ hl_executor = LocalHyperliquidExecutor(config=self._config)
300
+ logger.info(
301
+ f"Hyperliquid executor initialized for {hl_executor.address}"
302
+ )
303
+ except (ImportError, ValueError) as e:
304
+ logger.warning(f"Hyperliquid executor not available: {e}")
305
+
306
+ self.hyperliquid_adapter = HyperliquidAdapter(
307
+ config=self._config,
308
+ simulation=self.simulation,
309
+ executor=hl_executor,
310
+ )
311
+ try:
312
+ hl_connected = await self.hyperliquid_adapter.connect()
313
+ except Exception as e:
314
+ hl_connected = False
315
+ logger.warning(f"Hyperliquid adapter connection failed: {e}")
316
+ if not hl_connected:
317
+ logger.warning("Hyperliquid adapter connection failed")
318
+ except Exception as e:
319
+ logger.warning(f"Hyperliquid adapter not available: {e}")
320
+ self.hyperliquid_adapter = None
321
+
322
+ # Initialize BalanceAdapter for on-chain balance reads
323
+ try:
324
+ self.balance_adapter = BalanceAdapter(
325
+ config=self._config,
326
+ main_wallet_signing_callback=main_sign_callback,
327
+ strategy_wallet_signing_callback=self._sign_callback,
328
+ )
329
+ logger.debug("BalanceAdapter initialized for on-chain balance reads")
330
+ except Exception as e:
331
+ logger.warning(f"BalanceAdapter initialization failed: {e}")
332
+ self.balance_adapter = None
333
+
334
+ # Initialize BRAP adapter for swaps and bridging
335
+ try:
336
+ self.brap_adapter = BRAPAdapter(
337
+ config=self._config,
338
+ strategy_wallet_signing_callback=self._sign_callback,
339
+ )
340
+ logger.debug("BRAPAdapter initialized for swaps")
341
+ except Exception as e:
342
+ logger.warning(f"BRAPAdapter initialization failed: {e}")
343
+ self.brap_adapter = None
344
+
345
+ # Initialize ledger adapter for transaction recording
346
+ try:
347
+ self.ledger_adapter = LedgerAdapter()
348
+ logger.debug("LedgerAdapter initialized")
349
+ except Exception as e:
350
+ logger.warning(f"LedgerAdapter initialization failed: {e}")
351
+ self.ledger_adapter = None
352
+
353
+ logger.info(f"BorosHypeStrategy setup complete (simulation={self.simulation})")
354
+
355
+ async def analyze(self, deposit_usdc: float = 1000.0) -> dict[str, Any]:
356
+ # Read-only market analysis returning Boros fixed-rate markets for HYPE
357
+ client = (
358
+ self.boros_adapter.boros_client if self.boros_adapter else BorosClient()
359
+ )
360
+ try:
361
+ markets = await client.list_markets(is_whitelisted=True, skip=0, limit=250)
362
+ except Exception as exc: # noqa: BLE001
363
+ return {
364
+ "success": False,
365
+ "error": str(exc),
366
+ "deposit_usdc": float(deposit_usdc),
367
+ "hype_markets": [],
368
+ }
369
+
370
+ out: list[dict[str, Any]] = []
371
+ for m in markets:
372
+ if not isinstance(m, dict):
373
+ continue
374
+ im = m.get("imData") if isinstance(m.get("imData"), dict) else {}
375
+ meta = m.get("metadata") if isinstance(m.get("metadata"), dict) else {}
376
+ platform_obj = (
377
+ m.get("platform") if isinstance(m.get("platform"), dict) else {}
378
+ )
379
+ data = m.get("data") if isinstance(m.get("data"), dict) else {}
380
+
381
+ name = str(im.get("name") or meta.get("marketName") or "").strip()
382
+ symbol = str(im.get("symbol") or "").strip()
383
+ platform = str(
384
+ platform_obj.get("name") or meta.get("platformName") or ""
385
+ ).strip()
386
+ asset_symbol = str(
387
+ meta.get("assetSymbol") or meta.get("name") or ""
388
+ ).strip()
389
+
390
+ hay = " ".join([name, symbol, platform, asset_symbol]).upper()
391
+ if "HYPE" not in hay:
392
+ continue
393
+
394
+ try:
395
+ market_id = int(m.get("marketId") or m.get("id") or 0)
396
+ except Exception:
397
+ market_id = 0
398
+
399
+ maturity_ts = im.get("maturity")
400
+ try:
401
+ maturity_ts_i = int(maturity_ts) if maturity_ts is not None else None
402
+ except Exception:
403
+ maturity_ts_i = None
404
+
405
+ def _f(x: Any) -> float | None:
406
+ try:
407
+ if x is None:
408
+ return None
409
+ return float(x)
410
+ except Exception:
411
+ return None
412
+
413
+ out.append(
414
+ {
415
+ "market_id": market_id,
416
+ "name": name or None,
417
+ "symbol": symbol or None,
418
+ "platform": platform or None,
419
+ "asset_symbol": asset_symbol.upper() if asset_symbol else None,
420
+ "maturity_ts": maturity_ts_i,
421
+ "time_to_maturity_s": _f(data.get("timeToMaturity")),
422
+ "mark_apr": _f(data.get("markApr")),
423
+ "mid_apr": _f(data.get("midApr")),
424
+ "best_bid_apr": _f(data.get("bestBid")),
425
+ "best_ask_apr": _f(data.get("bestAsk")),
426
+ "floating_apr": _f(data.get("floatingApr")),
427
+ }
428
+ )
429
+
430
+ out.sort(key=lambda x: (x.get("maturity_ts") or 0, x.get("market_id") or 0))
431
+
432
+ primary = next(
433
+ (
434
+ x
435
+ for x in out
436
+ if (x.get("platform") or "").lower() == "hyperliquid"
437
+ and (x.get("asset_symbol") or "").upper() == "HYPE"
438
+ ),
439
+ None,
440
+ )
441
+
442
+ return {
443
+ "success": True,
444
+ "deposit_usdc": float(deposit_usdc),
445
+ "primary_market": primary,
446
+ "hype_markets": out,
447
+ "notes": {
448
+ "apr_units": "Decimals (0.10 = 10% APR).",
449
+ "lock_hint": "The fixed rate to lock is typically around best_bid_apr ↔ best_ask_apr (depends on side).",
450
+ },
451
+ }
452
+
453
+ @property
454
+ def opa_config(self) -> OPAConfig:
455
+ return OPAConfig(
456
+ max_iterations_per_tick=self._planner_config.max_iterations_per_tick,
457
+ max_steps_per_iteration=self._planner_config.max_steps_per_iteration,
458
+ max_total_steps_per_tick=self._planner_config.max_total_steps_per_tick,
459
+ )
460
+
461
+ def plan(self, inventory: Inventory) -> OPAPlan[PlanOp]:
462
+ plan = build_plan(
463
+ inv=inventory,
464
+ alloc=self._opa_alloc or self._get_allocation_status(inventory),
465
+ risk_progress=self._opa_risk_progress,
466
+ hedge_cfg=self.hedge_cfg,
467
+ config=self._planner_config,
468
+ runtime=self._planner_runtime,
469
+ boros_quotes=self._opa_boros_quotes,
470
+ pending_withdrawal_completion=self._opa_pending_withdrawal,
471
+ )
472
+
473
+ # Convert to OPA Plan format
474
+ opa_plan = OPAPlan[PlanOp](
475
+ steps=[
476
+ OPAPlanStep(
477
+ op=step.op,
478
+ priority=step.priority,
479
+ key=step.key,
480
+ params=step.params,
481
+ reason=step.reason,
482
+ )
483
+ for step in plan.steps
484
+ ],
485
+ desired_state={
486
+ "mode": plan.desired_state.mode.name,
487
+ "target_spot_usd": plan.desired_state.target_spot_usd,
488
+ "target_hl_margin_usd": plan.desired_state.target_hl_margin_usd,
489
+ "boros_market_id": plan.desired_state.boros_market_id,
490
+ },
491
+ )
492
+ return opa_plan
493
+
494
+ async def execute_step(
495
+ self, step: OPAPlanStep[PlanOp], inventory: Inventory
496
+ ) -> tuple[bool, str]:
497
+ op = step.op
498
+ params = step.params
499
+
500
+ logger.info(f"Executing {op.name}: {step.reason}")
501
+
502
+ if self.simulation:
503
+ return True, f"[SIMULATION] {op.name} executed"
504
+
505
+ if self._failsafe_triggered:
506
+ return False, "Failsafe already triggered; skipping further actions"
507
+
508
+ # Dispatch to handlers - complete mapping for all PlanOps
509
+ handlers = {
510
+ # Priority 0: Safety/Risk mitigation
511
+ PlanOp.CLOSE_AND_REDEPLOY: self._close_and_redeploy,
512
+ PlanOp.PARTIAL_TRIM_SPOT: self._partial_trim_spot,
513
+ PlanOp.COMPLETE_PENDING_WITHDRAWAL: self._complete_pending_withdrawal,
514
+ # Priority 5: Gas routing
515
+ PlanOp.ENSURE_GAS_ON_HYPEREVM: self._ensure_gas_on_hyperevm,
516
+ PlanOp.ENSURE_GAS_ON_ARBITRUM: self._ensure_gas_on_arbitrum,
517
+ # Priority 10-14: Capital routing
518
+ PlanOp.FUND_BOROS: self._fund_boros,
519
+ PlanOp.SEND_USDC_TO_HL: self._send_usdc_to_hl,
520
+ PlanOp.BRIDGE_TO_HYPEREVM: self._bridge_to_hyperevm,
521
+ PlanOp.DEPLOY_EXCESS_HL_MARGIN: self._deploy_excess_hl_margin,
522
+ PlanOp.TRANSFER_HL_SPOT_TO_HYPEREVM: self._transfer_hl_spot_to_hyperevm,
523
+ # Priority 20: Position management
524
+ PlanOp.SWAP_HYPE_TO_LST: self._swap_hype_to_lst,
525
+ PlanOp.ENSURE_HL_SHORT: self._ensure_hl_short,
526
+ # Priority 30: Rate positions
527
+ PlanOp.ENSURE_BOROS_POSITION: self._ensure_boros_position,
528
+ }
529
+
530
+ handler = handlers.get(op)
531
+ if handler:
532
+ return await handler(params, inventory)
533
+
534
+ logger.warning(f"No handler implemented for {op.name}")
535
+ return False, f"No handler for {op.name}"
536
+
537
+ def get_inventory_changing_ops(self) -> set[PlanOp]:
538
+ return INVENTORY_CHANGING_OPS
539
+
540
+ async def on_loop_start(self) -> tuple[bool, str] | None:
541
+ # Pre-loop setup: reset tick flags, check pending withdrawals, approve builder fee
542
+ self._planner_runtime.reset_virtual_ledger()
543
+ self._planner_runtime.reset_tick_flags()
544
+ self._planner_runtime.last_update_at = datetime.utcnow()
545
+ self._opa_completed_pending_withdrawal_this_tick = False
546
+ self._failsafe_triggered = False
547
+ self._failsafe_message = None
548
+
549
+ # Pre-check for pending withdrawal from Boros
550
+ # This allows build_plan() to prioritize withdrawal completion
551
+ self._opa_pending_withdrawal = False
552
+ if self.boros_adapter:
553
+ try:
554
+ token_id = (
555
+ self._planner_runtime.current_boros_token_id or BOROS_HYPE_TOKEN_ID
556
+ )
557
+ (
558
+ ok_pending,
559
+ pending_amt,
560
+ ) = await self.boros_adapter.get_pending_withdrawal_amount(
561
+ token_id=int(token_id),
562
+ token_decimals=18,
563
+ )
564
+ if ok_pending and pending_amt > 0:
565
+ self._opa_pending_withdrawal = True
566
+ logger.info(
567
+ f"Pending Boros withdrawal detected: {pending_amt:.6f} collateral units"
568
+ )
569
+ # We do NOT perform any OPA actions while Boros withdrawals are pending.
570
+ # Withdrawal settlement can take 10-20 minutes; running update() in the
571
+ # meantime risks redeploying or hedging against an in-flight withdrawal.
572
+ return (
573
+ True,
574
+ f"Pending Boros withdrawal ({pending_amt:.6f}) - skipping update tick",
575
+ )
576
+ except Exception as e:
577
+ logger.warning(f"Failed to check pending withdrawal: {e}")
578
+
579
+ # Ensure Hyperliquid builder fee is approved (required prerequisite).
580
+ if (
581
+ not self.simulation
582
+ and self.hyperliquid_adapter
583
+ and self.builder_fee
584
+ and not self._planner_runtime.builder_fee_approved
585
+ ):
586
+ strategy_wallet = self._config.get("strategy_wallet", {})
587
+ address = strategy_wallet.get("address")
588
+
589
+ if address:
590
+ ok, msg = await self.hyperliquid_adapter.ensure_builder_fee_approved(
591
+ address=address,
592
+ builder_fee=self.builder_fee,
593
+ )
594
+ if not ok:
595
+ return False, f"Failed to approve Hyperliquid builder fee: {msg}"
596
+ self._planner_runtime.builder_fee_approved = True
597
+
598
+ # Ensure Hyperliquid HYPE leverage is set *before* any paired fills/perp orders.
599
+ if (
600
+ not self.simulation
601
+ and self.hyperliquid_adapter
602
+ and not self._planner_runtime.leverage_set_for_hype
603
+ ):
604
+ strategy_wallet = self._config.get("strategy_wallet", {})
605
+ address = strategy_wallet.get("address")
606
+ if address:
607
+ ok_lev, lev_msg = await self._ensure_hl_hype_leverage_set(address)
608
+ if not ok_lev:
609
+ return False, lev_msg
610
+
611
+ return None # Continue with loop
612
+
613
+ async def on_step_executed(
614
+ self,
615
+ step: OPAPlanStep[PlanOp],
616
+ success: bool,
617
+ message: str,
618
+ ) -> None:
619
+ if step.op == PlanOp.COMPLETE_PENDING_WITHDRAWAL and success:
620
+ # Mark that we completed the pending withdrawal this tick
621
+ self._opa_pending_withdrawal = False
622
+ self._opa_completed_pending_withdrawal_this_tick = True
623
+ logger.info("Pending withdrawal completed this tick")
624
+
625
+ if step.op == PlanOp.FUND_BOROS and success:
626
+ # Prevent repeated Boros funding within same tick
627
+ self._planner_runtime.funded_boros_this_tick = True
628
+ logger.debug("Boros funded this tick - preventing duplicate funding")
629
+
630
+ def should_stop_early(
631
+ self, inventory: Inventory, iteration: int
632
+ ) -> tuple[bool, str] | None:
633
+ if inventory.boros_pending_withdrawal_usd > 1.0:
634
+ self._opa_pending_withdrawal = True
635
+ return (
636
+ True,
637
+ f"Pending Boros withdrawal (${inventory.boros_pending_withdrawal_usd:.2f}) - skipping update tick",
638
+ )
639
+
640
+ # Stop if we completed a pending withdrawal this tick
641
+ # (we don't want to redeploy capital in the same tick)
642
+ if self._opa_completed_pending_withdrawal_this_tick:
643
+ return (
644
+ True,
645
+ "Pending withdrawal completed - stopping to avoid same-tick redeployment",
646
+ )
647
+
648
+ return None
649
+
650
+ def _get_allocation_status(self, inv: Inventory) -> AllocationStatus:
651
+ total = inv.total_value or 1.0
652
+
653
+ spot_actual = inv.spot_value_usd
654
+ hl_actual = inv.hl_perp_margin + inv.hl_spot_usdc
655
+ boros_actual = inv.boros_committed_collateral_usd
656
+ idle_actual = inv.usdc_arb_idle + inv.usdt_arb_idle
657
+
658
+ return AllocationStatus(
659
+ spot_value=spot_actual,
660
+ hl_value=hl_actual,
661
+ boros_value=boros_actual,
662
+ idle_value=idle_actual,
663
+ total_value=total,
664
+ spot_pct_actual=spot_actual / total,
665
+ hl_pct_actual=hl_actual / total,
666
+ boros_pct_actual=boros_actual / total,
667
+ spot_deviation=(spot_actual / total) - self.hedge_cfg.spot_pct,
668
+ hl_deviation=(hl_actual / total) - self.hedge_cfg.hyperliquid_pct,
669
+ boros_deviation=(boros_actual / total) - self.hedge_cfg.boros_pct,
670
+ spot_needed_usd=max(0, (self.hedge_cfg.spot_pct * total) - spot_actual),
671
+ hl_needed_usd=max(0, (self.hedge_cfg.hyperliquid_pct * total) - hl_actual),
672
+ boros_needed_usd=max(0, (self.hedge_cfg.boros_pct * total) - boros_actual),
673
+ )
674
+
675
+ # ─────────────────────────────────────────────────────────────────────────
676
+ # Risk & Invariant Helpers
677
+ # ─────────────────────────────────────────────────────────────────────────
678
+
679
+ def _delta_neutral_ok(self, inv: Inventory) -> tuple[bool, str]:
680
+ # Combined absolute + relative tolerance to avoid dust chasing
681
+ exp = float(inv.total_hype_exposure or 0.0)
682
+ short = float(inv.hl_short_size_hype or 0.0)
683
+ if exp < 0.1:
684
+ if abs(short) < 0.01:
685
+ return True, "No meaningful HYPE exposure"
686
+ return (
687
+ False,
688
+ f"Unexpected HL short without spot exposure (short={short:.4f} HYPE, spot={exp:.4f} HYPE)",
689
+ )
690
+ diff = abs(short - exp)
691
+
692
+ # Combined tolerance: max of absolute and relative
693
+ tol = max(
694
+ self._planner_config.delta_neutral_abs_tol_hype, # ~$2 at $25 HYPE
695
+ exp * self._planner_config.delta_neutral_rel_tol, # 2% relative
696
+ )
697
+ ok = diff <= tol
698
+ return ok, f"Δ={diff:.4f} tol={tol:.4f} (spot={exp:.4f}, short={short:.4f})"
699
+
700
+ def _boros_coverage_ok(
701
+ self, inv: Inventory, quotes: list[BorosMarketQuote] | None = None
702
+ ) -> tuple[bool, str]:
703
+ # Target coverage is 85% of spot exposure with 5% band
704
+ if not self._planner_runtime.current_boros_market_id:
705
+ return True, "No Boros market selected"
706
+
707
+ if inv.total_value < self._planner_config.min_total_for_boros:
708
+ return True, "Below Boros minimum - skipping coverage check"
709
+
710
+ spot_usd = inv.total_hype_exposure * inv.hype_price_usd
711
+ if spot_usd < 10:
712
+ return True, "Negligible spot exposure"
713
+
714
+ target_position = spot_usd * self._planner_config.boros_coverage_target
715
+ current_position = inv.boros_position_size
716
+
717
+ if target_position < 10:
718
+ return True, "Target Boros position too small"
719
+
720
+ # Use resize threshold as hysteresis band
721
+ diff = abs(current_position - target_position)
722
+ ok = diff <= self._planner_config.boros_resize_min_excess_usd
723
+
724
+ return ok, (
725
+ f"diff=${diff:.2f} "
726
+ f"(current=${current_position:.0f}, target=${target_position:.0f})"
727
+ )
728
+
729
+ # ─────────────────────────────────────────────────────────────────────────
730
+ # Step Handlers
731
+ # ─────────────────────────────────────────────────────────────────────────
732
+
733
+ # Implemented in mixins:
734
+ # - boros_ops_mixin.py
735
+ # - hyperliquid_ops_mixin.py
736
+ # - hyperevm_ops_mixin.py
737
+ # - risk_ops_mixin.py
738
+
739
+ # ─────────────────────────────────────────────────────────────────────────
740
+ # Strategy Interface
741
+ # ─────────────────────────────────────────────────────────────────────────
742
+
743
+ async def deposit(
744
+ self,
745
+ main_token_amount: float = 0.0,
746
+ gas_token_amount: float = 0.0,
747
+ **kwargs,
748
+ ) -> StatusTuple:
749
+ if main_token_amount < MIN_NET_DEPOSIT:
750
+ return (
751
+ False,
752
+ f"Minimum deposit is ${MIN_NET_DEPOSIT:.0f} USDC, got ${main_token_amount:.2f}",
753
+ )
754
+
755
+ if self.simulation:
756
+ return (
757
+ True,
758
+ f"[SIMULATION] Deposited ${main_token_amount:.2f} USDC + {gas_token_amount:.4f} ETH",
759
+ )
760
+
761
+ if not self.balance_adapter:
762
+ return False, "Balance adapter not configured"
763
+
764
+ main_wallet = self._config.get("main_wallet")
765
+ strategy_wallet = self._config.get("strategy_wallet")
766
+ main_address = (
767
+ main_wallet.get("address") if isinstance(main_wallet, dict) else None
768
+ )
769
+ strategy_address = (
770
+ strategy_wallet.get("address")
771
+ if isinstance(strategy_wallet, dict)
772
+ else None
773
+ )
774
+
775
+ if not main_address or not strategy_address:
776
+ return False, "main_wallet or strategy_wallet missing address"
777
+
778
+ # USDC (Arbitrum) deposit
779
+ usdc_to_move = float(main_token_amount)
780
+ if main_address.lower() == strategy_address.lower():
781
+ usdc_to_move = 0.0
782
+ else:
783
+ ok, usdc_raw = await self.balance_adapter.get_vault_wallet_balance(USDC_ARB)
784
+ existing_usdc = (usdc_raw / 1e6) if ok else 0.0
785
+ usdc_to_move = max(0.0, float(main_token_amount) - existing_usdc)
786
+
787
+ if usdc_to_move > 0.01:
788
+ (
789
+ move_ok,
790
+ move_res,
791
+ ) = await self.balance_adapter.move_from_main_wallet_to_strategy_wallet(
792
+ token_id=USDC_ARB,
793
+ amount=usdc_to_move,
794
+ strategy_name="boros_hype_strategy",
795
+ skip_ledger=True,
796
+ )
797
+ if not move_ok:
798
+ return False, f"Failed to move USDC to strategy wallet: {move_res}"
799
+
800
+ # ETH (Arbitrum) gas deposit
801
+ eth_to_move = float(gas_token_amount)
802
+ if main_address.lower() == strategy_address.lower():
803
+ eth_to_move = 0.0
804
+ else:
805
+ ok, eth_raw = await self.balance_adapter.get_vault_wallet_balance(ETH_ARB)
806
+ existing_eth = (eth_raw / 1e18) if ok else 0.0
807
+ eth_to_move = max(0.0, float(gas_token_amount) - existing_eth)
808
+
809
+ if eth_to_move > 0.00001:
810
+ (
811
+ move_ok,
812
+ move_res,
813
+ ) = await self.balance_adapter.move_from_main_wallet_to_strategy_wallet(
814
+ token_id=ETH_ARB,
815
+ amount=eth_to_move,
816
+ strategy_name="boros_hype_strategy",
817
+ skip_ledger=True,
818
+ )
819
+ if not move_ok:
820
+ return False, f"Failed to move ETH to strategy wallet: {move_res}"
821
+
822
+ return True, (
823
+ f"Deposit ready. Moved {usdc_to_move:.2f} USDC + {eth_to_move:.4f} ETH to strategy wallet. "
824
+ "Run update() to deploy."
825
+ )
826
+
827
+ async def update(self) -> tuple[bool, str, bool]:
828
+ # Run OPA loop, then enforce invariants: LST allocation, delta-neutrality, Boros coverage
829
+ result = await self.run_opa_loop()
830
+ success, message, rotated = result
831
+
832
+ # If a failsafe liquidation was triggered inside the loop, treat update as failed.
833
+ if self._failsafe_triggered:
834
+ return False, self._failsafe_message or message, rotated
835
+
836
+ # If OPA loop failed, return immediately
837
+ if not success:
838
+ return result
839
+
840
+ # If a withdrawal is pending, do nothing else this tick.
841
+ # We intentionally avoid the safety pass and any redeployment/hedging.
842
+ if self._opa_pending_withdrawal:
843
+ return result
844
+
845
+ # FINAL SAFETY PASS - Enforce invariants after OPA loop
846
+ # Even with good planning, we can end a tick offside because:
847
+ # - A hedge order partially fills
848
+ # - Step budget exhausted before hedge steps run
849
+ # - Never re-observed after final hedge step
850
+ # This pass uses fresh inventory and fixes drift immediately.
851
+
852
+ try:
853
+ inv = await self.observe()
854
+ safety_messages: list[str] = []
855
+
856
+ # 1) Spot should be allocated into LSTs (kHYPE + looped HYPE)
857
+ swappable_hype = max(
858
+ 0.0, float(inv.hype_hyperevm_balance or 0.0) - MIN_HYPE_GAS
859
+ )
860
+ if swappable_hype > self._planner_config.min_hype_swap:
861
+ logger.info(
862
+ f"[SAFETY] Unallocated spot HYPE detected: {swappable_hype:.4f} HYPE"
863
+ )
864
+ ok, msg = await self._swap_hype_to_lst(
865
+ {"hype_amount": swappable_hype}, inv
866
+ )
867
+ if ok:
868
+ safety_messages.append(f"Swapped {swappable_hype:.2f} HYPE to LST")
869
+ inv = await self.observe() # Refresh after swap
870
+
871
+ # 2) Delta neutrality must hold
872
+ ok_delta, delta_msg = self._delta_neutral_ok(inv)
873
+ if not ok_delta:
874
+ logger.warning(f"[SAFETY] Delta imbalance detected: {delta_msg}")
875
+ target_short = inv.total_hype_exposure
876
+ ok, msg = await self._ensure_hl_short(
877
+ {
878
+ "target_size": target_short,
879
+ "current_size": inv.hl_short_size_hype,
880
+ },
881
+ inv,
882
+ )
883
+ if not ok:
884
+ if not self.simulation:
885
+ ok_fs, msg_fs = await self._failsafe_liquidate_all(
886
+ f"Failed to restore delta neutrality: {delta_msg} | hedge_err={msg}"
887
+ )
888
+ return ok_fs, msg_fs, rotated
889
+ return (
890
+ False,
891
+ f"{message} | SAFETY FAIL: {delta_msg} | hedge_err={msg}",
892
+ rotated,
893
+ )
894
+
895
+ # Recheck after hedge
896
+ inv = await self.observe()
897
+ ok_delta, delta_msg = self._delta_neutral_ok(inv)
898
+ if not ok_delta:
899
+ if not self.simulation:
900
+ ok_fs, msg_fs = await self._failsafe_liquidate_all(
901
+ f"Delta neutrality still broken after hedge: {delta_msg}"
902
+ )
903
+ return ok_fs, msg_fs, rotated
904
+ return (
905
+ False,
906
+ f"{message} | SAFETY FAIL after hedge: {delta_msg}",
907
+ rotated,
908
+ )
909
+ safety_messages.append("Delta-neutral hedge fixed")
910
+
911
+ # 3) Boros coverage should be ~85% (best effort, don't hard-fail)
912
+ ok_boros, boros_msg = self._boros_coverage_ok(inv, self._opa_boros_quotes)
913
+ if not ok_boros:
914
+ logger.info(f"[SAFETY] Boros coverage drift: {boros_msg}")
915
+ # Attempt fix; if Boros cannot be brought back into a sane state, melt down.
916
+ spot_usd = inv.total_hype_exposure * inv.hype_price_usd
917
+ target_usd = spot_usd * self._planner_config.boros_coverage_target
918
+
919
+ if self._planner_runtime.current_boros_market_id:
920
+ ok, msg = await self._ensure_boros_position(
921
+ {
922
+ "market_id": self._planner_runtime.current_boros_market_id,
923
+ "target_size_usd": target_usd,
924
+ },
925
+ inv,
926
+ )
927
+ if ok:
928
+ safety_messages.append(
929
+ f"Boros coverage adjusted to ${target_usd:.0f}"
930
+ )
931
+ else:
932
+ if not self.simulation:
933
+ ok_fs, msg_fs = await self._failsafe_liquidate_all(
934
+ f"Failed to restore Boros coverage: {boros_msg} | err={msg}"
935
+ )
936
+ return ok_fs, msg_fs, rotated
937
+ logger.warning(f"[SAFETY] Boros coverage fix failed: {msg}")
938
+
939
+ # Append safety messages to result
940
+ if safety_messages:
941
+ message = f"{message} | SAFETY: {'; '.join(safety_messages)}"
942
+
943
+ except Exception as e:
944
+ logger.error(f"Safety pass failed: {e}")
945
+ # Don't fail the whole update if safety pass has an exception
946
+ # The main OPA loop already succeeded
947
+
948
+ return success, message, rotated
949
+
950
+ async def _get_yield_info(self, inv: Inventory) -> YieldInfo:
951
+ yield_info = YieldInfo()
952
+
953
+ # Fetch external APYs in parallel
954
+ khype_apy, lhype_apy = await asyncio.gather(
955
+ fetch_khype_apy(), fetch_lhype_apy()
956
+ )
957
+ yield_info.khype_apy = khype_apy
958
+ yield_info.lhype_apy = lhype_apy
959
+
960
+ # Get Boros APR from active position
961
+ boros_notional = 0.0
962
+ if self.boros_adapter:
963
+ try:
964
+ success, positions = await self.boros_adapter.get_active_positions()
965
+ if success and positions:
966
+ pos = positions[0]
967
+ # fixedApr is the locked-in rate
968
+ fixed_apr = pos.get("fixedApr")
969
+ if fixed_apr is not None:
970
+ yield_info.boros_apr = float(fixed_apr)
971
+ boros_notional = abs(pos.get("notionalSizeFloat", 0) or 0)
972
+ if boros_notional == 0:
973
+ boros_notional = abs(inv.boros_position_size or 0)
974
+ except Exception as e:
975
+ logger.warning(f"Failed to get Boros APR: {e}")
976
+
977
+ # Calculate expected annual yields in USD
978
+
979
+ # kHYPE yield: APY applied to kHYPE value
980
+ if yield_info.khype_apy and inv.khype_value_usd > 0:
981
+ yield_info.khype_expected_yield_usd = (
982
+ inv.khype_value_usd * yield_info.khype_apy
983
+ )
984
+
985
+ # lHYPE yield: APY applied to lHYPE value
986
+ if yield_info.lhype_apy and inv.looped_hype_value_usd > 0:
987
+ yield_info.lhype_expected_yield_usd = (
988
+ inv.looped_hype_value_usd * yield_info.lhype_apy
989
+ )
990
+
991
+ # Boros yield: Locks in funding rate on perp position
992
+ if yield_info.boros_apr is not None and boros_notional > 0:
993
+ yield_info.boros_expected_yield_usd = yield_info.boros_apr * boros_notional
994
+
995
+ # Total expected yield
996
+ yield_info.total_expected_yield_usd = (
997
+ yield_info.khype_expected_yield_usd
998
+ + yield_info.lhype_expected_yield_usd
999
+ + yield_info.boros_expected_yield_usd
1000
+ )
1001
+
1002
+ # Blended APY based on total value
1003
+ if inv.total_value > 0:
1004
+ yield_info.blended_apy = (
1005
+ yield_info.total_expected_yield_usd / inv.total_value
1006
+ )
1007
+
1008
+ return yield_info
1009
+
1010
+ async def exit(self, **kwargs) -> StatusTuple:
1011
+ # Transfer remaining balances to main wallet (does NOT close positions - call withdraw() first)
1012
+ if not self.balance_adapter:
1013
+ return False, "Balance adapter not configured"
1014
+
1015
+ strategy_wallet = self._config.get("strategy_wallet", {})
1016
+ main_wallet = self._config.get("main_wallet", {})
1017
+ strategy_addr = strategy_wallet.get("address") if strategy_wallet else None
1018
+ main_addr = main_wallet.get("address") if main_wallet else None
1019
+
1020
+ if not strategy_addr or not main_addr:
1021
+ return False, "Strategy or main wallet address not configured"
1022
+
1023
+ if strategy_addr.lower() == main_addr.lower():
1024
+ return True, "Strategy wallet is main wallet, no transfer needed"
1025
+
1026
+ transferred = []
1027
+
1028
+ # Transfer USDC on Arbitrum
1029
+ ok, usdc_raw = await self.balance_adapter.get_vault_wallet_balance(USDC_ARB)
1030
+ if ok and isinstance(usdc_raw, int) and usdc_raw > 0:
1031
+ usdc_amount = usdc_raw / 1e6
1032
+ if usdc_amount > 0.01:
1033
+ (
1034
+ ok,
1035
+ msg,
1036
+ ) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
1037
+ USDC_ARB, usdc_amount, "boros_hype_strategy", skip_ledger=True
1038
+ )
1039
+ if ok:
1040
+ transferred.append(f"{usdc_amount:.2f} USDC")
1041
+ else:
1042
+ return False, f"USDC transfer failed: {msg}"
1043
+
1044
+ # Transfer USDT on Arbitrum
1045
+ ok, usdt_raw = await self.balance_adapter.get_vault_wallet_balance(USDT_ARB)
1046
+ if ok and isinstance(usdt_raw, int) and usdt_raw > 0:
1047
+ usdt_amount = usdt_raw / 1e6
1048
+ if usdt_amount > 0.01:
1049
+ (
1050
+ ok,
1051
+ msg,
1052
+ ) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
1053
+ USDT_ARB, usdt_amount, "boros_hype_strategy", skip_ledger=True
1054
+ )
1055
+ if ok:
1056
+ transferred.append(f"{usdt_amount:.2f} USDT")
1057
+ else:
1058
+ return False, f"USDT transfer failed: {msg}"
1059
+
1060
+ if transferred:
1061
+ return True, f"Transferred to main wallet: {', '.join(transferred)}"
1062
+ return True, "No balances to transfer"
1063
+
1064
+ async def _status(self) -> StatusDict:
1065
+ inv = await self.observe()
1066
+ alloc = self._get_allocation_status(inv)
1067
+ yield_info = await self._get_yield_info(inv)
1068
+
1069
+ # Build human-readable summary with full breakdown
1070
+ spot_parts = []
1071
+ if inv.khype_balance > 0.001:
1072
+ spot_parts.append(
1073
+ f"{inv.khype_balance:.4f} kHYPE (${inv.khype_value_usd:.2f})"
1074
+ )
1075
+ if inv.looped_hype_balance > 0.001:
1076
+ spot_parts.append(
1077
+ f"{inv.looped_hype_balance:.4f} lHYPE (${inv.looped_hype_value_usd:.2f})"
1078
+ )
1079
+ if inv.whype_balance > 0.001:
1080
+ spot_parts.append(
1081
+ f"{inv.whype_balance:.4f} WHYPE (${inv.whype_value_usd:.2f})"
1082
+ )
1083
+ if inv.hype_hyperevm_balance > 0.001:
1084
+ spot_parts.append(
1085
+ f"{inv.hype_hyperevm_balance:.4f} HYPE (${inv.hype_hyperevm_value_usd:.2f})"
1086
+ )
1087
+ if inv.hl_spot_hype > 0.001:
1088
+ spot_parts.append(
1089
+ f"{inv.hl_spot_hype:.4f} HYPE on HL spot (${inv.hl_spot_hype_value_usd:.2f})"
1090
+ )
1091
+
1092
+ if spot_parts:
1093
+ spot_summary = (
1094
+ f"Spot: {' + '.join(spot_parts)} = "
1095
+ f"{inv.total_hype_exposure:.4f} HYPE equivalent (${inv.spot_value_usd:.2f})"
1096
+ )
1097
+ else:
1098
+ spot_summary = f"Spot: No HYPE exposure (${inv.spot_value_usd:.2f})"
1099
+ hl_summary = (
1100
+ f"Hyperliquid: ${inv.hl_perp_margin:.2f} margin, "
1101
+ f"{inv.hl_short_size_hype:.4f} HYPE short (${inv.hl_short_value_usd:.2f} notional)"
1102
+ )
1103
+ boros_summary = (
1104
+ f"Boros: ${inv.boros_collateral_usd:.2f} collateral, "
1105
+ f"{inv.boros_position_size:.2f} YU position"
1106
+ )
1107
+
1108
+ # Yield summary
1109
+ yield_parts = []
1110
+ if yield_info.khype_apy is not None:
1111
+ yield_parts.append(
1112
+ f"kHYPE: {yield_info.khype_apy * 100:.2f}% APY (${yield_info.khype_expected_yield_usd:.2f}/yr)"
1113
+ )
1114
+ if yield_info.lhype_apy is not None:
1115
+ yield_parts.append(
1116
+ f"lHYPE: {yield_info.lhype_apy * 100:.2f}% APY (${yield_info.lhype_expected_yield_usd:.2f}/yr)"
1117
+ )
1118
+ if yield_info.boros_apr is not None:
1119
+ yield_parts.append(
1120
+ f"Boros: {yield_info.boros_apr * 100:.2f}% APR locked (${yield_info.boros_expected_yield_usd:.2f}/yr)"
1121
+ )
1122
+ yield_summary = (
1123
+ "Yields: " + ", ".join(yield_parts) if yield_parts else "Yields: N/A"
1124
+ )
1125
+
1126
+ if yield_info.blended_apy is not None:
1127
+ yield_summary += f"\nBlended APY: {yield_info.blended_apy * 100:.2f}% (${yield_info.total_expected_yield_usd:.2f}/yr expected)"
1128
+
1129
+ strategy_summary = (
1130
+ f"{spot_summary}\n{hl_summary}\n{boros_summary}\n{yield_summary}"
1131
+ )
1132
+
1133
+ return {
1134
+ "portfolio_value": inv.total_value,
1135
+ "net_deposit": 0.0,
1136
+ "strategy_summary": strategy_summary,
1137
+ "strategy_status": {
1138
+ "mode": "NORMAL",
1139
+ "allocations": {
1140
+ "spot": {
1141
+ "value": alloc.spot_value,
1142
+ "pct": alloc.spot_pct_actual,
1143
+ "target_pct": self.hedge_cfg.spot_pct,
1144
+ },
1145
+ "hyperliquid": {
1146
+ "value": alloc.hl_value,
1147
+ "pct": alloc.hl_pct_actual,
1148
+ "target_pct": self.hedge_cfg.hyperliquid_pct,
1149
+ },
1150
+ "boros": {
1151
+ "value": alloc.boros_value,
1152
+ "pct": alloc.boros_pct_actual,
1153
+ "target_pct": self.hedge_cfg.boros_pct,
1154
+ },
1155
+ },
1156
+ },
1157
+ "positions": {
1158
+ "spot": {
1159
+ "khype_balance": inv.khype_balance,
1160
+ "khype_value_usd": inv.khype_value_usd,
1161
+ "lhype_balance": inv.looped_hype_balance,
1162
+ "lhype_value_usd": inv.looped_hype_value_usd,
1163
+ "whype_balance": inv.whype_balance,
1164
+ "whype_value_usd": inv.whype_value_usd,
1165
+ "hype_hyperevm_balance": inv.hype_hyperevm_balance,
1166
+ "hype_hyperevm_value_usd": inv.hype_hyperevm_value_usd,
1167
+ "hl_spot_hype": inv.hl_spot_hype,
1168
+ "hl_spot_hype_value_usd": inv.hl_spot_hype_value_usd,
1169
+ "total_hype_equivalent": inv.total_hype_exposure,
1170
+ "total_spot_value_usd": inv.spot_value_usd,
1171
+ },
1172
+ "hyperliquid": {
1173
+ "perp_margin": inv.hl_perp_margin,
1174
+ "short_size_hype": inv.hl_short_size_hype,
1175
+ "short_value_usd": inv.hl_short_value_usd,
1176
+ },
1177
+ "boros": {
1178
+ "collateral_usd": inv.boros_collateral_usd,
1179
+ "position_size_yu": inv.boros_position_size,
1180
+ },
1181
+ },
1182
+ "yield_info": {
1183
+ "khype_apy": yield_info.khype_apy,
1184
+ "lhype_apy": yield_info.lhype_apy,
1185
+ "boros_apr": yield_info.boros_apr,
1186
+ "khype_expected_yield_usd": yield_info.khype_expected_yield_usd,
1187
+ "lhype_expected_yield_usd": yield_info.lhype_expected_yield_usd,
1188
+ "boros_expected_yield_usd": yield_info.boros_expected_yield_usd,
1189
+ "total_expected_yield_usd": yield_info.total_expected_yield_usd,
1190
+ "blended_apy": yield_info.blended_apy,
1191
+ },
1192
+ "gas_available": inv.hype_hyperevm_balance,
1193
+ "gassed_up": inv.hype_hyperevm_balance >= MIN_HYPE_GAS,
1194
+ }