wayfinder-paths 0.1.24__py3-none-any.whl → 0.1.27__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 (44) hide show
  1. wayfinder_paths/__init__.py +2 -0
  2. wayfinder_paths/adapters/brap_adapter/adapter.py +7 -47
  3. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +10 -31
  4. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +128 -60
  5. wayfinder_paths/adapters/hyperliquid_adapter/exchange.py +399 -0
  6. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +74 -0
  7. wayfinder_paths/adapters/hyperliquid_adapter/local_signer.py +82 -0
  8. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +1 -1
  9. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +1 -1
  10. wayfinder_paths/adapters/hyperliquid_adapter/util.py +237 -0
  11. wayfinder_paths/adapters/pendle_adapter/adapter.py +19 -55
  12. wayfinder_paths/adapters/pendle_adapter/test_adapter.py +14 -46
  13. wayfinder_paths/core/__init__.py +2 -0
  14. wayfinder_paths/core/clients/BalanceClient.py +72 -0
  15. wayfinder_paths/core/clients/TokenClient.py +1 -1
  16. wayfinder_paths/core/clients/__init__.py +2 -0
  17. wayfinder_paths/core/strategies/Strategy.py +3 -3
  18. wayfinder_paths/core/types.py +19 -0
  19. wayfinder_paths/core/utils/tokens.py +19 -1
  20. wayfinder_paths/core/utils/transaction.py +9 -7
  21. wayfinder_paths/mcp/tools/balances.py +122 -214
  22. wayfinder_paths/mcp/tools/execute.py +63 -41
  23. wayfinder_paths/mcp/tools/quotes.py +16 -5
  24. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +6 -22
  25. wayfinder_paths/strategies/boros_hype_strategy/boros_ops_mixin.py +227 -33
  26. wayfinder_paths/strategies/boros_hype_strategy/constants.py +17 -1
  27. wayfinder_paths/strategies/boros_hype_strategy/hyperevm_ops_mixin.py +44 -1
  28. wayfinder_paths/strategies/boros_hype_strategy/planner.py +87 -32
  29. wayfinder_paths/strategies/boros_hype_strategy/risk_ops_mixin.py +50 -28
  30. wayfinder_paths/strategies/boros_hype_strategy/strategy.py +71 -50
  31. wayfinder_paths/strategies/boros_hype_strategy/test_planner_golden.py +3 -1
  32. wayfinder_paths/strategies/boros_hype_strategy/test_strategy.py +0 -2
  33. wayfinder_paths/strategies/boros_hype_strategy/types.py +4 -1
  34. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +0 -2
  35. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +0 -2
  36. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +0 -2
  37. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +0 -2
  38. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +0 -2
  39. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +0 -2
  40. wayfinder_paths/tests/test_mcp_quote_swap.py +3 -3
  41. {wayfinder_paths-0.1.24.dist-info → wayfinder_paths-0.1.27.dist-info}/METADATA +2 -3
  42. {wayfinder_paths-0.1.24.dist-info → wayfinder_paths-0.1.27.dist-info}/RECORD +44 -39
  43. {wayfinder_paths-0.1.24.dist-info → wayfinder_paths-0.1.27.dist-info}/WHEEL +1 -1
  44. {wayfinder_paths-0.1.24.dist-info → wayfinder_paths-0.1.27.dist-info}/LICENSE +0 -0
@@ -19,14 +19,20 @@ from wayfinder_paths.core.utils.web3 import web3_from_chain_id
19
19
  from .constants import (
20
20
  BOROS_HYPE_MARKET_ID,
21
21
  BOROS_HYPE_TOKEN_ID,
22
- BOROS_MIN_DEPOSIT_USD,
22
+ BOROS_MIN_DEPOSIT_HYPE,
23
+ HYPE_NATIVE,
23
24
  HYPE_OFT_ABI,
24
25
  HYPE_OFT_ADDRESS,
25
26
  HYPEREVM_CHAIN_ID,
27
+ KHYPE_LST,
28
+ LOOPED_HYPE,
26
29
  LZ_EID_ARBITRUM,
27
30
  MIN_HYPE_GAS,
28
31
  USDC_ARB,
29
32
  USDT_ARB,
33
+ WHYPE,
34
+ WHYPE_ABI,
35
+ WHYPE_ADDRESS,
30
36
  )
31
37
  from .types import Inventory
32
38
 
@@ -67,8 +73,8 @@ class BorosHypeBorosOpsMixin:
67
73
  if not collateral_address:
68
74
  collateral_address = HYPE_OFT_ADDRESS
69
75
 
