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,460 @@
1
+ """Planner for BorosHypeStrategy.
2
+
3
+ This module is intentionally mostly-pure: given the current Inventory snapshot
4
+ and configuration, it outputs a prioritized list of PlanOp steps.
5
+
6
+ Extracted from `strategy.py` to make the strategy easier to read/refactor and to
7
+ enable independent golden testing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from datetime import datetime
13
+
14
+ from wayfinder_paths.adapters.boros_adapter import BorosMarketQuote
15
+
16
+ from .constants import BOROS_MIN_DEPOSIT_USD, BOROS_MIN_TENOR_DAYS, MIN_HYPE_GAS
17
+ from .types import (
18
+ AllocationStatus,
19
+ DesiredState,
20
+ HedgeConfig,
21
+ Inventory,
22
+ Mode,
23
+ Plan,
24
+ PlannerConfig,
25
+ PlannerRuntime,
26
+ PlanOp,
27
+ )
28
+
29
+
30
+ def _choose_boros_market(
31
+ quotes: list[BorosMarketQuote],
32
+ runtime: PlannerRuntime,
33
+ config: PlannerConfig,
34
+ ) -> tuple[int | None, str | None, float | None, int | None, str | None]:
35
+ # Hysteresis to prevent thrashing. Updates runtime state when a new market is selected.
36
+ if not quotes:
37
+ return None, None, None, None, None
38
+
39
+ valid_quotes = [q for q in quotes if q.market_id is not None]
40
+ if not valid_quotes:
41
+ return None, None, None, None, None
42
+
43
+ # Sort by tenor (prefer longer tenors for reduced rollover frequency)
44
+ sorted_quotes = sorted(
45
+ valid_quotes,
46
+ key=lambda q: (q.tenor_days or 0, q.mid_apr or 0),
47
+ reverse=True,
48
+ )
49
+
50
+ sorted_quotes = [
51
+ q for q in sorted_quotes if (q.tenor_days or 0) >= BOROS_MIN_TENOR_DAYS
52
+ ]
53
+ if not sorted_quotes:
54
+ return None, None, None, None, None
55
+
56
+ best = sorted_quotes[0]
57
+
58
+ if runtime.current_boros_market_id is None:
59
+ runtime.current_boros_market_id = best.market_id
60
+ runtime.current_boros_token_id = best.collateral_token_id
61
+ runtime.current_boros_collateral_address = best.collateral_address
62
+ runtime.boros_market_selected_at = datetime.utcnow()
63
+ return (
64
+ best.market_id,
65
+ best.symbol,
66
+ best.tenor_days,
67
+ best.collateral_token_id,
68
+ best.collateral_address,
69
+ )
70
+
71
+ current = next(
72
+ (q for q in sorted_quotes if q.market_id == runtime.current_boros_market_id),
73
+ None,
74
+ )
75
+
76
+ if current is None: # Current market gone/invalid - switch to best
77
+ runtime.current_boros_market_id = best.market_id
78
+ runtime.current_boros_token_id = best.collateral_token_id
79
+ runtime.current_boros_collateral_address = best.collateral_address
80
+ runtime.boros_market_selected_at = datetime.utcnow()
81
+ return (
82
+ best.market_id,
83
+ best.symbol,
84
+ best.tenor_days,
85
+ best.collateral_token_id,
86
+ best.collateral_address,
87
+ )
88
+
89
+ if runtime.boros_market_selected_at: # Check cooldown
90
+ hours_since = (
91
+ datetime.utcnow() - runtime.boros_market_selected_at
92
+ ).total_seconds() / 3600
93
+ if hours_since < config.boros_market_switch_cooldown_hours:
94
+ return (
95
+ current.market_id,
96
+ current.symbol,
97
+ current.tenor_days,
98
+ current.collateral_token_id,
99
+ current.collateral_address,
100
+ )
101
+
102
+ # Only switch if APR improvement exceeds threshold
103
+ if best.market_id != current.market_id:
104
+ current_apr = current.mid_apr or 0
105
+ best_apr = best.mid_apr or 0
106
+ if best_apr - current_apr > config.boros_apr_improvement_threshold:
107
+ runtime.current_boros_market_id = best.market_id
108
+ runtime.current_boros_token_id = best.collateral_token_id
109
+ runtime.current_boros_collateral_address = best.collateral_address
110
+ runtime.boros_market_selected_at = datetime.utcnow()
111
+ return (
112
+ best.market_id,
113
+ best.symbol,
114
+ best.tenor_days,
115
+ best.collateral_token_id,
116
+ best.collateral_address,
117
+ )
118
+
119
+ return (
120
+ current.market_id,
121
+ current.symbol,
122
+ current.tenor_days,
123
+ current.collateral_token_id,
124
+ current.collateral_address,
125
+ )
126
+
127
+
128
+ def build_plan(
129
+ inv: Inventory,
130
+ alloc: AllocationStatus,
131
+ risk_progress: float,
132
+ hedge_cfg: HedgeConfig,
133
+ config: PlannerConfig,
134
+ runtime: PlannerRuntime,
135
+ boros_quotes: list[BorosMarketQuote],
136
+ *,
137
+ pending_withdrawal_completion: bool = False,
138
+ ) -> Plan:
139
+ total = inv.total_value
140
+
141
+ # Pending withdrawal: do not run any actions in update().
142
+ if pending_withdrawal_completion or inv.boros_pending_withdrawal_usd > 0:
143
+ plan = Plan(
144
+ desired_state=DesiredState(
145
+ mode=Mode.NORMAL,
146
+ target_spot_usd=0,
147
+ target_hl_margin_usd=0,
148
+ target_boros_collateral_usd=0,
149
+ target_hype_short_size=0,
150
+ target_boros_position_usd=0,
151
+ ),
152
+ )
153
+ plan.messages.append(
154
+ f"Pending Boros withdrawal detected (${inv.boros_pending_withdrawal_usd:.2f}); skipping plan"
155
+ )
156
+ return plan # Skip all other operations
157
+
158
+ # Check for HL liquidation detection
159
+ if inv.hl_liquidation_detected:
160
+ # Log the liquidation alert in plan messages
161
+ plan = Plan(
162
+ desired_state=DesiredState(
163
+ mode=Mode.REDEPLOY,
164
+ target_spot_usd=0,
165
+ target_hl_margin_usd=0,
166
+ target_boros_collateral_usd=0,
167
+ target_hype_short_size=0,
168
+ target_boros_position_usd=0,
169
+ ),
170
+ )
171
+ plan.messages.append(
172
+ "[LIQUIDATION] HL short was liquidated - entering recovery mode"
173
+ )
174
+ # Add close and redeploy step
175
+ plan.add_step(
176
+ PlanOp.CLOSE_AND_REDEPLOY,
177
+ priority=0,
178
+ key="close_and_redeploy_post_liquidation",
179
+ reason="HL position was liquidated - need to rebalance",
180
+ )
181
+ return plan
182
+
183
+ # Determine mode based on risk
184
+ if risk_progress >= config.full_rebalance_threshold:
185
+ mode = Mode.REDEPLOY
186
+ elif risk_progress >= config.partial_trim_threshold:
187
+ mode = Mode.TRIM
188
+ else:
189
+ mode = Mode.NORMAL
190
+
191
+ # Gap 3: Boros enable/disable + min-deposit floor logic
192
+ # Disable Boros if:
193
+ # - Total AUM below minimum threshold
194
+ # - Pending withdrawal exists (prevents double-collateralizing)
195
+ # Note: funded_boros_this_tick only prevents repeated FUND_BOROS steps,
196
+ # not Boros positions/coverage entirely
197
+ boros_enabled = (
198
+ total >= config.min_total_for_boros and inv.boros_pending_withdrawal_usd <= 0
199
+ )
200
+
201
+ # Calculate target allocations
202
+ target_spot = total * hedge_cfg.spot_pct
203
+ target_hl = total * hedge_cfg.hyperliquid_pct
204
+
205
+ # 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.
207
+ target_boros = (
208
+ max(hedge_cfg.boros_pct * total, BOROS_MIN_DEPOSIT_USD + 1.0)
209
+ if boros_enabled
210
+ else 0.0
211
+ )
212
+
213
+ # Select Boros market with hysteresis
214
+ (
215
+ market_id,
216
+ market_symbol,
217
+ tenor_days,
218
+ boros_token_id,
219
+ boros_collateral_address,
220
+ ) = _choose_boros_market(boros_quotes, runtime, config)
221
+
222
+ # Gap 5: Delta-neutral targeting - use actual spot exposure, not target
223
+ # Short target = current HYPE exposure (to hedge what we actually have)
224
+ target_hype_short = inv.total_hype_exposure
225
+
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
+ )
232
+
233
+ desired = DesiredState(
234
+ mode=mode,
235
+ target_spot_usd=target_spot,
236
+ target_hl_margin_usd=target_hl,
237
+ target_boros_collateral_usd=target_boros,
238
+ target_hype_short_size=target_hype_short,
239
+ target_boros_position_usd=target_boros_position,
240
+ boros_market_id=market_id,
241
+ boros_market_symbol=market_symbol,
242
+ boros_tenor_days=tenor_days,
243
+ )
244
+
245
+ plan = Plan(desired_state=desired)
246
+
247
+ # Priority 0: Safety - emergency actions
248
+ if mode == Mode.REDEPLOY:
249
+ plan.add_step(
250
+ PlanOp.CLOSE_AND_REDEPLOY,
251
+ priority=0,
252
+ key="close_and_redeploy",
253
+ reason=f"Risk at {risk_progress:.0%}, triggering full rebalance",
254
+ )
255
+ plan.sort_steps()
256
+ return plan
257
+
258
+ if mode == Mode.TRIM:
259
+ # Calculate margin shortfall instead of blunt 25% trim
260
+ # This prevents the whipsaw of "trim → deploy excess margin back"
261
+ short_notional = inv.hl_short_size_hype * inv.hype_price_usd
262
+ required_margin = short_notional * config.hl_target_margin_ratio # 50% for 2x
263
+ buffer_margin = short_notional * config.hl_margin_buffer_ratio # 15% buffer
264
+ current_margin = inv.hl_perp_margin
265
+ margin_shortfall = max(0, required_margin + buffer_margin - current_margin)
266
+
267
+ # Only trim what's needed (plus 5% buffer for slippage)
268
+ trim_amount_usd = margin_shortfall * 1.05 if margin_shortfall > 5 else 0
269
+
270
+ if trim_amount_usd > 0 and inv.spot_value_usd > 0:
271
+ # Convert to percentage of spot value, capped at 50%
272
+ trim_pct = min(trim_amount_usd / inv.spot_value_usd, 0.50)
273
+ if trim_pct > 0.02: # Only trim if > 2%
274
+ plan.add_step(
275
+ PlanOp.PARTIAL_TRIM_SPOT,
276
+ priority=0,
277
+ key="partial_trim_spot",
278
+ params={"trim_pct": trim_pct},
279
+ reason=f"Risk at {risk_progress:.0%} - need ${margin_shortfall:.2f} margin",
280
+ )
281
+
282
+ # Priority 5: Gas routing (must happen before capital routing)
283
+ if inv.hype_hyperevm_balance < MIN_HYPE_GAS:
284
+ plan.add_step(
285
+ PlanOp.ENSURE_GAS_ON_HYPEREVM,
286
+ priority=5,
287
+ key="ensure_gas_hyperevm",
288
+ params={"min_hype": MIN_HYPE_GAS},
289
+ reason=f"HyperEVM HYPE balance ({inv.hype_hyperevm_balance:.4f}) below minimum gas",
290
+ )
291
+
292
+ # Gap 4: Priority 10 - Capital routing with virtual ledger
293
+ # Use runtime.available_usdc_arb() to avoid double-spending within a tick
294
+
295
+ # Fund Boros (highest priority among capital routing)
296
+ # Check funded_boros_this_tick to prevent repeated funding in same tick.
297
+ #
298
+ # IMPORTANT: Use the Boros target (includes minimum+buffer) instead of pct-based allocation,
299
+ # otherwise small deposits get skipped (Boros min deposit).
300
+ if boros_enabled and not runtime.funded_boros_this_tick:
301
+ boros_shortfall = max(
302
+ 0.0, target_boros - float(inv.boros_committed_collateral_usd or 0.0)
303
+ )
304
+ 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
+ )
318
+
319
+ # Send USDC to Hyperliquid ONCE to cover both:
320
+ # - HL margin allocation
321
+ # - HyperEVM spot deployment pipeline (paired fill + bridge)
322
+ need_hl = alloc.hl_deviation < -config.allocation_deviation_threshold
323
+ need_spot = alloc.spot_deviation < -config.allocation_deviation_threshold
324
+ available_usdc = runtime.available_usdc_arb(inv.usdc_arb_idle)
325
+ if available_usdc > config.min_usdc_action and (need_hl or need_spot):
326
+ # New routing: always send all Arbitrum USDC to Hyperliquid first.
327
+ amount = float(available_usdc)
328
+ plan.add_step(
329
+ PlanOp.SEND_USDC_TO_HL,
330
+ priority=10,
331
+ key=f"send_usdc_hl_{amount:.0f}",
332
+ params={"amount_usd": amount},
333
+ reason="Routing Arbitrum USDC to Hyperliquid (primary venue)",
334
+ )
335
+ runtime.commit_usdc(amount) # Mark as committed
336
+
337
+ # Bridge to HyperEVM for spot using HL margin (the HL deposit is handled by SEND_USDC_TO_HL).
338
+ if need_spot:
339
+ plan.add_step(
340
+ PlanOp.BRIDGE_TO_HYPEREVM,
341
+ priority=10,
342
+ key=f"bridge_hyperevm_{alloc.spot_needed_usd:.0f}",
343
+ params={
344
+ "amount_usd": float(alloc.spot_needed_usd or 0.0),
345
+ # Keep target HL margin in perp while transferring to spot for the paired fill.
346
+ "reserve_hl_margin_usd": float(target_hl),
347
+ },
348
+ reason=f"Spot underallocated by {abs(alloc.spot_deviation):.1%}",
349
+ )
350
+
351
+ # Deploy excess HL margin to spot if margin ratio is too high
352
+ if inv.hl_short_size_hype > 0:
353
+ short_notional = inv.hl_short_size_hype * inv.hype_price_usd
354
+ current_margin_ratio = (
355
+ inv.hl_perp_margin / short_notional if short_notional > 0 else 0
356
+ )
357
+ target_margin_ratio = (
358
+ config.hl_target_margin_ratio + config.hl_margin_buffer_ratio
359
+ ) # ~65%
360
+ if current_margin_ratio > target_margin_ratio + 0.10: # 10% excess
361
+ excess_margin = (
362
+ current_margin_ratio - target_margin_ratio
363
+ ) * short_notional
364
+ if excess_margin > config.min_usdc_action:
365
+ plan.add_step(
366
+ PlanOp.DEPLOY_EXCESS_HL_MARGIN,
367
+ priority=13,
368
+ key=f"deploy_excess_margin_{excess_margin:.0f}",
369
+ params={"excess_margin_usd": excess_margin},
370
+ reason=f"Deploy ${excess_margin:.2f} excess HL margin to spot",
371
+ )
372
+
373
+ # Transfer HYPE stuck on HL spot to HyperEVM
374
+ if inv.hl_spot_hype > 0.1:
375
+ plan.add_step(
376
+ PlanOp.TRANSFER_HL_SPOT_TO_HYPEREVM,
377
+ priority=14,
378
+ key=f"transfer_hl_spot_{inv.hl_spot_hype:.4f}",
379
+ params={"hype_amount": inv.hl_spot_hype},
380
+ reason=f"Transfer {inv.hl_spot_hype:.4f} HYPE from HL spot to HyperEVM",
381
+ )
382
+
383
+ # Priority 20: Position management
384
+ # Swap unallocated HYPE to LSTs (leave MIN_HYPE_GAS for gas)
385
+ swappable_hype = max(0.0, inv.hype_hyperevm_balance - MIN_HYPE_GAS)
386
+ if swappable_hype > config.min_hype_swap:
387
+ plan.add_step(
388
+ PlanOp.SWAP_HYPE_TO_LST,
389
+ priority=20,
390
+ key=f"swap_hype_lst_{swappable_hype:.2f}",
391
+ params={"hype_amount": swappable_hype},
392
+ reason=f"Converting {swappable_hype:.2f} HYPE to yield-bearing LST",
393
+ )
394
+
395
+ # Gap 5: Ensure delta neutral short using actual exposure
396
+ # Use combined absolute + relative tolerance to avoid dust chasing
397
+ current_short = inv.hl_short_size_hype
398
+ short_delta = abs(target_hype_short - current_short)
399
+
400
+ # Combined tolerance: max of absolute and relative
401
+ tolerance = max(
402
+ config.delta_neutral_abs_tol_hype,
403
+ target_hype_short * config.delta_neutral_rel_tol,
404
+ )
405
+
406
+ if short_delta > tolerance and target_hype_short > 0.1:
407
+ plan.add_step(
408
+ PlanOp.ENSURE_HL_SHORT,
409
+ priority=20,
410
+ key=f"ensure_hl_short_{target_hype_short:.2f}",
411
+ params={"target_size": target_hype_short, "current_size": current_short},
412
+ reason=f"Delta imbalance: short={current_short:.4f}, exposure={target_hype_short:.4f}",
413
+ )
414
+
415
+ # Priority 30: Rate positions (Boros)
416
+ if boros_enabled and market_id:
417
+ current_boros_size = inv.boros_position_size
418
+ size_diff = abs(target_boros_position - current_boros_size)
419
+
420
+ # Trigger if:
421
+ # 1. Position size needs adjustment (resize threshold met)
422
+ # 2. There's isolated collateral that needs to move to cross (market expiry/rotation)
423
+ # 3. There's cross collateral but no position (collateral sitting idle)
424
+ needs_position_resize = size_diff > config.boros_resize_min_excess_usd
425
+ isolated_usd = float(inv.boros_idle_collateral_isolated or 0.0) * float(
426
+ inv.hype_price_usd or 0.0
427
+ )
428
+ cross_usd = float(inv.boros_idle_collateral_cross or 0.0) * float(
429
+ inv.hype_price_usd or 0.0
430
+ )
431
+ has_stranded_isolated = isolated_usd > 0.5 # $0.50 threshold
432
+ has_idle_cross = cross_usd > 1.0 and current_boros_size < 1.0
433
+
434
+ if needs_position_resize or has_stranded_isolated or has_idle_cross:
435
+ reason = f"Adjusting Boros position to {target_boros_position:.0f} YU"
436
+ if has_stranded_isolated and not needs_position_resize:
437
+ reason = (
438
+ "Moving "
439
+ f"{inv.boros_idle_collateral_isolated:.6f} HYPE (≈${isolated_usd:.2f}) "
440
+ "from isolated to cross margin"
441
+ )
442
+ elif has_idle_cross and not needs_position_resize:
443
+ reason = (
444
+ "Deploying "
445
+ f"{inv.boros_idle_collateral_cross:.6f} HYPE (≈${cross_usd:.2f}) "
446
+ "idle cross collateral as rate position"
447
+ )
448
+ plan.add_step(
449
+ PlanOp.ENSURE_BOROS_POSITION,
450
+ priority=20, # Same as ENSURE_HL_SHORT so both execute before re-observe
451
+ key=f"ensure_boros_pos_{target_boros_position:.0f}",
452
+ params={
453
+ "market_id": market_id,
454
+ "target_size_usd": target_boros_position,
455
+ },
456
+ reason=reason,
457
+ )
458
+
459
+ plan.sort_steps()
460
+ return plan