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.
- wayfinder_paths/adapters/balance_adapter/README.md +0 -10
- wayfinder_paths/adapters/balance_adapter/adapter.py +0 -20
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +0 -30
- wayfinder_paths/adapters/brap_adapter/adapter.py +3 -2
- wayfinder_paths/adapters/brap_adapter/test_adapter.py +9 -13
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +14 -7
- wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +18 -0
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +1093 -0
- wayfinder_paths/adapters/hyperliquid_adapter/executor.py +549 -0
- wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +8 -0
- wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1050 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +126 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +219 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +220 -0
- wayfinder_paths/adapters/hyperliquid_adapter/utils.py +134 -0
- wayfinder_paths/adapters/ledger_adapter/test_adapter.py +7 -6
- wayfinder_paths/adapters/pool_adapter/README.md +3 -28
- wayfinder_paths/adapters/pool_adapter/adapter.py +0 -72
- wayfinder_paths/adapters/pool_adapter/examples.json +0 -43
- wayfinder_paths/adapters/pool_adapter/test_adapter.py +4 -54
- wayfinder_paths/adapters/token_adapter/test_adapter.py +4 -14
- wayfinder_paths/core/adapters/models.py +9 -4
- wayfinder_paths/core/analytics/__init__.py +11 -0
- wayfinder_paths/core/analytics/bootstrap.py +57 -0
- wayfinder_paths/core/analytics/stats.py +48 -0
- wayfinder_paths/core/analytics/test_analytics.py +170 -0
- wayfinder_paths/core/clients/BRAPClient.py +1 -0
- wayfinder_paths/core/clients/LedgerClient.py +2 -7
- wayfinder_paths/core/clients/PoolClient.py +0 -16
- wayfinder_paths/core/clients/WalletClient.py +0 -27
- wayfinder_paths/core/clients/protocols.py +104 -18
- wayfinder_paths/scripts/make_wallets.py +9 -0
- wayfinder_paths/scripts/run_strategy.py +124 -0
- wayfinder_paths/strategies/basis_trading_strategy/README.md +213 -0
- wayfinder_paths/strategies/basis_trading_strategy/__init__.py +3 -0
- wayfinder_paths/strategies/basis_trading_strategy/constants.py +1 -0
- wayfinder_paths/strategies/basis_trading_strategy/examples.json +16 -0
- wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +23 -0
- wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1011 -0
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +4522 -0
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +727 -0
- wayfinder_paths/strategies/basis_trading_strategy/types.py +39 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +1 -9
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +36 -5
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +367 -278
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +204 -7
- {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/METADATA +32 -3
- {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/RECORD +50 -27
- {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/LICENSE +0 -0
- {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, {}
|