70
- if amount_usd < BOROS_MIN_DEPOSIT_USD:
71
- return True, f"Skipping small Boros funding (${amount_usd:.2f})"
76
+ if amount_usd < 1.0:
77
+ return True, f"Skipping tiny Boros funding (${amount_usd:.2f})"
72
78
 
73
79
  if self.simulation:
74
80
  return True, f"[SIMULATION] Funded Boros with ~${amount_usd:.2f} HYPE"
@@ -96,28 +102,156 @@ class BorosHypeBorosOpsMixin:
96
102
  if available_oft_hype > 0:
97
103
  deposit_hype = min(available_oft_hype, target_hype)
98
104
  deposit_usd = deposit_hype * hype_price
99
- if deposit_usd < 1.0:
100
- return True, f"Skipping tiny OFT HYPE deposit (${deposit_usd:.2f})"
105
+ if deposit_usd >= 1.0:
106
+ deposit_wei = int(deposit_hype * 1e18)
107
+ ok_dep, dep_res = await self.boros_adapter.deposit_to_cross_margin(
108
+ collateral_address=collateral_address,
109
+ amount_wei=deposit_wei,
110
+ token_id=token_id,
111
+ market_id=market_id,
112
+ )
113
+ if not ok_dep:
114
+ return False, f"Boros deposit failed: {dep_res}"
101
115
 
102
- deposit_wei = int(deposit_hype * 1e18)
103
- ok_dep, dep_res = await self.boros_adapter.deposit_to_cross_margin(
104
- collateral_address=collateral_address,
105
- amount_wei=deposit_wei,
106
- token_id=token_id,
107
- market_id=market_id,
108
- )
109
- if not ok_dep:
110
- return False, f"Boros deposit failed: {dep_res}"
116
+ logger.info(
117
+ f"Deposited {deposit_hype:.6f} HYPE (≈${deposit_usd:.2f}) to Boros cross margin"
118
+ )
111
119
 
112
- logger.info(
113
- f"Deposited {deposit_hype:.6f} HYPE (≈${deposit_usd:.2f}) to Boros cross margin"
114
- )
115
- return True, (
116
- f"Funded Boros with {deposit_hype:.6f} HYPE (≈${deposit_usd:.2f}) from Arbitrum OFT balance"
117
- )
120
+ # If this fully satisfies the target, stop here.
121
+ if deposit_hype >= target_hype - 1e-9:
122
+ return True, (
123
+ f"Funded Boros with {deposit_hype:.6f} HYPE (≈${deposit_usd:.2f}) "
124
+ "from Arbitrum OFT balance"
125
+ )
126
+
127
+ # Otherwise, bridge the remainder.
128
+ target_hype = max(0.0, target_hype - deposit_hype)
129
+ target_wei = int(target_hype * 1e18)
130
+ else:
131
+ logger.info(
132
+ f"Skipping tiny OFT HYPE deposit (${deposit_usd:.2f}); will top up via bridge"
133
+ )
118
134
 
119
135
  # 2) Bridge native HYPE from HyperEVM to Arbitrum using the OFT contract.
120
136
  hype_balance = float(inventory.hype_hyperevm_balance or 0.0)
