wayfinder-paths 0.1.6__py3-none-any.whl → 0.1.8__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 (50) hide show
  1. wayfinder_paths/adapters/balance_adapter/README.md +0 -10
  2. wayfinder_paths/adapters/balance_adapter/adapter.py +0 -20
  3. wayfinder_paths/adapters/balance_adapter/test_adapter.py +0 -30
  4. wayfinder_paths/adapters/brap_adapter/adapter.py +3 -2
  5. wayfinder_paths/adapters/brap_adapter/test_adapter.py +9 -13
  6. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +14 -7
  7. wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +18 -0
  8. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +1093 -0
  9. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +549 -0
  10. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +8 -0
  11. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1050 -0
  12. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +126 -0
  13. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +219 -0
  14. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +220 -0
  15. wayfinder_paths/adapters/hyperliquid_adapter/utils.py +134 -0
  16. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +7 -6
  17. wayfinder_paths/adapters/pool_adapter/README.md +3 -28
  18. wayfinder_paths/adapters/pool_adapter/adapter.py +0 -72
  19. wayfinder_paths/adapters/pool_adapter/examples.json +0 -43
  20. wayfinder_paths/adapters/pool_adapter/test_adapter.py +4 -54
  21. wayfinder_paths/adapters/token_adapter/test_adapter.py +4 -14
  22. wayfinder_paths/core/adapters/models.py +9 -4
  23. wayfinder_paths/core/analytics/__init__.py +11 -0
  24. wayfinder_paths/core/analytics/bootstrap.py +57 -0
  25. wayfinder_paths/core/analytics/stats.py +48 -0
  26. wayfinder_paths/core/analytics/test_analytics.py +170 -0
  27. wayfinder_paths/core/clients/BRAPClient.py +1 -0
  28. wayfinder_paths/core/clients/LedgerClient.py +2 -7
  29. wayfinder_paths/core/clients/PoolClient.py +0 -16
  30. wayfinder_paths/core/clients/WalletClient.py +0 -27
  31. wayfinder_paths/core/clients/protocols.py +104 -18
  32. wayfinder_paths/scripts/make_wallets.py +9 -0
  33. wayfinder_paths/scripts/run_strategy.py +124 -0
  34. wayfinder_paths/strategies/basis_trading_strategy/README.md +213 -0
  35. wayfinder_paths/strategies/basis_trading_strategy/__init__.py +3 -0
  36. wayfinder_paths/strategies/basis_trading_strategy/constants.py +1 -0
  37. wayfinder_paths/strategies/basis_trading_strategy/examples.json +16 -0
  38. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +23 -0
  39. wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1011 -0
  40. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +4522 -0
  41. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +727 -0
  42. wayfinder_paths/strategies/basis_trading_strategy/types.py +39 -0
  43. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +1 -9
  44. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +36 -5
  45. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +367 -278
  46. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +204 -7
  47. {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/METADATA +32 -3
  48. {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/RECORD +50 -27
  49. {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/LICENSE +0 -0
  50. {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/WHEEL +0 -0
@@ -0,0 +1,1093 @@
1
+ """HyperliquidAdapter - wraps hyperliquid SDK for market data and order execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from wayfinder_paths.core.adapters.BaseAdapter import BaseAdapter
10
+
11
+ if TYPE_CHECKING:
12
+ from wayfinder_paths.core.clients.protocols import (
13
+ HyperliquidExecutorProtocol as HyperliquidExecutor,
14
+ )
15
+
16
+ # Hyperliquid L1 bridge address on Arbitrum - send USDC here to deposit
17
+ HYPERLIQUID_BRIDGE_ADDRESS = "0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7"
18
+
19
+ # USDC contract on Arbitrum
20
+ ARBITRUM_USDC_ADDRESS = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"
21
+
22
+ try:
23
+ from hyperliquid.info import Info
24
+ from hyperliquid.utils import constants
25
+
26
+ HYPERLIQUID_AVAILABLE = True
27
+ except ImportError:
28
+ HYPERLIQUID_AVAILABLE = False
29
+ Info = None
30
+ constants = None
31
+
32
+
33
+ class SimpleCache:
34
+ """Simple in-memory cache with TTL to replace Django cache."""
35
+
36
+ def __init__(self):
37
+ self._cache: dict[str, Any] = {}
38
+ self._expiry: dict[str, float] = {}
39
+
40
+ def get(self, key: str) -> Any | None:
41
+ if key in self._cache:
42
+ if time.time() < self._expiry.get(key, 0):
43
+ return self._cache[key]
44
+ del self._cache[key]
45
+ if key in self._expiry:
46
+ del self._expiry[key]
47
+ return None
48
+
49
+ def set(self, key: str, value: Any, timeout: int = 300) -> None:
50
+ self._cache[key] = value
51
+ self._expiry[key] = time.time() + timeout
52
+
53
+ def clear(self) -> None:
54
+ self._cache.clear()
55
+ self._expiry.clear()
56
+
57
+
58
+ class HyperliquidAdapter(BaseAdapter):
59
+ """
60
+ Adapter for Hyperliquid exchange operations.
61
+
62
+ Wraps the hyperliquid SDK directly for market data access.
63
+ Uses Hyperliquid's public API for:
64
+ - Market metadata (perp and spot)
65
+ - Funding rate history
66
+ - Price candles
67
+ - Order book snapshots
68
+ - User positions and balances
69
+ """
70
+
71
+ adapter_type = "HYPERLIQUID"
72
+
73
+ def __init__(
74
+ self,
75
+ config: dict[str, Any] | None = None,
76
+ *,
77
+ simulation: bool = False,
78
+ executor: HyperliquidExecutor | None = None,
79
+ ) -> None:
80
+ super().__init__("hyperliquid_adapter", config)
81
+
82
+ if not HYPERLIQUID_AVAILABLE:
83
+ raise ImportError(
84
+ "hyperliquid package not installed. "
85
+ "Install with: poetry add hyperliquid"
86
+ )
87
+
88
+ self.simulation = simulation
89
+ self._cache = SimpleCache()
90
+ self._executor = executor
91
+
92
+ # Initialize Hyperliquid Info client
93
+ self.info = Info(constants.MAINNET_API_URL, skip_ws=True)
94
+
95
+ # Cache asset mappings after first fetch
96
+ self._asset_to_sz_decimals: dict[int, int] | None = None
97
+ self._coin_to_asset: dict[str, int] | None = None
98
+
99
+ async def connect(self) -> bool:
100
+ """Verify connection by fetching market metadata."""
101
+ try:
102
+ meta = self.info.meta_and_asset_ctxs()
103
+ if meta:
104
+ self.logger.debug("HyperliquidAdapter connected successfully")
105
+ return True
106
+ return False
107
+ except Exception as exc:
108
+ self.logger.error(f"HyperliquidAdapter connection failed: {exc}")
109
+ return False
110
+
111
+ # ------------------------------------------------------------------ #
112
+ # Market Data - Read Operations #
113
+ # ------------------------------------------------------------------ #
114
+
115
+ async def get_meta_and_asset_ctxs(self) -> tuple[bool, Any]:
116
+ """
117
+ Get perpetual market metadata and asset contexts.
118
+
119
+ Returns combined [meta, assetCtxs] from Hyperliquid API.
120
+ """
121
+ cache_key = "hl_meta_and_asset_ctxs"
122
+ cached = self._cache.get(cache_key)
123
+ if cached:
124
+ return True, cached
125
+
126
+ try:
127
+ data = self.info.meta_and_asset_ctxs()
128
+ self._cache.set(cache_key, data, timeout=60) # Cache for 1 minute
129
+ return True, data
130
+ except Exception as exc:
131
+ self.logger.error(f"Failed to fetch meta_and_asset_ctxs: {exc}")
132
+ return False, str(exc)
133
+
134
+ async def get_spot_meta(self) -> tuple[bool, Any]:
135
+ """
136
+ Get spot market metadata.
137
+
138
+ Returns spot market information including tokens and pairs.
139
+ """
140
+ cache_key = "hl_spot_meta"
141
+ cached = self._cache.get(cache_key)
142
+ if cached:
143
+ return True, cached
144
+
145
+ try:
146
+ # Handle both callable and property access patterns
147
+ spot_meta = self.info.spot_meta
148
+ if callable(spot_meta):
149
+ data = spot_meta()
150
+ else:
151
+ data = spot_meta
152
+ self._cache.set(cache_key, data, timeout=60)
153
+ return True, data
154
+ except Exception as exc:
155
+ self.logger.error(f"Failed to fetch spot_meta: {exc}")
156
+ return False, str(exc)
157
+
158
+ async def get_spot_assets(self) -> tuple[bool, dict[str, int]]:
159
+ """
160
+ Get mapping of spot pair names to asset IDs.
161
+
162
+ Returns:
163
+ Dict mapping "BASE/QUOTE" names to spot asset IDs (index + 10000).
164
+ Example: {"PURR/USDC": 10000, "HYPE/USDC": 10107, ...}
165
+ """
166
+ cache_key = "hl_spot_assets"
167
+ cached = self._cache.get(cache_key)
168
+ if cached:
169
+ return True, cached
170
+
171
+ try:
172
+ success, spot_meta = await self.get_spot_meta()
173
+ if not success:
174
+ return False, {}
175
+
176
+ response = {}
177
+ tokens = spot_meta.get("tokens", [])
178
+ universe = spot_meta.get("universe", [])
179
+
180
+ for pair in universe:
181
+ pair_tokens = pair.get("tokens", [])
182
+ if len(pair_tokens) < 2:
183
+ continue
184
+
185
+ base_idx, quote_idx = pair_tokens[0], pair_tokens[1]
186
+
187
+ # Get token names
188
+ base_info = tokens[base_idx] if base_idx < len(tokens) else {}
189
+ quote_info = tokens[quote_idx] if quote_idx < len(tokens) else {}
190
+
191
+ base_name = base_info.get("name", f"TOKEN{base_idx}")
192
+ quote_name = quote_info.get("name", f"TOKEN{quote_idx}")
193
+
194
+ name = f"{base_name}/{quote_name}"
195
+ spot_asset_id = pair.get("index", 0) + 10000
196
+ response[name] = spot_asset_id
197
+
198
+ self._cache.set(cache_key, response, timeout=300) # Cache for 5 min
199
+ return True, response
200
+
201
+ except Exception as exc:
202
+ self.logger.error(f"Failed to get spot assets: {exc}")
203
+ return False, {}
204
+
205
+ def get_spot_asset_id(self, base_coin: str, quote_coin: str = "USDC") -> int | None:
206
+ """
207
+ Synchronous helper to get spot asset ID from cached data.
208
+
209
+ Args:
210
+ base_coin: Base token name (e.g., "HYPE", "ETH", "BTC")
211
+ quote_coin: Quote token name (default: "USDC")
212
+
213
+ Returns:
214
+ Spot asset ID or None if not found.
215
+ """
216
+ cache_key = "hl_spot_assets"
217
+ cached = self._cache.get(cache_key)
218
+ if cached:
219
+ pair_name = f"{base_coin}/{quote_coin}"
220
+ return cached.get(pair_name)
221
+ return None
222
+
223
+ async def get_funding_history(
224
+ self,
225
+ coin: str,
226
+ start_time_ms: int,
227
+ end_time_ms: int | None = None,
228
+ ) -> tuple[bool, list[dict[str, Any]]]:
229
+ """
230
+ Get funding rate history for a perpetual.
231
+
232
+ Args:
233
+ coin: Coin symbol (e.g., "ETH", "BTC")
234
+ start_time_ms: Start time in milliseconds
235
+ end_time_ms: End time in milliseconds (optional)
236
+
237
+ Returns:
238
+ List of funding rate records with time and fundingRate fields.
239
+ """
240
+ try:
241
+ data = self.info.funding_history(coin, start_time_ms, end_time_ms)
242
+ return True, data
243
+ except Exception as exc:
244
+ self.logger.error(f"Failed to fetch funding_history for {coin}: {exc}")
245
+ return False, str(exc)
246
+
247
+ async def get_candles(
248
+ self,
249
+ coin: str,
250
+ interval: str,
251
+ start_time_ms: int,
252
+ end_time_ms: int | None = None,
253
+ ) -> tuple[bool, list[dict[str, Any]]]:
254
+ """
255
+ Get OHLCV candle data.
256
+
257
+ Args:
258
+ coin: Coin symbol (e.g., "ETH", "BTC")
259
+ interval: Candle interval (e.g., "1h", "4h", "1d")
260
+ start_time_ms: Start time in milliseconds
261
+ end_time_ms: End time in milliseconds (optional)
262
+
263
+ Returns:
264
+ List of candle records with t, o, h, l, c, v fields.
265
+ """
266
+ try:
267
+ data = self.info.candles_snapshot(
268
+ coin, interval, start_time_ms, end_time_ms
269
+ )
270
+ return True, data
271
+ except Exception as exc:
272
+ self.logger.error(f"Failed to fetch candles for {coin}: {exc}")
273
+ return False, str(exc)
274
+
275
+ async def get_l2_book(
276
+ self,
277
+ coin: str,
278
+ n_levels: int = 20,
279
+ ) -> tuple[bool, dict[str, Any]]:
280
+ """
281
+ Get L2 order book snapshot.
282
+
283
+ Args:
284
+ coin: Coin symbol (e.g., "ETH", "BTC", or spot pair like "HYPE/USDC")
285
+ n_levels: Number of price levels to fetch
286
+
287
+ Returns:
288
+ Order book with levels containing px, sz, n fields.
289
+ """
290
+ try:
291
+ data = self.info.l2_snapshot(coin)
292
+ return True, data
293
+ except Exception as exc:
294
+ self.logger.error(f"Failed to fetch L2 book for {coin}: {exc}")
295
+ return False, str(exc)
296
+
297
+ async def get_user_state(self, address: str) -> tuple[bool, dict[str, Any]]:
298
+ """
299
+ Get user's perpetual account state including positions and margin.
300
+
301
+ Args:
302
+ address: Wallet address
303
+
304
+ Returns:
305
+ User state with assetPositions, crossMarginSummary, etc.
306
+ """
307
+ try:
308
+ data = self.info.user_state(address)
309
+ return True, data
310
+ except Exception as exc:
311
+ self.logger.error(f"Failed to fetch user_state for {address}: {exc}")
312
+ return False, str(exc)
313
+
314
+ async def get_spot_user_state(self, address: str) -> tuple[bool, dict[str, Any]]:
315
+ """
316
+ Get user's spot account balances.
317
+
318
+ Args:
319
+ address: Wallet address
320
+
321
+ Returns:
322
+ Spot balances for the user.
323
+ """
324
+ try:
325
+ data = self.info.spot_user_state(address)
326
+ return True, data
327
+ except Exception as exc:
328
+ self.logger.error(f"Failed to fetch spot_user_state for {address}: {exc}")
329
+ return False, str(exc)
330
+
331
+ async def get_margin_table(self, margin_table_id: int) -> tuple[bool, list[dict]]:
332
+ """
333
+ Get tiered margin table for an asset.
334
+
335
+ Args:
336
+ margin_table_id: Margin table ID from asset context
337
+
338
+ Returns:
339
+ List of margin tiers with notional and maintenance rate.
340
+ """
341
+ cache_key = f"hl_margin_table_{margin_table_id}"
342
+ cached = self._cache.get(cache_key)
343
+ if cached:
344
+ return True, cached
345
+
346
+ try:
347
+ # Hyperliquid expects `id` for margin tables in the /info API.
348
+ # Keep a fallback to `marginTableId` for compatibility with older SDKs.
349
+ body = {"type": "marginTable", "id": int(margin_table_id)}
350
+ try:
351
+ data = self.info.post("/info", body)
352
+ except Exception: # noqa: BLE001 - try alternate payload key
353
+ body = {"type": "marginTable", "marginTableId": int(margin_table_id)}
354
+ data = self.info.post("/info", body)
355
+ self._cache.set(cache_key, data, timeout=86400) # Cache for 24h
356
+ return True, data
357
+ except Exception as exc:
358
+ self.logger.error(f"Failed to fetch margin_table {margin_table_id}: {exc}")
359
+ return False, str(exc)
360
+
361
+ async def get_spot_l2_book(self, spot_asset_id: int) -> tuple[bool, dict[str, Any]]:
362
+ """
363
+ Get L2 order book for a spot market by asset ID.
364
+
365
+ Args:
366
+ spot_asset_id: Spot asset ID (>= 10000)
367
+
368
+ Returns:
369
+ Order book with levels.
370
+ """
371
+ try:
372
+ # Spot L2 uses different coin names based on spot index:
373
+ # - Index 0 (PURR): use "PURR/USDC"
374
+ # - All other indices: use "@{index}"
375
+ spot_index = (
376
+ spot_asset_id - 10000 if spot_asset_id >= 10000 else spot_asset_id
377
+ )
378
+
379
+ if spot_index == 0:
380
+ coin = "PURR/USDC"
381
+ else:
382
+ coin = f"@{spot_index}"
383
+
384
+ body = {"type": "l2Book", "coin": coin}
385
+ data = self.info.post("/info", body)
386
+ return True, data
387
+ except Exception as exc:
388
+ self.logger.error(
389
+ f"Failed to fetch spot L2 book for {spot_asset_id}: {exc}"
390
+ )
391
+ return False, str(exc)
392
+
393
+ # ------------------------------------------------------------------ #
394
+ # Asset Mappings #
395
+ # ------------------------------------------------------------------ #
396
+
397
+ @property
398
+ def asset_to_sz_decimals(self) -> dict[int, int]:
399
+ """Get asset ID to size decimals mapping."""
400
+ if self._asset_to_sz_decimals is None:
401
+ self._asset_to_sz_decimals = dict(self.info.asset_to_sz_decimals)
402
+ return self._asset_to_sz_decimals
403
+
404
+ @property
405
+ def coin_to_asset(self) -> dict[str, int]:
406
+ """Get coin name to asset ID mapping (perps only)."""
407
+ if self._coin_to_asset is None:
408
+ self._coin_to_asset = dict(self.info.coin_to_asset)
409
+ return self._coin_to_asset
410
+
411
+ def get_sz_decimals(self, asset_id: int) -> int:
412
+ """Get size decimals for an asset."""
413
+ try:
414
+ return self.asset_to_sz_decimals[asset_id]
415
+ except KeyError:
416
+ raise ValueError(
417
+ f"Unknown asset_id {asset_id}: missing szDecimals"
418
+ ) from None
419
+
420
+ def refresh_mappings(self) -> None:
421
+ """Force refresh of cached asset mappings."""
422
+ self._asset_to_sz_decimals = None
423
+ self._coin_to_asset = None
424
+ self._cache.clear()
425
+
426
+ # ------------------------------------------------------------------ #
427
+ # Utility Methods #
428
+ # ------------------------------------------------------------------ #
429
+
430
+ async def get_all_mid_prices(self) -> tuple[bool, dict[str, float]]:
431
+ """Get mid prices for all markets."""
432
+ try:
433
+ data = self.info.all_mids()
434
+ return True, {k: float(v) for k, v in data.items()}
435
+ except Exception as exc:
436
+ self.logger.error(f"Failed to fetch mid prices: {exc}")
437
+ return False, str(exc)
438
+
439
+ def get_valid_order_size(self, asset_id: int, size: float) -> float:
440
+ """Round size to valid lot size for asset."""
441
+ decimals = self.get_sz_decimals(asset_id)
442
+ from decimal import ROUND_DOWN, Decimal
443
+
444
+ step = Decimal(10) ** (-decimals)
445
+ if size <= 0:
446
+ return 0.0
447
+ quantized = (Decimal(str(size)) / step).to_integral_value(
448
+ rounding=ROUND_DOWN
449
+ ) * step
450
+ return float(quantized)
451
+
452
+ # ------------------------------------------------------------------ #
453
+ # Execution Methods (require signing callback) #
454
+ # ------------------------------------------------------------------ #
455
+
456
+ async def place_market_order(
457
+ self,
458
+ asset_id: int,
459
+ is_buy: bool,
460
+ slippage: float,
461
+ size: float,
462
+ address: str,
463
+ *,
464
+ reduce_only: bool = False,
465
+ cloid: str | None = None,
466
+ builder: dict[str, Any] | None = None,
467
+ ) -> tuple[bool, dict[str, Any]]:
468
+ """
469
+ Place a market order (IOC with slippage).
470
+
471
+ Args:
472
+ asset_id: Asset ID (perp < 10000, spot >= 10000)
473
+ is_buy: True for buy, False for sell
474
+ slippage: Slippage tolerance (0.0 to 1.0)
475
+ size: Order size in base units
476
+ address: Wallet address
477
+ reduce_only: If True, only reduce existing position
478
+ cloid: Client order ID (optional)
479
+ builder: Optional builder fee config with keys 'b' (address) and 'f' (fee bps)
480
+
481
+ Returns:
482
+ (success, response_data or error_message)
483
+ """
484
+ if self.simulation:
485
+ self.logger.info(
486
+ f"[SIMULATION] place_market_order: asset={asset_id}, "
487
+ f"is_buy={is_buy}, size={size}, address={address}"
488
+ )
489
+ return True, {"simulation": True, "status": "ok"}
490
+
491
+ if not self._executor:
492
+ raise NotImplementedError(
493
+ "No Hyperliquid executor configured. "
494
+ "Inject a HyperliquidExecutor implementation (e.g., LocalHyperliquidExecutor)."
495
+ )
496
+
497
+ result = await self._executor.place_market_order(
498
+ asset_id=asset_id,
499
+ is_buy=is_buy,
500
+ slippage=slippage,
501
+ size=size,
502
+ address=address,
503
+ reduce_only=reduce_only,
504
+ cloid=cloid,
505
+ builder=builder,
506
+ )
507
+
508
+ success = result.get("status") == "ok"
509
+ return success, result
510
+
511
+ async def cancel_order(
512
+ self,
513
+ asset_id: int,
514
+ order_id: int | str,
515
+ address: str,
516
+ ) -> tuple[bool, dict[str, Any]]:
517
+ """
518
+ Cancel an open order.
519
+
520
+ Args:
521
+ asset_id: Asset ID
522
+ order_id: Order ID to cancel
523
+ address: Wallet address
524
+
525
+ Returns:
526
+ (success, response_data or error_message)
527
+ """
528
+ if self.simulation:
529
+ self.logger.info(
530
+ f"[SIMULATION] cancel_order: asset={asset_id}, oid={order_id}"
531
+ )
532
+ return True, {"simulation": True, "status": "ok"}
533
+
534
+ if not self._executor:
535
+ raise NotImplementedError(
536
+ "No Hyperliquid executor configured. "
537
+ "Inject a HyperliquidExecutor implementation (e.g., LocalHyperliquidExecutor)."
538
+ )
539
+
540
+ result = await self._executor.cancel_order(
541
+ asset_id=asset_id,
542
+ order_id=int(order_id) if isinstance(order_id, str) else order_id,
543
+ address=address,
544
+ )
545
+
546
+ success = result.get("status") == "ok"
547
+ return success, result
548
+
549
+ async def update_leverage(
550
+ self,
551
+ asset_id: int,
552
+ leverage: int,
553
+ is_cross: bool,
554
+ address: str,
555
+ ) -> tuple[bool, dict[str, Any]]:
556
+ """
557
+ Update leverage for an asset.
558
+
559
+ Args:
560
+ asset_id: Asset ID
561
+ leverage: Target leverage
562
+ is_cross: True for cross margin, False for isolated
563
+ address: Wallet address
564
+
565
+ Returns:
566
+ (success, response_data or error_message)
567
+ """
568
+ if self.simulation:
569
+ self.logger.info(
570
+ f"[SIMULATION] update_leverage: asset={asset_id}, leverage={leverage}"
571
+ )
572
+ return True, {"simulation": True, "status": "ok"}
573
+
574
+ if not self._executor:
575
+ raise NotImplementedError("No Hyperliquid executor configured.")
576
+
577
+ result = await self._executor.update_leverage(
578
+ asset_id=asset_id,
579
+ leverage=leverage,
580
+ is_cross=is_cross,
581
+ address=address,
582
+ )
583
+
584
+ success = result.get("status") == "ok"
585
+ return success, result
586
+
587
+ async def transfer_spot_to_perp(
588
+ self,
589
+ amount: float,
590
+ address: str,
591
+ ) -> tuple[bool, dict[str, Any]]:
592
+ """Transfer USDC from spot to perp balance."""
593
+ if self.simulation:
594
+ self.logger.info(f"[SIMULATION] transfer_spot_to_perp: {amount} USDC")
595
+ return True, {"simulation": True, "status": "ok"}
596
+
597
+ if not self._executor:
598
+ raise NotImplementedError("No Hyperliquid executor configured.")
599
+
600
+ result = await self._executor.transfer_spot_to_perp(
601
+ amount=amount,
602
+ address=address,
603
+ )
604
+
605
+ success = result.get("status") == "ok"
606
+ return success, result
607
+
608
+ async def transfer_perp_to_spot(
609
+ self,
610
+ amount: float,
611
+ address: str,
612
+ ) -> tuple[bool, dict[str, Any]]:
613
+ """Transfer USDC from perp to spot balance."""
614
+ if self.simulation:
615
+ self.logger.info(f"[SIMULATION] transfer_perp_to_spot: {amount} USDC")
616
+ return True, {"simulation": True, "status": "ok"}
617
+
618
+ if not self._executor:
619
+ raise NotImplementedError("No Hyperliquid executor configured.")
620
+
621
+ result = await self._executor.transfer_perp_to_spot(
622
+ amount=amount,
623
+ address=address,
624
+ )
625
+
626
+ success = result.get("status") == "ok"
627
+ return success, result
628
+
629
+ async def place_stop_loss(
630
+ self,
631
+ asset_id: int,
632
+ is_buy: bool,
633
+ trigger_price: float,
634
+ size: float,
635
+ address: str,
636
+ ) -> tuple[bool, dict[str, Any]]:
637
+ """
638
+ Place a stop-loss order.
639
+
640
+ Args:
641
+ asset_id: Asset ID
642
+ is_buy: True to buy (close short), False to sell (close long)
643
+ trigger_price: Price at which to trigger
644
+ size: Order size
645
+ address: Wallet address
646
+
647
+ Returns:
648
+ (success, response_data or error_message)
649
+ """
650
+ if self.simulation:
651
+ self.logger.info(
652
+ f"[SIMULATION] place_stop_loss: asset={asset_id}, "
653
+ f"trigger={trigger_price}, size={size}"
654
+ )
655
+ return True, {"simulation": True, "status": "ok"}
656
+
657
+ if not self._executor:
658
+ raise NotImplementedError("No Hyperliquid executor configured.")
659
+
660
+ result = await self._executor.place_stop_loss(
661
+ asset_id=asset_id,
662
+ is_buy=is_buy,
663
+ trigger_price=trigger_price,
664
+ size=size,
665
+ address=address,
666
+ )
667
+
668
+ success = result.get("status") == "ok"
669
+ return success, result
670
+
671
+ async def get_user_fills(self, address: str) -> tuple[bool, list[dict[str, Any]]]:
672
+ """
673
+ Get recent fills for a user.
674
+
675
+ Args:
676
+ address: Wallet address
677
+
678
+ Returns:
679
+ List of fill records
680
+ """
681
+ try:
682
+ data = self.info.user_fills(address)
683
+ return True, data if isinstance(data, list) else []
684
+ except Exception as exc:
685
+ self.logger.error(f"Failed to fetch user_fills for {address}: {exc}")
686
+ return False, str(exc)
687
+
688
+ async def get_order_status(
689
+ self, address: str, order_id: int | str
690
+ ) -> tuple[bool, dict[str, Any]]:
691
+ """
692
+ Get status of a specific order.
693
+
694
+ Args:
695
+ address: Wallet address
696
+ order_id: Order ID (numeric) or client order ID (string)
697
+
698
+ Returns:
699
+ Order status data
700
+ """
701
+ try:
702
+ body = {"type": "orderStatus", "user": address, "oid": order_id}
703
+ data = self.info.post("/info", body)
704
+ return True, data
705
+ except Exception as exc:
706
+ self.logger.error(f"Failed to fetch order_status for {order_id}: {exc}")
707
+ return False, str(exc)
708
+
709
+ async def get_open_orders(self, address: str) -> tuple[bool, list[dict[str, Any]]]:
710
+ """
711
+ Get open orders for a user.
712
+
713
+ Args:
714
+ address: Wallet address
715
+
716
+ Returns:
717
+ List of open order records
718
+ """
719
+ try:
720
+ data = self.info.open_orders(address)
721
+ return True, data if isinstance(data, list) else []
722
+ except Exception as exc:
723
+ self.logger.error(f"Failed to fetch open_orders for {address}: {exc}")
724
+ return False, str(exc)
725
+
726
+ async def get_frontend_open_orders(
727
+ self, address: str
728
+ ) -> tuple[bool, list[dict[str, Any]]]:
729
+ """
730
+ Get all open orders including trigger orders (stop-loss, take-profit).
731
+
732
+ Uses frontendOpenOrders endpoint which returns both limit and trigger orders
733
+ with full order details including orderType and triggerPx.
734
+
735
+ Args:
736
+ address: Wallet address
737
+
738
+ Returns:
739
+ List of open order records including trigger orders
740
+ """
741
+ try:
742
+ data = self.info.frontend_open_orders(address)
743
+ return True, data if isinstance(data, list) else []
744
+ except Exception as exc:
745
+ self.logger.error(
746
+ f"Failed to fetch frontend_open_orders for {address}: {exc}"
747
+ )
748
+ return False, str(exc)
749
+
750
+ async def withdraw(
751
+ self,
752
+ *,
753
+ amount: float,
754
+ address: str,
755
+ ) -> tuple[bool, dict[str, Any]]:
756
+ """
757
+ Withdraw USDC from Hyperliquid to Arbitrum.
758
+
759
+ Note: This is an L1 withdrawal handled by the Hyperliquid executor (signing required).
760
+ """
761
+ if self.simulation:
762
+ self.logger.info(f"[SIMULATION] withdraw: {amount} USDC")
763
+ return True, {"simulation": True, "status": "ok"}
764
+
765
+ if not self._executor:
766
+ raise NotImplementedError("No Hyperliquid executor configured.")
767
+
768
+ result = await self._executor.withdraw(
769
+ amount=amount,
770
+ address=address,
771
+ )
772
+ success = result.get("status") == "ok"
773
+ return success, result
774
+
775
+ # ------------------------------------------------------------------ #
776
+ # Health Check #
777
+ # ------------------------------------------------------------------ #
778
+
779
+ async def health_check(self) -> dict[str, Any]:
780
+ """Check adapter health by verifying API connectivity."""
781
+ try:
782
+ success, meta = await self.get_meta_and_asset_ctxs()
783
+ if success and meta:
784
+ return {
785
+ "status": "healthy",
786
+ "perp_markets": len(meta[0].get("universe", [])) if meta else 0,
787
+ }
788
+ return {"status": "unhealthy", "error": "Failed to fetch metadata"}
789
+ except Exception as exc:
790
+ return {"status": "unhealthy", "error": str(exc)}
791
+
792
+ # ------------------------------------------------------------------ #
793
+ # Deposit/Withdrawal Helpers #
794
+ # ------------------------------------------------------------------ #
795
+
796
+ def get_perp_margin_amount(self, user_state: dict[str, Any]) -> float:
797
+ """
798
+ Extract perp margin amount from user state.
799
+
800
+ Args:
801
+ user_state: User state from get_user_state()
802
+
803
+ Returns:
804
+ Perp margin balance in USD
805
+ """
806
+ try:
807
+ margin_summary = user_state.get("marginSummary", {})
808
+ account_value = margin_summary.get("accountValue")
809
+ if account_value is not None:
810
+ return float(account_value)
811
+ # Fallback to crossMarginSummary
812
+ cross_summary = user_state.get("crossMarginSummary", {})
813
+ return float(cross_summary.get("accountValue", 0.0))
814
+ except (TypeError, ValueError):
815
+ return 0.0
816
+
817
+ async def get_max_builder_fee(
818
+ self,
819
+ user: str,
820
+ builder: str,
821
+ ) -> tuple[bool, int]:
822
+ """
823
+ Get the current max builder fee approval for a user/builder pair.
824
+
825
+ Args:
826
+ user: User wallet address
827
+ builder: Builder wallet address
828
+
829
+ Returns:
830
+ (success, fee_in_tenths_bp) - The approved fee in tenths of basis points.
831
+ Returns 0 if no approval exists.
832
+ """
833
+ try:
834
+ body = {"type": "maxBuilderFee", "user": user, "builder": builder}
835
+ data = self.info.post("/info", body)
836
+ # Response is just an integer (tenths of basis points)
837
+ return True, int(data) if data is not None else 0
838
+ except Exception as exc:
839
+ self.logger.error(f"Failed to fetch max_builder_fee for {user}: {exc}")
840
+ return False, 0
841
+
842
+ async def approve_builder_fee(
843
+ self,
844
+ builder: str,
845
+ max_fee_rate: str,
846
+ address: str,
847
+ ) -> tuple[bool, dict[str, Any]]:
848
+ """
849
+ Approve a builder fee for a user.
850
+
851
+ Args:
852
+ builder: Builder wallet address
853
+ max_fee_rate: Fee rate as percentage string (e.g., "0.030%" for 30 tenths bp)
854
+ address: User wallet address
855
+
856
+ Returns:
857
+ (success, response_data or error_message)
858
+ """
859
+ if self.simulation:
860
+ self.logger.info(
861
+ f"[SIMULATION] approve_builder_fee: builder={builder}, "
862
+ f"rate={max_fee_rate}, address={address}"
863
+ )
864
+ return True, {"simulation": True, "status": "ok"}
865
+
866
+ if not self._executor:
867
+ raise NotImplementedError("No Hyperliquid executor configured.")
868
+
869
+ result = await self._executor.approve_builder_fee(
870
+ builder=builder,
871
+ max_fee_rate=max_fee_rate,
872
+ address=address,
873
+ )
874
+
875
+ success = result.get("status") == "ok"
876
+ return success, result
877
+
878
+ async def place_limit_order(
879
+ self,
880
+ asset_id: int,
881
+ is_buy: bool,
882
+ price: float,
883
+ size: float,
884
+ address: str,
885
+ *,
886
+ reduce_only: bool = False,
887
+ builder: dict[str, Any] | None = None,
888
+ ) -> tuple[bool, dict[str, Any]]:
889
+ """
890
+ Place a limit order (GTC - Good Till Cancelled).
891
+
892
+ Used for spot stop-loss orders in basis trading.
893
+
894
+ Args:
895
+ asset_id: Asset ID (perp < 10000, spot >= 10000)
896
+ is_buy: True for buy, False for sell
897
+ price: Limit price
898
+ size: Order size
899
+ address: Wallet address
900
+ reduce_only: If True, only reduces existing position
901
+ builder: Optional builder fee config
902
+
903
+ Returns:
904
+ (success, response_data or error_message)
905
+ """
906
+ if self.simulation:
907
+ self.logger.info(
908
+ f"[SIMULATION] place_limit_order: asset={asset_id}, "
909
+ f"is_buy={is_buy}, price={price}, size={size}"
910
+ )
911
+ return True, {"simulation": True, "status": "ok"}
912
+
913
+ if not self._executor:
914
+ raise NotImplementedError("No Hyperliquid executor configured.")
915
+
916
+ result = await self._executor.place_limit_order(
917
+ asset_id=asset_id,
918
+ is_buy=is_buy,
919
+ price=price,
920
+ size=size,
921
+ address=address,
922
+ reduce_only=reduce_only,
923
+ builder=builder,
924
+ )
925
+
926
+ success = result.get("status") == "ok"
927
+ return success, result
928
+
929
+ async def wait_for_deposit(
930
+ self,
931
+ address: str,
932
+ expected_increase: float,
933
+ *,
934
+ timeout_s: int = 120,
935
+ poll_interval_s: int = 5,
936
+ ) -> tuple[bool, float]:
937
+ """
938
+ Wait for a deposit to be credited on Hyperliquid L1.
939
+
940
+ Args:
941
+ address: Wallet address
942
+ expected_increase: Expected USD amount to be deposited
943
+ timeout_s: Maximum time to wait in seconds
944
+ poll_interval_s: Time between polling attempts
945
+
946
+ Returns:
947
+ (success, final_balance) - True if deposit confirmed within timeout
948
+ """
949
+ iterations = timeout_s // poll_interval_s
950
+
951
+ # Get initial balance
952
+ success, initial_state = await self.get_user_state(address)
953
+ if not success:
954
+ self.logger.warning(f"Could not fetch initial state: {initial_state}")
955
+ initial_balance = 0.0
956
+ else:
957
+ initial_balance = self.get_perp_margin_amount(initial_state)
958
+
959
+ self.logger.info(
960
+ f"Waiting for Hyperliquid deposit. Initial balance: ${initial_balance:.2f}, "
961
+ f"expecting +${expected_increase:.2f}"
962
+ )
963
+
964
+ for i in range(iterations):
965
+ await asyncio.sleep(poll_interval_s)
966
+
967
+ success, state = await self.get_user_state(address)
968
+ if not success:
969
+ continue
970
+
971
+ current_balance = self.get_perp_margin_amount(state)
972
+
973
+ # Allow 5% tolerance for fees/slippage
974
+ if current_balance >= initial_balance + expected_increase * 0.95:
975
+ self.logger.info(
976
+ f"Hyperliquid deposit confirmed: ${current_balance - initial_balance:.2f} "
977
+ f"(expected ${expected_increase:.2f})"
978
+ )
979
+ return True, current_balance
980
+
981
+ remaining_s = (iterations - i - 1) * poll_interval_s
982
+ self.logger.debug(
983
+ f"Waiting for deposit... current=${current_balance:.2f}, "
984
+ f"need=${initial_balance + expected_increase:.2f}, {remaining_s}s remaining"
985
+ )
986
+
987
+ self.logger.warning(
988
+ f"Hyperliquid deposit not confirmed after {timeout_s}s. "
989
+ f"Deposits typically take 1-2 minutes."
990
+ )
991
+ # Return current balance even if not confirmed
992
+ success, state = await self.get_user_state(address)
993
+ final_balance = (
994
+ self.get_perp_margin_amount(state) if success else initial_balance
995
+ )
996
+ return False, final_balance
997
+
998
+ async def get_user_withdrawals(
999
+ self,
1000
+ address: str,
1001
+ from_timestamp_ms: int,
1002
+ ) -> tuple[bool, dict[str, float]]:
1003
+ """
1004
+ Get user withdrawal history from Hyperliquid.
1005
+
1006
+ Args:
1007
+ address: Wallet address
1008
+ from_timestamp_ms: Start time in milliseconds
1009
+
1010
+ Returns:
1011
+ (success, {tx_hash: usdc_amount})
1012
+ """
1013
+ try:
1014
+ from eth_utils import to_checksum_address
1015
+
1016
+ data = self.info.post(
1017
+ "/info",
1018
+ {
1019
+ "type": "userNonFundingLedgerUpdates",
1020
+ "user": to_checksum_address(address),
1021
+ "startTime": int(from_timestamp_ms),
1022
+ },
1023
+ )
1024
+
1025
+ result = {}
1026
+ # Sort earliest to latest
1027
+ for update in sorted(data or [], key=lambda x: x.get("time", 0)):
1028
+ delta = update.get("delta") or {}
1029
+ if delta.get("type") == "withdraw":
1030
+ tx_hash = update.get("hash")
1031
+ usdc_amount = float(delta.get("usdc", 0))
1032
+ if tx_hash:
1033
+ result[tx_hash] = usdc_amount
1034
+
1035
+ return True, result
1036
+
1037
+ except Exception as exc:
1038
+ self.logger.error(f"Failed to get user withdrawals: {exc}")
1039
+ return False, {}
1040
+
1041
+ async def wait_for_withdrawal(
1042
+ self,
1043
+ address: str,
1044
+ *,
1045
+ lookback_s: int = 5,
1046
+ max_poll_time_s: int = 30 * 60,
1047
+ poll_interval_s: int = 5,
1048
+ ) -> tuple[bool, dict[str, float]]:
1049
+ """
1050
+ Wait for a withdrawal to appear on-chain.
1051
+
1052
+ Polls Hyperliquid's ledger updates until a withdrawal is detected.
1053
+ Withdrawals typically take 5-15 minutes to process.
1054
+
1055
+ Args:
1056
+ address: Wallet address
1057
+ lookback_s: How far back to look for withdrawals (small buffer for latency)
1058
+ max_poll_time_s: Maximum time to wait (default 30 minutes)
1059
+ poll_interval_s: Time between polls
1060
+
1061
+ Returns:
1062
+ (success, {tx_hash: usdc_amount}) - withdrawals found
1063
+ """
1064
+ import time
1065
+
1066
+ start_time_ms = time.time() * 1000
1067
+ iterations = int(max_poll_time_s / poll_interval_s) + 1
1068
+
1069
+ for i in range(iterations, 0, -1):
1070
+ # Check for withdrawals since just before we started
1071
+ check_from_ms = start_time_ms - (lookback_s * 1000)
1072
+ success, withdrawals = await self.get_user_withdrawals(
1073
+ address, int(check_from_ms)
1074
+ )
1075
+
1076
+ if success and withdrawals:
1077
+ self.logger.info(
1078
+ f"Found {len(withdrawals)} withdrawal(s): {withdrawals}"
1079
+ )
1080
+ return True, withdrawals
1081
+
1082
+ remaining_s = i * poll_interval_s
1083
+ self.logger.info(
1084
+ f"Waiting for withdrawal to appear on-chain... "
1085
+ f"{remaining_s}s remaining (withdrawals often take 10+ minutes)"
1086
+ )
1087
+ await asyncio.sleep(poll_interval_s)
1088
+
1089
+ self.logger.warning(
1090
+ f"No withdrawal detected after {max_poll_time_s}s. "
1091
+ "The withdrawal may still be processing."
1092
+ )
1093
+ return False, {}