137
+ whype_balance = float(inventory.whype_balance or 0.0)
138
+ fee_buffer_hype = 0.03 # conservative buffer for OFT native fee
139
+ desired_native_hype = float(MIN_HYPE_GAS) + float(target_hype) + fee_buffer_hype
140
+ if whype_balance > 0.0:
141
+ # On HyperEVM, HYPE can be split between native HYPE and WHYPE. The OFT
142
+ # bridge requires native HYPE (msg.value), so unwrap enough WHYPE to
143
+ # cover (amount + fee) while still leaving MIN_HYPE_GAS for future gas.
144
+ if hype_balance < desired_native_hype:
145
+ unwrap_hype = min(whype_balance, desired_native_hype - hype_balance)
146
+ unwrap_wei = int(unwrap_hype * 1e18)
147
+ if self.balance_adapter:
148
+ (
149
+ ok_whype,
150
+ whype_raw,
151
+ ) = await self.balance_adapter.get_vault_wallet_balance(WHYPE)
152
+ if ok_whype and isinstance(whype_raw, int) and whype_raw > 0:
153
+ unwrap_wei = min(int(unwrap_wei), int(whype_raw))
154
+ if unwrap_wei > 0:
155
+ if not self._sign_callback:
156
+ return False, "No signing callback configured"
157
+
158
+ logger.info(
159
+ f"Unwrapping {unwrap_hype:.6f} WHYPE → native HYPE to fund OFT bridge"
160
+ )
161
+ try:
162
+ tx = await encode_call(
163
+ target=WHYPE_ADDRESS,
164
+ abi=WHYPE_ABI,
165
+ fn_name="withdraw",
166
+ args=[int(unwrap_wei)],
167
+ from_address=wallet_address,
168
+ chain_id=HYPEREVM_CHAIN_ID,
169
+ )
170
+ tx_hash = await send_transaction(
171
+ tx, self._sign_callback, wait_for_receipt=True
172
+ )
173
+ logger.info(f"WHYPE unwrap tx={tx_hash}")
174
+ await asyncio.sleep(2)
175
+ except Exception as exc: # noqa: BLE001
176
+ logger.warning(f"WHYPE unwrap failed: {exc}")
177
+
178
+ if self.balance_adapter:
179
+ (
180
+ ok_hype,
181
+ hype_raw,
182
+ ) = await self.balance_adapter.get_vault_wallet_balance(
183
+ HYPE_NATIVE
184
+ )
185
+ if ok_hype and hype_raw:
186
+ hype_balance = float(hype_raw) / 1e18
187
+
188
+ # If we still don't have enough native HYPE and there are LSTs on HyperEVM,
189
+ # sell a small amount back to native HYPE so Boros can be funded.
190
+ if (
191
+ hype_balance < desired_native_hype
192
+ and (inventory.khype_value_usd > 0 or inventory.looped_hype_value_usd > 0)
193
+ and self.brap_adapter
194
+ ):
195
+ needed_hype = max(0.0, desired_native_hype - hype_balance)
196
+ if needed_hype > 0.01:
197
+ if inventory.khype_value_usd > 0 and inventory.khype_to_hype_ratio > 0:
198
+ max_hype_from_khype = float(inventory.khype_value_usd) / hype_price
199
+ sell_hype = min(needed_hype, max_hype_from_khype)
200
+ sell_khype = sell_hype / float(inventory.khype_to_hype_ratio)
201
+ sell_khype_wei = int(sell_khype * 1e18)
202
+ if sell_khype_wei > 0:
203
+ logger.info(
204
+ f"Selling {sell_khype:.6f} kHYPE → native HYPE to fund Boros"
205
+ )
206
+ ok_swap, res_swap = await self.brap_adapter.swap_from_token_ids(
207
+ from_token_id=KHYPE_LST,
208
+ to_token_id=HYPE_NATIVE,
209
+ from_address=wallet_address,
210
+ amount=str(int(sell_khype_wei)),
211
+ slippage=0.01,
212
+ strategy_name="boros_hype_strategy",
213
+ )
214
+ if ok_swap:
215
+ needed_hype = max(0.0, needed_hype - sell_hype)
216
+ await asyncio.sleep(2)
217
+ else:
218
+ logger.warning(f"Sell kHYPE→HYPE failed: {res_swap}")
219
+
220
+ if (
221
+ needed_hype > 0.01
222
+ and inventory.looped_hype_value_usd > 0
223
+ and inventory.looped_hype_to_hype_ratio > 0
224
+ ):
225
+ max_hype_from_lhype = (
226
+ float(inventory.looped_hype_value_usd) / hype_price
227
+ )
228
+ sell_hype = min(needed_hype, max_hype_from_lhype)
229
+ sell_lhype = sell_hype / float(inventory.looped_hype_to_hype_ratio)
230
+ sell_lhype_wei = int(sell_lhype * 1e18)
231
+ if sell_lhype_wei > 0:
232
+ logger.info(
233
+ f"Selling {sell_lhype:.6f} looped HYPE → native HYPE to fund Boros"
234
+ )
235
+ ok_swap, res_swap = await self.brap_adapter.swap_from_token_ids(
236
+ from_token_id=LOOPED_HYPE,
237
+ to_token_id=HYPE_NATIVE,
238
+ from_address=wallet_address,
239
+ amount=str(int(sell_lhype_wei)),
240
+ slippage=0.01,
241
+ strategy_name="boros_hype_strategy",
242
+ )
243
+ if ok_swap:
244
+ await asyncio.sleep(2)
245
+ else:
246
+ logger.warning(f"Sell looped HYPE→HYPE failed: {res_swap}")
247
+
248
+ if self.balance_adapter:
249
+ (
250
+ ok_hype,
251
+ hype_raw,
252
+ ) = await self.balance_adapter.get_vault_wallet_balance(HYPE_NATIVE)
253
+ if ok_hype and hype_raw:
254
+ hype_balance = float(hype_raw) / 1e18
121
255
  if hype_balance <= MIN_HYPE_GAS + 0.0005:
122
256
  return (
123
257
  False,
@@ -142,6 +276,16 @@ class BorosHypeBorosOpsMixin:
142
276
  abi=HYPE_OFT_ABI,
143
277
  )
144
278
 
279
+ conversion_rate = int(
280
+ await contract.functions.decimalConversionRate().call()
281
+ ) # OFT sharedDecimals rounding
282
+ if conversion_rate > 0:
283
+ bridge_amount_wei = (
284
+ bridge_amount_wei // conversion_rate
285
+ ) * conversion_rate
286
+ if bridge_amount_wei <= 0:
287
+ return True, "Boros funding: amount too small after OFT rounding"
288
+
145
289
  # Quote fee, then clamp amount to fit balance (amount + fee) while
146
290
  # still leaving MIN_HYPE_GAS behind for future gas.
147
291
  send_params = (
@@ -158,6 +302,10 @@ class BorosHypeBorosOpsMixin:
158
302
  lz_token_fee = int(fee[1])
159
303
 
160
304
  max_send_amount_wei = max(0, max_value_wei - native_fee)
305
+ if conversion_rate > 0:
306
+ max_send_amount_wei = (
307
+ max_send_amount_wei // conversion_rate
308
+ ) * conversion_rate
161
309
  if bridge_amount_wei > max_send_amount_wei:
162
310
  bridge_amount_wei = max_send_amount_wei
163
311
  if bridge_amount_wei <= 0:
@@ -230,7 +378,7 @@ class BorosHypeBorosOpsMixin:
230
378
  or self._planner_runtime.current_boros_token_id
231
379
  or BOROS_HYPE_TOKEN_ID
232
380
  )
233
- target_size_usd = float(params.get("target_size_usd") or 0.0)
381
+ target_size_yu = float(params.get("target_size_yu") or 0.0)
234
382
 
235
383
  if inventory.boros_pending_withdrawal_usd > 0:
236
384
  return True, (
@@ -244,14 +392,14 @@ class BorosHypeBorosOpsMixin:
244
392
  if self.simulation:
245
393
  return (
246
394
  True,
247
- f"[SIMULATION] Boros position at market {market_id} set to ${target_size_usd:.0f}",
395
+ f"[SIMULATION] Boros position at market {market_id} set to {target_size_yu:.4f} YU",
248
396
  )
249
397
 
250
398
  try:
251
399
  return await self._ensure_boros_position_impl(
252
400
  market_id=market_id,
253
401
  token_id=token_id,
254
- target_size_usd=target_size_usd,
402
+ target_size_yu=target_size_yu,
255
403
  inventory=inventory,
256
404
  )
257
405
  except Exception as exc:
@@ -268,9 +416,21 @@ class BorosHypeBorosOpsMixin:
268
416
  *,
269
417
  market_id: int,
270
418
  token_id: int,
271
- target_size_usd: float,
419
+ target_size_yu: float,
272
420
  inventory: Inventory,
273
421
  ) -> tuple[bool, str]:
422
+ # Only try to manage the rate position once we have at least the minimum
423
+ # HYPE collateral funded on Boros (or sitting idle as OFT HYPE on Arbitrum).
424
+ depositable_hype = float(inventory.boros_collateral_hype or 0.0) + float(
425
+ inventory.hype_oft_arb_balance or 0.0
426
+ )
427
+ if depositable_hype < BOROS_MIN_DEPOSIT_HYPE:
428
+ return (
429
+ True,
430
+ "Skipping Boros position: collateral not funded yet "
431
+ f"({depositable_hype:.6f} HYPE)",
432
+ )
433
+
274
434
  # 0) Move any isolated collateral to cross margin (cleanup).
275
435
  # Boros markets expire, so we need to get the actual market ID from isolated positions.
276
436
  try:
@@ -331,6 +491,38 @@ class BorosHypeBorosOpsMixin:
331
491
  except Exception as exc: # noqa: BLE001
332
492
  logger.warning(f"Failed to deposit OFT HYPE to Boros: {exc}")
333
493
 
494
+ # Deposits can land as isolated cash for the given market_id; ensure we
495
+ # sweep isolated -> cross again before attempting to trade.
496
+ try:
497
+ ok_bal, balances = await self.boros_adapter.get_account_balances(
498
+ token_id=int(token_id)
499
+ )
500
+ if ok_bal and isinstance(balances, dict):
501
+ isolated_positions = balances.get("isolated_positions", [])
502
+ for iso_pos in isolated_positions:
503
+ iso_market_id = iso_pos.get("market_id")
504
+ iso_balance = float(iso_pos.get("balance", 0) or 0.0)
505
+ if iso_market_id and iso_balance > 0.001:
506
+ iso_wei = int(iso_balance * 1e18) # Boros cash units
507
+ logger.info(
508
+ f"Moving {iso_balance:.6f} collateral from isolated market {iso_market_id} to cross"
509
+ )
510
+ ok_xfer, res_xfer = await self.boros_adapter.cash_transfer(
511
+ market_id=int(iso_market_id),
512
+ amount_wei=iso_wei,
513
+ is_deposit=False, # isolated -> cross
514
+ )
515
+ if ok_xfer:
516
+ await asyncio.sleep(2)
517
+ else:
518
+ logger.warning(
519
+ f"Failed Boros isolated->cross transfer for market {iso_market_id}: {res_xfer}"
520
+ )
521
+ except Exception as exc: # noqa: BLE001
522
+ logger.warning(
523
+ f"Failed Boros isolated->cross transfer after deposit: {exc}"
524
+ )
525
+
334
526
  # 2) Rollover: close positions in other markets (best effort).
335
527
  try:
336
528
  for mid in inventory.boros_position_market_ids or []:
@@ -356,18 +548,20 @@ class BorosHypeBorosOpsMixin:
356
548
  if not success:
357
549
  return False, "Failed to get Boros positions"
358
550
 
359
- current_size_usd = 0.0
360
551
  if positions:
361
552
  pos = positions[0]
362
- current_size_usd = abs(float(pos.get("size", 0) or 0.0))
553
+ current_size_yu = abs(float(pos.get("size", 0) or 0.0))
554
+ else:
555
+ current_size_yu = 0.0
363
556
 
364
- diff_usd = float(target_size_usd) - float(current_size_usd)
365
- if abs(diff_usd) < self._planner_config.boros_resize_min_excess_usd:
366
- return True, f"Boros position already at target (${current_size_usd:.0f})"
557
+ diff_yu = float(target_size_yu) - float(current_size_yu)
558
+ diff_usd_equiv = abs(diff_yu) * float(inventory.hype_price_usd or 0.0)
559
+ if diff_usd_equiv < self._planner_config.boros_resize_min_excess_usd:
560
+ return True, f"Boros position already at target ({current_size_yu:.4f} YU)"
367
561
 
368
- size_yu_wei = int(abs(diff_usd) * 1e18) # Boros YU wei
562
+ size_yu_wei = int(abs(diff_yu) * 1e18) # Boros YU wei
369
563
 
370
- if diff_usd > 0:
564
+ if diff_yu > 0:
371
565
  # Open/increase SHORT side (receive fixed)
372
566
  ok_open, open_res = await self.boros_adapter.place_rate_order(
373
567
  market_id=int(market_id),
@@ -380,7 +574,7 @@ class BorosHypeBorosOpsMixin:
380
574
  return False, f"Failed to open Boros position: {open_res}"
381
575
  return (
382
576
  True,
383
- f"Boros position increased by ${diff_usd:.0f} on market {market_id}",
577
+ f"Boros position increased by {diff_yu:.4f} YU on market {market_id}",
384
578
  )
385
579
 
386
580
  # Close/decrease position
@@ -394,7 +588,7 @@ class BorosHypeBorosOpsMixin:
394
588
 
395
589
  return (
396
590
  True,
397
- f"Boros position decreased by ${abs(diff_usd):.0f} on market {market_id}",
591
+ f"Boros position decreased by {abs(diff_yu):.4f} YU on market {market_id}",
398
592
  )
399
593
 
400
594
  async def _complete_pending_withdrawal(
@@ -94,7 +94,9 @@ HORIZON_DAYS = 7 # Planning horizon in days
94
94
  # entry path uses HYPE collateral (via the LayerZero OFT token on Arbitrum).
95
95
  BOROS_HYPE_MARKET_ID = 51 # HYPERLIQUID-HYPE-27FEB2026 (fallback)
96
96
  BOROS_HYPE_TOKEN_ID = 5 # HYPE collateral token ID on Boros (current)
97
- BOROS_MIN_DEPOSIT_USD = 10.50 # Minimum collateral value to bother funding
97
+ # Boros requires a minimum cross-margin cash balance per token to place orders.
98
+ # For the HYPE collateral token (tokenId=5) this is currently 0.4 HYPE (MarketHub.getCashFeeData()).
99
+ BOROS_MIN_DEPOSIT_HYPE = 0.4
98
100
  BOROS_MIN_TENOR_DAYS = 3 # Roll to new market if < 3 days to expiry
99
101
  BOROS_ENABLE_MIN_TOTAL_USD = 80.0 # Skip Boros if capital below this
100
102
 
@@ -237,6 +239,20 @@ HYPE_OFT_ABI = [
237
239
  "stateMutability": "payable",
238
240
  "type": "function",
239
241
  },
242
+ {
243
+ "inputs": [],
244
+ "name": "sharedDecimals",
245
+ "outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}],
246
+ "stateMutability": "view",
247
+ "type": "function",
248
+ },
249
+ {
250
+ "inputs": [],
251
+ "name": "decimalConversionRate",
252
+ "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
253
+ "stateMutability": "view",
254
+ "type": "function",
255
+ },
240
256
  ]
241
257
 
242
258
  # ─────────────────────────────────────────────────────────────────────────────
@@ -6,9 +6,22 @@ Kept as a mixin so the main strategy file stays readable without changing behavi
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import asyncio
9
10
  from typing import Any
10
11
 
11
- from .constants import HYPE_NATIVE, KHYPE_LST, LOOPED_HYPE, MIN_HYPE_GAS
12
+ from loguru import logger
13
+
14
+ from wayfinder_paths.core.utils.transaction import encode_call, send_transaction
15
+
16
+ from .constants import (
17
+ HYPE_NATIVE,
18
+ HYPEREVM_CHAIN_ID,
19
+ KHYPE_LST,
20
+ LOOPED_HYPE,
21
+ MIN_HYPE_GAS,
22
+ WHYPE_ABI,
23
+ WHYPE_ADDRESS,
24
+ )
12
25
  from .types import Inventory
13
26
 
14
27
 
@@ -21,6 +34,36 @@ class BorosHypeHyperEvmOpsMixin:
21
34
  if need <= 0.0:
22
35
  return True, "HyperEVM gas already sufficient"
23
36
 
37
+ # If we have WHYPE on HyperEVM, unwrap a small amount to native HYPE for gas.
38
+ if float(inventory.whype_balance or 0.0) > 0.0:
39
+ strategy_wallet = self._config.get("strategy_wallet", {})
40
+ address = strategy_wallet.get("address")
41
+ if address and self._sign_callback:
42
+ unwrap_hype = min(
43
+ float(inventory.whype_balance or 0.0), max(0.01, need + 0.002)
44
+ )
45
+ unwrap_wei = int(unwrap_hype * 1e18)
46
+ if unwrap_wei > 0:
47
+ logger.info(
48
+ f"Unwrapping {unwrap_hype:.6f} WHYPE → native HYPE for gas"
49
+ )
50
+ tx = await encode_call(
51
+ target=WHYPE_ADDRESS,
52
+ abi=WHYPE_ABI,
53
+ fn_name="withdraw",
54
+ args=[int(unwrap_wei)],
55
+ from_address=address,
56
+ chain_id=HYPEREVM_CHAIN_ID,
57
+ )
58
+ tx_hash = await send_transaction(
59
+ tx, self._sign_callback, wait_for_receipt=True
60
+ )
61
+ await asyncio.sleep(2)
62
+ return (
63
+ True,
64
+ f"Unwrapped {unwrap_hype:.6f} WHYPE for gas (tx={tx_hash})",
65
+ )
66
+
24
67
  # Best-effort: if HYPE exists on HL spot, bridge it over.
25
68
  if inventory.hl_spot_hype > max(0.1, need + 0.001):
26
69
  return await self._transfer_hl_spot_to_hyperevm(
@@ -13,7 +13,13 @@ from datetime import datetime
13
13
 
14
14
  from wayfinder_paths.adapters.boros_adapter import BorosMarketQuote
15
15
 
16
- from .constants import BOROS_MIN_DEPOSIT_USD, BOROS_MIN_TENOR_DAYS, MIN_HYPE_GAS
16
+ from .constants import (
17
+ BOROS_HYPE_TOKEN_ID,
18
+ BOROS_MIN_DEPOSIT_HYPE,
19
+ BOROS_MIN_TENOR_DAYS,
20
+ MAX_HL_LEVERAGE,
21
+ MIN_HYPE_GAS,
22
+ )
17
23
  from .types import (
18
24
  AllocationStatus,
19
25
  DesiredState,
@@ -147,7 +153,7 @@ def build_plan(
147
153
  target_hl_margin_usd=0,
148
154
  target_boros_collateral_usd=0,
149
155
  target_hype_short_size=0,
150
- target_boros_position_usd=0,
156
+ target_boros_position_yu=0,
151
157
  ),
152
158
  )
153
159
  plan.messages.append(
@@ -165,7 +171,7 @@ def build_plan(
165
171
  target_hl_margin_usd=0,
166
172
  target_boros_collateral_usd=0,
167
173
  target_hype_short_size=0,
168
- target_boros_position_usd=0,
174
+ target_boros_position_yu=0,
169
175
  ),
170
176
  )
171
177
  plan.messages.append(
@@ -203,9 +209,12 @@ def build_plan(
203
209
  target_hl = total * hedge_cfg.hyperliquid_pct
204
210
 
205
211
  # Boros target: use MAX of percentage allocation and minimum deposit + buffer.
206
- # We keep a small buffer so the USDC→USDT swap doesn't undershoot the Boros minimum.
212
+ # We keep a small buffer so rounding doesn't undershoot the Boros minimum.
207
213
  target_boros = (
208
- max(hedge_cfg.boros_pct * total, BOROS_MIN_DEPOSIT_USD + 1.0)
214
+ max(
215
+ hedge_cfg.boros_pct * total,
216
+ (BOROS_MIN_DEPOSIT_HYPE + 0.01) * float(inv.hype_price_usd or 0.0),
217
+ )
209
218
  if boros_enabled
210
219
  else 0.0
211
220
  )
@@ -223,12 +232,19 @@ def build_plan(
223
232
  # Short target = current HYPE exposure (to hedge what we actually have)
224
233
  target_hype_short = inv.total_hype_exposure
225
234
 
226
- # Boros coverage target = percentage of spot exposure
227
- target_boros_position = (
228
- target_hype_short * inv.hype_price_usd * config.boros_coverage_target
229
- if boros_enabled and market_id
230
- else 0.0
231
- )
235
+ # Boros position sizing is in YU, but YU units depend on the collateral token:
236
+ # - HYPE collateral: 1 YU = 1 HYPE
237
+ # - USDT collateral: 1 YU = 1 USDT (≈$1)
238
+ # For HYPE collateral we size to HYPE exposure; for USDT we size to USD notional.
239
+ if boros_enabled and market_id:
240
+ if boros_token_id == BOROS_HYPE_TOKEN_ID:
241
+ target_boros_position_yu = target_hype_short * config.boros_coverage_target
242
+ else:
243
+ target_boros_position_yu = (
244
+ target_hype_short * inv.hype_price_usd * config.boros_coverage_target
245
+ )
246
+ else:
247
+ target_boros_position_yu = 0.0
232
248
 
233
249
  desired = DesiredState(
234
250
  mode=mode,
@@ -236,7 +252,7 @@ def build_plan(
236
252
  target_hl_margin_usd=target_hl,
237
253
  target_boros_collateral_usd=target_boros,
238
254
  target_hype_short_size=target_hype_short,
239
- target_boros_position_usd=target_boros_position,
255
+ target_boros_position_yu=target_boros_position_yu,
240
256
  boros_market_id=market_id,
241
257
  boros_market_symbol=market_symbol,
242
258
  boros_tenor_days=tenor_days,
@@ -302,19 +318,18 @@ def build_plan(
302
318
  0.0, target_boros - float(inv.boros_committed_collateral_usd or 0.0)
303
319
  )
304
320
  if boros_shortfall >= config.min_usdt_action:
305
- if boros_shortfall >= BOROS_MIN_DEPOSIT_USD:
306
- plan.add_step(
307
- PlanOp.FUND_BOROS,
308
- priority=10,
309
- key=f"fund_boros_{boros_shortfall:.2f}",
310
- params={
311
- "amount_usd": float(boros_shortfall),
312
- "market_id": int(market_id) if market_id else None,
313
- "token_id": int(boros_token_id) if boros_token_id else None,
314
- "collateral_address": str(boros_collateral_address or ""),
315
- },
316
- reason=f"Funding Boros toward target (${target_boros:.2f}) via HYPE collateral",
317
- )
321
+ plan.add_step(
322
+ PlanOp.FUND_BOROS,
323
+ priority=10,
324
+ key=f"fund_boros_{boros_shortfall:.2f}",
325
+ params={
326
+ "amount_usd": float(boros_shortfall),
327
+ "market_id": int(market_id) if market_id else None,
328
+ "token_id": int(boros_token_id) if boros_token_id else None,
329
+ "collateral_address": str(boros_collateral_address or ""),
330
+ },
331
+ reason=f"Funding Boros toward target (${target_boros:.2f}) via HYPE collateral",
332
+ )
318
333
 
319
334
  # Send USDC to Hyperliquid ONCE to cover both:
320
335
  # - HL margin allocation
@@ -403,6 +418,32 @@ def build_plan(
403
418
  target_hype_short * config.delta_neutral_rel_tol,
404
419
  )
405
420
 
421
+ # If we can't hedge to within tolerance due to margin constraints, trim spot first.
422
+ # (This avoids safety-triggered liquidation loops when HL free margin is too low.)
423
+ hedge_increase_needed = float(target_hype_short) - float(current_short)
424
+ if hedge_increase_needed > 0 and target_hype_short > 0.1:
425
+ hype_price = float(inv.hype_price_usd or 0.0)
426
+ free_margin = float(inv.hl_withdrawable_usd or 0.0)
427
+ max_increase_by_margin = (
428
+ (free_margin * MAX_HL_LEVERAGE) / hype_price if hype_price > 0 else 0.0
429
+ )
430
+ if hedge_increase_needed > max_increase_by_margin + tolerance:
431
+ trim_hype = hedge_increase_needed - (max_increase_by_margin + tolerance)
432
+ trim_usd = trim_hype * hype_price
433
+ if inv.spot_value_usd > 0:
434
+ trim_pct = min(0.50, trim_usd / float(inv.spot_value_usd))
435
+ if trim_pct > 0.02:
436
+ plan.add_step(
437
+ PlanOp.PARTIAL_TRIM_SPOT,
438
+ priority=0,
439
+ key="partial_trim_spot_for_margin",
440
+ params={"trim_pct": float(trim_pct)},
441
+ reason=(
442
+ "Insufficient HL free margin to hedge within tolerance; "
443
+ f"trimming spot by ~${trim_usd:.2f}"
444
+ ),
445
+ )
446
+
406
447
  if short_delta > tolerance and target_hype_short > 0.1:
407
448
  plan.add_step(
408
449
  PlanOp.ENSURE_HL_SHORT,
@@ -414,14 +455,28 @@ def build_plan(
414
455
 
415
456
  # Priority 30: Rate positions (Boros)
416
457
  if boros_enabled and market_id:
417
- current_boros_size = inv.boros_position_size
418
- size_diff = abs(target_boros_position - current_boros_size)
458
+ # Only try to manage rate positions once collateral is actually available.
459
+ # (In-flight OFT bridges are tracked separately; they can't be used yet.)
460
+ depositable_collateral_hype = float(inv.boros_collateral_hype or 0.0) + float(
461
+ inv.hype_oft_arb_balance or 0.0
462
+ )
463
+ if depositable_collateral_hype < BOROS_MIN_DEPOSIT_HYPE:
464
+ plan.messages.append(
465
+ "Skipping Boros position: collateral not funded yet "
466
+ f"({depositable_collateral_hype:.6f} HYPE)"
467
+ )
468
+ plan.sort_steps()
469
+ return plan
470
+
471
+ current_boros_size_yu = float(inv.boros_position_size or 0.0)
472
+ size_diff_yu = abs(target_boros_position_yu - current_boros_size_yu)
473
+ size_diff_usd = size_diff_yu * float(inv.hype_price_usd or 0.0)
419
474
 
420
475
  # Trigger if:
421
476
  # 1. Position size needs adjustment (resize threshold met)
422
477
  # 2. There's isolated collateral that needs to move to cross (market expiry/rotation)
423
478
  # 3. There's cross collateral but no position (collateral sitting idle)
424
- needs_position_resize = size_diff > config.boros_resize_min_excess_usd
479
+ needs_position_resize = size_diff_usd > config.boros_resize_min_excess_usd
425
480
  isolated_usd = float(inv.boros_idle_collateral_isolated or 0.0) * float(
426
481
  inv.hype_price_usd or 0.0
427
482
  )
@@ -429,10 +484,10 @@ def build_plan(
429
484
  inv.hype_price_usd or 0.0
430
485
  )
431
486
  has_stranded_isolated = isolated_usd > 0.5 # $0.50 threshold
432
- has_idle_cross = cross_usd > 1.0 and current_boros_size < 1.0
487
+ has_idle_cross = cross_usd > 1.0 and current_boros_size_yu < 0.01
433
488
 
434
489
  if needs_position_resize or has_stranded_isolated or has_idle_cross:
435
- reason = f"Adjusting Boros position to {target_boros_position:.0f} YU"
490
+ reason = f"Adjusting Boros position to {target_boros_position_yu:.4f} YU"
436
491
  if has_stranded_isolated and not needs_position_resize:
437
492
  reason = (
438
493
  "Moving "
@@ -448,10 +503,10 @@ def build_plan(
448
503
  plan.add_step(
449
504
  PlanOp.ENSURE_BOROS_POSITION,
450
505
  priority=20, # Same as ENSURE_HL_SHORT so both execute before re-observe
451
- key=f"ensure_boros_pos_{target_boros_position:.0f}",
506
+ key=f"ensure_boros_pos_{target_boros_position_yu:.4f}",
452
507
  params={
453
508
  "market_id": market_id,
454
- "target_size_usd": target_boros_position,
509
+ "target_size_yu": target_boros_position_yu,
455
510
  },
456
511
  reason=reason,
457
512
  )