wayfinder-paths 0.1.23__py3-none-any.whl → 0.1.24__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/adapter.py +250 -0
- wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +0 -11
- wayfinder_paths/adapters/boros_adapter/__init__.py +17 -0
- wayfinder_paths/adapters/boros_adapter/adapter.py +1574 -0
- wayfinder_paths/adapters/boros_adapter/client.py +476 -0
- wayfinder_paths/adapters/boros_adapter/manifest.yaml +10 -0
- wayfinder_paths/adapters/boros_adapter/parsers.py +88 -0
- wayfinder_paths/adapters/boros_adapter/test_adapter.py +460 -0
- wayfinder_paths/adapters/boros_adapter/test_golden.py +156 -0
- wayfinder_paths/adapters/boros_adapter/types.py +70 -0
- wayfinder_paths/adapters/boros_adapter/utils.py +85 -0
- wayfinder_paths/adapters/brap_adapter/adapter.py +1 -1
- wayfinder_paths/adapters/brap_adapter/manifest.yaml +9 -0
- wayfinder_paths/adapters/hyperlend_adapter/adapter.py +161 -26
- wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +9 -0
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +77 -13
- wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +2 -9
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +585 -61
- wayfinder_paths/adapters/hyperliquid_adapter/executor.py +47 -68
- wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +14 -0
- wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +2 -3
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +17 -21
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +3 -6
- wayfinder_paths/adapters/hyperliquid_adapter/test_executor.py +4 -8
- wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +2 -2
- wayfinder_paths/adapters/ledger_adapter/manifest.yaml +7 -0
- wayfinder_paths/adapters/ledger_adapter/test_adapter.py +1 -2
- wayfinder_paths/adapters/moonwell_adapter/adapter.py +592 -400
- wayfinder_paths/adapters/moonwell_adapter/manifest.yaml +14 -0
- wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +126 -219
- wayfinder_paths/adapters/multicall_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/multicall_adapter/adapter.py +166 -0
- wayfinder_paths/adapters/multicall_adapter/manifest.yaml +5 -0
- wayfinder_paths/adapters/multicall_adapter/test_adapter.py +97 -0
- wayfinder_paths/adapters/pendle_adapter/README.md +102 -0
- wayfinder_paths/adapters/pendle_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/pendle_adapter/adapter.py +1992 -0
- wayfinder_paths/adapters/pendle_adapter/examples.json +11 -0
- wayfinder_paths/adapters/pendle_adapter/manifest.yaml +21 -0
- wayfinder_paths/adapters/pendle_adapter/test_adapter.py +666 -0
- wayfinder_paths/adapters/pool_adapter/manifest.yaml +6 -0
- wayfinder_paths/adapters/token_adapter/examples.json +0 -4
- wayfinder_paths/adapters/token_adapter/manifest.yaml +7 -0
- wayfinder_paths/conftest.py +24 -17
- wayfinder_paths/core/adapters/BaseAdapter.py +0 -25
- wayfinder_paths/core/adapters/models.py +17 -7
- wayfinder_paths/core/clients/BRAPClient.py +1 -1
- wayfinder_paths/core/clients/TokenClient.py +47 -1
- wayfinder_paths/core/clients/WayfinderClient.py +1 -2
- wayfinder_paths/core/clients/protocols.py +21 -22
- wayfinder_paths/core/clients/test_ledger_client.py +448 -0
- wayfinder_paths/core/config.py +12 -0
- wayfinder_paths/core/constants/__init__.py +15 -0
- wayfinder_paths/core/constants/base.py +6 -1
- wayfinder_paths/core/constants/contracts.py +39 -26
- wayfinder_paths/core/constants/erc20_abi.py +0 -1
- wayfinder_paths/core/constants/hyperlend_abi.py +0 -4
- wayfinder_paths/core/constants/hyperliquid.py +16 -0
- wayfinder_paths/core/constants/moonwell_abi.py +0 -15
- wayfinder_paths/core/engine/manifest.py +66 -0
- wayfinder_paths/core/strategies/Strategy.py +0 -61
- wayfinder_paths/core/strategies/__init__.py +10 -1
- wayfinder_paths/core/strategies/opa_loop.py +167 -0
- wayfinder_paths/core/utils/test_transaction.py +289 -0
- wayfinder_paths/core/utils/transaction.py +44 -1
- wayfinder_paths/core/utils/web3.py +3 -0
- wayfinder_paths/mcp/__init__.py +5 -0
- wayfinder_paths/mcp/preview.py +185 -0
- wayfinder_paths/mcp/scripting.py +84 -0
- wayfinder_paths/mcp/server.py +52 -0
- wayfinder_paths/mcp/state/profile_store.py +195 -0
- wayfinder_paths/mcp/state/store.py +89 -0
- wayfinder_paths/mcp/test_scripting.py +267 -0
- wayfinder_paths/mcp/tools/__init__.py +0 -0
- wayfinder_paths/mcp/tools/balances.py +290 -0
- wayfinder_paths/mcp/tools/discovery.py +158 -0
- wayfinder_paths/mcp/tools/execute.py +770 -0
- wayfinder_paths/mcp/tools/hyperliquid.py +931 -0
- wayfinder_paths/mcp/tools/quotes.py +288 -0
- wayfinder_paths/mcp/tools/run_script.py +286 -0
- wayfinder_paths/mcp/tools/strategies.py +188 -0
- wayfinder_paths/mcp/tools/tokens.py +46 -0
- wayfinder_paths/mcp/tools/wallets.py +354 -0
- wayfinder_paths/mcp/utils.py +129 -0
- wayfinder_paths/policies/hyperliquid.py +1 -1
- wayfinder_paths/policies/lifi.py +18 -0
- wayfinder_paths/policies/util.py +8 -2
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +28 -119
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +24 -53
- wayfinder_paths/strategies/boros_hype_strategy/__init__.py +3 -0
- wayfinder_paths/strategies/boros_hype_strategy/boros_ops_mixin.py +450 -0
- wayfinder_paths/strategies/boros_hype_strategy/constants.py +255 -0
- wayfinder_paths/strategies/boros_hype_strategy/examples.json +37 -0
- wayfinder_paths/strategies/boros_hype_strategy/hyperevm_ops_mixin.py +114 -0
- wayfinder_paths/strategies/boros_hype_strategy/hyperliquid_ops_mixin.py +642 -0
- wayfinder_paths/strategies/boros_hype_strategy/manifest.yaml +36 -0
- wayfinder_paths/strategies/boros_hype_strategy/planner.py +460 -0
- wayfinder_paths/strategies/boros_hype_strategy/risk_ops_mixin.py +886 -0
- wayfinder_paths/strategies/boros_hype_strategy/snapshot_mixin.py +494 -0
- wayfinder_paths/strategies/boros_hype_strategy/strategy.py +1194 -0
- wayfinder_paths/strategies/boros_hype_strategy/test_planner_golden.py +374 -0
- wayfinder_paths/strategies/boros_hype_strategy/test_strategy.py +202 -0
- wayfinder_paths/strategies/boros_hype_strategy/types.py +365 -0
- wayfinder_paths/strategies/boros_hype_strategy/withdraw_mixin.py +997 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +3 -12
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +7 -29
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +63 -40
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +5 -15
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +0 -34
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +11 -34
- wayfinder_paths/tests/test_mcp_quote_swap.py +165 -0
- wayfinder_paths/tests/test_test_coverage.py +1 -4
- wayfinder_paths-0.1.24.dist-info/METADATA +378 -0
- wayfinder_paths-0.1.24.dist-info/RECORD +185 -0
- {wayfinder_paths-0.1.23.dist-info → wayfinder_paths-0.1.24.dist-info}/WHEEL +1 -1
- wayfinder_paths/scripts/create_strategy.py +0 -139
- wayfinder_paths/scripts/make_wallets.py +0 -142
- wayfinder_paths-0.1.23.dist-info/METADATA +0 -354
- wayfinder_paths-0.1.23.dist-info/RECORD +0 -120
- /wayfinder_paths/{scripts → mcp/state}/__init__.py +0 -0
- {wayfinder_paths-0.1.23.dist-info → wayfinder_paths-0.1.24.dist-info}/LICENSE +0 -0
|
@@ -2,12 +2,24 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import time
|
|
5
|
+
from decimal import ROUND_DOWN, Decimal, getcontext
|
|
5
6
|
from typing import TYPE_CHECKING, Any
|
|
6
7
|
|
|
8
|
+
from aiocache import Cache
|
|
9
|
+
from eth_utils import to_checksum_address
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
7
12
|
from wayfinder_paths.core.adapters.BaseAdapter import BaseAdapter
|
|
8
|
-
from wayfinder_paths.core.constants
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
from wayfinder_paths.core.constants import ZERO_ADDRESS
|
|
14
|
+
from wayfinder_paths.core.constants.hyperliquid import (
|
|
15
|
+
ARBITRUM_USDC_ADDRESS as _ARBITRUM_USDC_ADDRESS,
|
|
16
|
+
)
|
|
17
|
+
from wayfinder_paths.core.constants.hyperliquid import (
|
|
18
|
+
DEFAULT_HYPERLIQUID_BUILDER_FEE_TENTHS_BP,
|
|
19
|
+
HYPE_FEE_WALLET,
|
|
20
|
+
)
|
|
21
|
+
from wayfinder_paths.core.constants.hyperliquid import (
|
|
22
|
+
HYPERLIQUID_BRIDGE_ADDRESS as _HYPERLIQUID_BRIDGE_ADDRESS,
|
|
11
23
|
)
|
|
12
24
|
|
|
13
25
|
if TYPE_CHECKING:
|
|
@@ -15,8 +27,9 @@ if TYPE_CHECKING:
|
|
|
15
27
|
HyperliquidExecutorProtocol as HyperliquidExecutor,
|
|
16
28
|
)
|
|
17
29
|
|
|
18
|
-
|
|
19
|
-
|
|
30
|
+
# Re-export Bridge2 constants for backwards compatibility.
|
|
31
|
+
HYPERLIQUID_BRIDGE_ADDRESS = _HYPERLIQUID_BRIDGE_ADDRESS
|
|
32
|
+
ARBITRUM_USDC_ADDRESS = _ARBITRUM_USDC_ADDRESS
|
|
20
33
|
|
|
21
34
|
try:
|
|
22
35
|
from hyperliquid.info import Info
|
|
@@ -29,36 +42,26 @@ except ImportError:
|
|
|
29
42
|
constants = None
|
|
30
43
|
|
|
31
44
|
|
|
32
|
-
class SimpleCache:
|
|
33
|
-
def __init__(self):
|
|
34
|
-
self._cache: dict[str, Any] = {}
|
|
35
|
-
self._expiry: dict[str, float] = {}
|
|
36
|
-
|
|
37
|
-
def get(self, key: str) -> Any | None:
|
|
38
|
-
if key in self._cache:
|
|
39
|
-
if time.time() < self._expiry.get(key, 0):
|
|
40
|
-
return self._cache[key]
|
|
41
|
-
del self._cache[key]
|
|
42
|
-
if key in self._expiry:
|
|
43
|
-
del self._expiry[key]
|
|
44
|
-
return None
|
|
45
|
-
|
|
46
|
-
def set(self, key: str, value: Any, timeout: int = 300) -> None:
|
|
47
|
-
self._cache[key] = value
|
|
48
|
-
self._expiry[key] = time.time() + timeout
|
|
49
|
-
|
|
50
|
-
def clear(self) -> None:
|
|
51
|
-
self._cache.clear()
|
|
52
|
-
self._expiry.clear()
|
|
53
|
-
|
|
54
|
-
|
|
55
45
|
class HyperliquidAdapter(BaseAdapter):
|
|
46
|
+
"""
|
|
47
|
+
Adapter for Hyperliquid exchange operations.
|
|
48
|
+
|
|
49
|
+
Wraps the hyperliquid SDK directly for market data access.
|
|
50
|
+
Uses Hyperliquid's public API for:
|
|
51
|
+
- Market metadata (perp and spot)
|
|
52
|
+
- Funding rate history
|
|
53
|
+
- Price candles
|
|
54
|
+
- Order book snapshots
|
|
55
|
+
- User positions and balances
|
|
56
|
+
"""
|
|
57
|
+
|
|
56
58
|
adapter_type = "HYPERLIQUID"
|
|
57
59
|
|
|
58
60
|
def __init__(
|
|
59
61
|
self,
|
|
60
62
|
config: dict[str, Any] | None = None,
|
|
61
63
|
*,
|
|
64
|
+
simulation: bool = False,
|
|
62
65
|
executor: HyperliquidExecutor | None = None,
|
|
63
66
|
) -> None:
|
|
64
67
|
super().__init__("hyperliquid_adapter", config)
|
|
@@ -69,9 +72,11 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
69
72
|
"Install with: poetry add hyperliquid"
|
|
70
73
|
)
|
|
71
74
|
|
|
72
|
-
self.
|
|
75
|
+
self.simulation = simulation
|
|
76
|
+
self._cache = Cache(Cache.MEMORY)
|
|
73
77
|
self._executor = executor
|
|
74
78
|
|
|
79
|
+
# Initialize Hyperliquid Info client
|
|
75
80
|
self.info = Info(constants.MAINNET_API_URL, skip_ws=True)
|
|
76
81
|
|
|
77
82
|
# Cache asset mappings after first fetch
|
|
@@ -95,13 +100,14 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
95
100
|
|
|
96
101
|
async def get_meta_and_asset_ctxs(self) -> tuple[bool, Any]:
|
|
97
102
|
cache_key = "hl_meta_and_asset_ctxs"
|
|
98
|
-
cached = self._cache.get(cache_key)
|
|
103
|
+
cached = await self._cache.get(cache_key)
|
|
99
104
|
if cached:
|
|
100
105
|
return True, cached
|
|
101
106
|
|
|
102
107
|
try:
|
|
103
108
|
data = self.info.meta_and_asset_ctxs()
|
|
104
|
-
|
|
109
|
+
# Cache for 1 minute
|
|
110
|
+
await self._cache.set(cache_key, data, ttl=60)
|
|
105
111
|
return True, data
|
|
106
112
|
except Exception as exc:
|
|
107
113
|
self.logger.error(f"Failed to fetch meta_and_asset_ctxs: {exc}")
|
|
@@ -109,25 +115,85 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
109
115
|
|
|
110
116
|
async def get_spot_meta(self) -> tuple[bool, Any]:
|
|
111
117
|
cache_key = "hl_spot_meta"
|
|
112
|
-
cached = self._cache.get(cache_key)
|
|
118
|
+
cached = await self._cache.get(cache_key)
|
|
113
119
|
if cached:
|
|
114
120
|
return True, cached
|
|
115
121
|
|
|
116
122
|
try:
|
|
123
|
+
# Handle both callable and property access patterns
|
|
117
124
|
spot_meta = self.info.spot_meta
|
|
118
125
|
if callable(spot_meta):
|
|
119
126
|
data = spot_meta()
|
|
120
127
|
else:
|
|
121
128
|
data = spot_meta
|
|
122
|
-
self._cache.set(cache_key, data,
|
|
129
|
+
await self._cache.set(cache_key, data, ttl=60)
|
|
123
130
|
return True, data
|
|
124
131
|
except Exception as exc:
|
|
125
132
|
self.logger.error(f"Failed to fetch spot_meta: {exc}")
|
|
126
133
|
return False, str(exc)
|
|
127
134
|
|
|
135
|
+
async def get_spot_token_sz_decimals(self, coin: str) -> int | None:
|
|
136
|
+
try:
|
|
137
|
+
success, spot_meta = await self.get_spot_meta()
|
|
138
|
+
if not success or not isinstance(spot_meta, dict):
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
for token in spot_meta.get("tokens", []):
|
|
142
|
+
name = token.get("name") or token.get("coin") or token.get("symbol")
|
|
143
|
+
if not name:
|
|
144
|
+
continue
|
|
145
|
+
if str(name).upper() != str(coin).upper():
|
|
146
|
+
continue
|
|
147
|
+
sz_decimals = token.get("szDecimals") or token.get("sz_decimals")
|
|
148
|
+
if sz_decimals is None:
|
|
149
|
+
return None
|
|
150
|
+
return int(sz_decimals)
|
|
151
|
+
except Exception: # noqa: BLE001
|
|
152
|
+
return None
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def max_transferable_amount(
|
|
157
|
+
total: str,
|
|
158
|
+
hold: str,
|
|
159
|
+
*,
|
|
160
|
+
sz_decimals: int,
|
|
161
|
+
leave_one_tick: bool = True,
|
|
162
|
+
) -> float:
|
|
163
|
+
"""Compute a safe transferable amount (Decimal math, round down, leave 1 tick).
|
|
164
|
+
|
|
165
|
+
Hyperliquid requires amounts to respect szDecimals. This helper avoids
|
|
166
|
+
float rounding edge cases by:
|
|
167
|
+
- parsing balances as Decimal
|
|
168
|
+
- computing available = total - hold
|
|
169
|
+
- rounding down to sz_decimals
|
|
170
|
+
- optionally leaving 1 tick so we don't request the full balance
|
|
171
|
+
"""
|
|
172
|
+
getcontext().prec = 50
|
|
173
|
+
|
|
174
|
+
if sz_decimals < 0:
|
|
175
|
+
sz_decimals = 0
|
|
176
|
+
|
|
177
|
+
step = Decimal(10) ** (-int(sz_decimals))
|
|
178
|
+
|
|
179
|
+
total_d = Decimal(str(total or "0"))
|
|
180
|
+
hold_d = Decimal(str(hold or "0"))
|
|
181
|
+
available = total_d - hold_d
|
|
182
|
+
if available <= 0:
|
|
183
|
+
return 0.0
|
|
184
|
+
|
|
185
|
+
safe = available - step if leave_one_tick else available
|
|
186
|
+
if safe <= 0:
|
|
187
|
+
return 0.0
|
|
188
|
+
|
|
189
|
+
quantized = (safe / step).to_integral_value(rounding=ROUND_DOWN) * step
|
|
190
|
+
if quantized <= 0:
|
|
191
|
+
return 0.0
|
|
192
|
+
return float(quantized)
|
|
193
|
+
|
|
128
194
|
async def get_spot_assets(self) -> tuple[bool, dict[str, int]]:
|
|
129
195
|
cache_key = "hl_spot_assets"
|
|
130
|
-
cached = self._cache.get(cache_key)
|
|
196
|
+
cached = await self._cache.get(cache_key)
|
|
131
197
|
if cached:
|
|
132
198
|
return True, cached
|
|
133
199
|
|
|
@@ -147,6 +213,7 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
147
213
|
|
|
148
214
|
base_idx, quote_idx = pair_tokens[0], pair_tokens[1]
|
|
149
215
|
|
|
216
|
+
# Get token names
|
|
150
217
|
base_info = tokens[base_idx] if base_idx < len(tokens) else {}
|
|
151
218
|
quote_info = tokens[quote_idx] if quote_idx < len(tokens) else {}
|
|
152
219
|
|
|
@@ -157,16 +224,19 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
157
224
|
spot_asset_id = pair.get("index", 0) + 10000
|
|
158
225
|
response[name] = spot_asset_id
|
|
159
226
|
|
|
160
|
-
|
|
227
|
+
# Cache for 5 min
|
|
228
|
+
await self._cache.set(cache_key, response, ttl=300)
|
|
161
229
|
return True, response
|
|
162
230
|
|
|
163
231
|
except Exception as exc:
|
|
164
232
|
self.logger.error(f"Failed to get spot assets: {exc}")
|
|
165
233
|
return False, {}
|
|
166
234
|
|
|
167
|
-
def get_spot_asset_id(
|
|
235
|
+
async def get_spot_asset_id(
|
|
236
|
+
self, base_coin: str, quote_coin: str = "USDC"
|
|
237
|
+
) -> int | None:
|
|
168
238
|
cache_key = "hl_spot_assets"
|
|
169
|
-
cached = self._cache.get(cache_key)
|
|
239
|
+
cached = await self._cache.get(cache_key)
|
|
170
240
|
if cached:
|
|
171
241
|
pair_name = f"{base_coin}/{quote_coin}"
|
|
172
242
|
return cached.get(pair_name)
|
|
@@ -229,9 +299,63 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
229
299
|
self.logger.error(f"Failed to fetch spot_user_state for {address}: {exc}")
|
|
230
300
|
return False, str(exc)
|
|
231
301
|
|
|
302
|
+
async def get_full_user_state(
|
|
303
|
+
self,
|
|
304
|
+
*,
|
|
305
|
+
account: str,
|
|
306
|
+
include_spot: bool = True,
|
|
307
|
+
include_open_orders: bool = True,
|
|
308
|
+
include_frontend_open_orders: bool = True,
|
|
309
|
+
) -> tuple[bool, dict[str, Any] | str]:
|
|
310
|
+
"""
|
|
311
|
+
Full Hyperliquid user state snapshot.
|
|
312
|
+
|
|
313
|
+
Includes perp positions (user_state), optional spot balances, and optional open
|
|
314
|
+
orders (frontendOpenOrders by default, since it includes trigger orders).
|
|
315
|
+
"""
|
|
316
|
+
out: dict[str, Any] = {
|
|
317
|
+
"protocol": "hyperliquid",
|
|
318
|
+
"account": account,
|
|
319
|
+
"perp": None,
|
|
320
|
+
"spot": None,
|
|
321
|
+
"openOrders": None,
|
|
322
|
+
"errors": {},
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
ok_any = False
|
|
326
|
+
|
|
327
|
+
ok_perp, perp = await self.get_user_state(account)
|
|
328
|
+
if ok_perp:
|
|
329
|
+
ok_any = True
|
|
330
|
+
out["perp"] = perp
|
|
331
|
+
out["positions"] = perp.get("assetPositions", [])
|
|
332
|
+
else:
|
|
333
|
+
out["errors"]["perp"] = perp
|
|
334
|
+
|
|
335
|
+
if include_spot:
|
|
336
|
+
ok_spot, spot = await self.get_spot_user_state(account)
|
|
337
|
+
if ok_spot:
|
|
338
|
+
ok_any = True
|
|
339
|
+
out["spot"] = spot
|
|
340
|
+
else:
|
|
341
|
+
out["errors"]["spot"] = spot
|
|
342
|
+
|
|
343
|
+
if include_open_orders:
|
|
344
|
+
if include_frontend_open_orders:
|
|
345
|
+
ok_orders, orders = await self.get_frontend_open_orders(account)
|
|
346
|
+
else:
|
|
347
|
+
ok_orders, orders = await self.get_open_orders(account)
|
|
348
|
+
if ok_orders:
|
|
349
|
+
ok_any = True
|
|
350
|
+
out["openOrders"] = orders
|
|
351
|
+
else:
|
|
352
|
+
out["errors"]["openOrders"] = orders
|
|
353
|
+
|
|
354
|
+
return ok_any, out
|
|
355
|
+
|
|
232
356
|
async def get_margin_table(self, margin_table_id: int) -> tuple[bool, list[dict]]:
|
|
233
357
|
cache_key = f"hl_margin_table_{margin_table_id}"
|
|
234
|
-
cached = self._cache.get(cache_key)
|
|
358
|
+
cached = await self._cache.get(cache_key)
|
|
235
359
|
if cached:
|
|
236
360
|
return True, cached
|
|
237
361
|
|
|
@@ -244,7 +368,7 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
244
368
|
except Exception: # noqa: BLE001 - try alternate payload key
|
|
245
369
|
body = {"type": "marginTable", "marginTableId": int(margin_table_id)}
|
|
246
370
|
data = self.info.post("/info", body)
|
|
247
|
-
self._cache.set(cache_key, data,
|
|
371
|
+
await self._cache.set(cache_key, data, ttl=86400) # Cache for 24h
|
|
248
372
|
return True, data
|
|
249
373
|
except Exception as exc:
|
|
250
374
|
self.logger.error(f"Failed to fetch margin_table {margin_table_id}: {exc}")
|
|
@@ -285,6 +409,7 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
285
409
|
|
|
286
410
|
@property
|
|
287
411
|
def coin_to_asset(self) -> dict[str, int]:
|
|
412
|
+
"""Get coin name to asset ID mapping (perps only)."""
|
|
288
413
|
if self._coin_to_asset is None:
|
|
289
414
|
self._coin_to_asset = dict(self.info.coin_to_asset)
|
|
290
415
|
return self._coin_to_asset
|
|
@@ -297,10 +422,10 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
297
422
|
f"Unknown asset_id {asset_id}: missing szDecimals"
|
|
298
423
|
) from None
|
|
299
424
|
|
|
300
|
-
def refresh_mappings(self) -> None:
|
|
425
|
+
async def refresh_mappings(self) -> None:
|
|
301
426
|
self._asset_to_sz_decimals = None
|
|
302
427
|
self._coin_to_asset = None
|
|
303
|
-
self._cache.clear()
|
|
428
|
+
await self._cache.clear()
|
|
304
429
|
|
|
305
430
|
# ------------------------------------------------------------------ #
|
|
306
431
|
# Utility Methods #
|
|
@@ -316,8 +441,6 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
316
441
|
|
|
317
442
|
def get_valid_order_size(self, asset_id: int, size: float) -> float:
|
|
318
443
|
decimals = self.get_sz_decimals(asset_id)
|
|
319
|
-
from decimal import ROUND_DOWN, Decimal
|
|
320
|
-
|
|
321
444
|
step = Decimal(10) ** (-decimals)
|
|
322
445
|
if size <= 0:
|
|
323
446
|
return 0.0
|
|
@@ -330,6 +453,49 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
330
453
|
# Execution Methods (require signing callback) #
|
|
331
454
|
# ------------------------------------------------------------------ #
|
|
332
455
|
|
|
456
|
+
def _mandatory_builder_fee(self, builder: dict[str, Any] | None) -> dict[str, Any]:
|
|
457
|
+
"""
|
|
458
|
+
Resolve the builder fee config to attach to orders.
|
|
459
|
+
|
|
460
|
+
Builder attribution is mandatory in this repo and is always directed to
|
|
461
|
+
the Wayfinder builder wallet (`HYPE_FEE_WALLET`).
|
|
462
|
+
"""
|
|
463
|
+
expected_builder = HYPE_FEE_WALLET.lower()
|
|
464
|
+
|
|
465
|
+
if isinstance(builder, dict) and builder.get("b") is not None:
|
|
466
|
+
provided_builder = str(builder.get("b") or "").strip()
|
|
467
|
+
if provided_builder and provided_builder.lower() != expected_builder:
|
|
468
|
+
raise ValueError(
|
|
469
|
+
f"builder wallet must be {expected_builder} (got {provided_builder})"
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
fee = None
|
|
473
|
+
if isinstance(builder, dict) and builder.get("f") is not None:
|
|
474
|
+
fee = builder.get("f")
|
|
475
|
+
|
|
476
|
+
if fee is None and isinstance(self.config, dict):
|
|
477
|
+
cfg = self.config.get("builder_fee")
|
|
478
|
+
if isinstance(cfg, dict):
|
|
479
|
+
cfg_builder = str(cfg.get("b") or "").strip()
|
|
480
|
+
if cfg_builder and cfg_builder.lower() != expected_builder:
|
|
481
|
+
raise ValueError(
|
|
482
|
+
f"config builder_fee.b must be {expected_builder} (got {cfg_builder})"
|
|
483
|
+
)
|
|
484
|
+
if cfg.get("f") is not None:
|
|
485
|
+
fee = cfg.get("f")
|
|
486
|
+
|
|
487
|
+
if fee is None:
|
|
488
|
+
fee = DEFAULT_HYPERLIQUID_BUILDER_FEE_TENTHS_BP
|
|
489
|
+
|
|
490
|
+
try:
|
|
491
|
+
fee_i = int(fee)
|
|
492
|
+
except (TypeError, ValueError) as exc:
|
|
493
|
+
raise ValueError("builder fee f must be an int (tenths of bp)") from exc
|
|
494
|
+
if fee_i <= 0:
|
|
495
|
+
raise ValueError("builder fee f must be > 0 (tenths of bp)")
|
|
496
|
+
|
|
497
|
+
return {"b": expected_builder, "f": fee_i}
|
|
498
|
+
|
|
333
499
|
async def place_market_order(
|
|
334
500
|
self,
|
|
335
501
|
asset_id: int,
|
|
@@ -342,6 +508,31 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
342
508
|
cloid: str | None = None,
|
|
343
509
|
builder: dict[str, Any] | None = None,
|
|
344
510
|
) -> tuple[bool, dict[str, Any]]:
|
|
511
|
+
"""
|
|
512
|
+
Place a market order (IOC with slippage).
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
asset_id: Asset ID (perp < 10000, spot >= 10000)
|
|
516
|
+
is_buy: True for buy, False for sell
|
|
517
|
+
slippage: Slippage tolerance (0.0 to 1.0)
|
|
518
|
+
size: Order size in base units
|
|
519
|
+
address: Wallet address
|
|
520
|
+
reduce_only: If True, only reduce existing position
|
|
521
|
+
cloid: Client order ID (optional)
|
|
522
|
+
builder: Builder fee config; if omitted, a mandatory default is applied.
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
(success, response_data or error_message)
|
|
526
|
+
"""
|
|
527
|
+
builder = self._mandatory_builder_fee(builder)
|
|
528
|
+
|
|
529
|
+
if self.simulation:
|
|
530
|
+
self.logger.info(
|
|
531
|
+
f"[SIMULATION] place_market_order: asset={asset_id}, "
|
|
532
|
+
f"is_buy={is_buy}, size={size}, address={address}"
|
|
533
|
+
)
|
|
534
|
+
return True, {"simulation": True, "status": "ok"}
|
|
535
|
+
|
|
345
536
|
if not self._executor:
|
|
346
537
|
raise NotImplementedError(
|
|
347
538
|
"No Hyperliquid executor configured. "
|
|
@@ -359,7 +550,17 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
359
550
|
builder=builder,
|
|
360
551
|
)
|
|
361
552
|
|
|
553
|
+
# Check both the API status and the order statuses for errors
|
|
362
554
|
success = result.get("status") == "ok"
|
|
555
|
+
if success:
|
|
556
|
+
# Check if the order itself has errors in statuses
|
|
557
|
+
response = result.get("response", {})
|
|
558
|
+
data = response.get("data", {})
|
|
559
|
+
statuses = data.get("statuses", [])
|
|
560
|
+
for status in statuses:
|
|
561
|
+
if isinstance(status, dict) and status.get("error"):
|
|
562
|
+
success = False
|
|
563
|
+
break
|
|
363
564
|
return success, result
|
|
364
565
|
|
|
365
566
|
async def cancel_order(
|
|
@@ -368,6 +569,12 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
368
569
|
order_id: int | str,
|
|
369
570
|
address: str,
|
|
370
571
|
) -> tuple[bool, dict[str, Any]]:
|
|
572
|
+
if self.simulation:
|
|
573
|
+
self.logger.info(
|
|
574
|
+
f"[SIMULATION] cancel_order: asset={asset_id}, oid={order_id}"
|
|
575
|
+
)
|
|
576
|
+
return True, {"simulation": True, "status": "ok"}
|
|
577
|
+
|
|
371
578
|
if not self._executor:
|
|
372
579
|
raise NotImplementedError(
|
|
373
580
|
"No Hyperliquid executor configured. "
|
|
@@ -383,6 +590,163 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
383
590
|
success = result.get("status") == "ok"
|
|
384
591
|
return success, result
|
|
385
592
|
|
|
593
|
+
async def cancel_order_by_cloid(
|
|
594
|
+
self,
|
|
595
|
+
asset_id: int,
|
|
596
|
+
cloid: str,
|
|
597
|
+
address: str,
|
|
598
|
+
) -> tuple[bool, dict[str, Any]]:
|
|
599
|
+
if self.simulation:
|
|
600
|
+
logger.info(
|
|
601
|
+
f"[SIMULATION] cancel_order_by_cloid: asset={asset_id}, cloid={cloid}"
|
|
602
|
+
)
|
|
603
|
+
return True, {"simulation": True, "status": "ok"}
|
|
604
|
+
|
|
605
|
+
if not self._executor:
|
|
606
|
+
raise NotImplementedError(
|
|
607
|
+
"No Hyperliquid executor configured. "
|
|
608
|
+
"Inject a HyperliquidExecutor implementation (e.g., LocalHyperliquidExecutor)."
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
result = await self._executor.cancel_order_by_cloid(
|
|
612
|
+
asset_id=asset_id,
|
|
613
|
+
cloid=cloid,
|
|
614
|
+
address=address,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
success = result.get("status") == "ok"
|
|
618
|
+
return success, result
|
|
619
|
+
|
|
620
|
+
async def spot_transfer(
|
|
621
|
+
self,
|
|
622
|
+
*,
|
|
623
|
+
amount: float,
|
|
624
|
+
destination: str,
|
|
625
|
+
token: str,
|
|
626
|
+
address: str,
|
|
627
|
+
) -> tuple[bool, dict[str, Any]]:
|
|
628
|
+
"""
|
|
629
|
+
Transfer a spot token to a destination address (signed spotSend action).
|
|
630
|
+
|
|
631
|
+
This is used for:
|
|
632
|
+
- user-to-user spot transfers
|
|
633
|
+
- HyperCore → HyperEVM routing (destination = system address)
|
|
634
|
+
"""
|
|
635
|
+
if self.simulation:
|
|
636
|
+
logger.info(
|
|
637
|
+
f"[SIMULATION] spot_transfer: token={token}, amount={amount}, destination={destination}"
|
|
638
|
+
)
|
|
639
|
+
return True, {"simulation": True, "status": "ok"}
|
|
640
|
+
|
|
641
|
+
if not self._executor:
|
|
642
|
+
raise NotImplementedError("No Hyperliquid executor configured.")
|
|
643
|
+
|
|
644
|
+
result = await self._executor.spot_transfer(
|
|
645
|
+
amount=float(amount),
|
|
646
|
+
destination=str(destination),
|
|
647
|
+
token=str(token),
|
|
648
|
+
address=address,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
success = result.get("status") == "ok"
|
|
652
|
+
return success, result
|
|
653
|
+
|
|
654
|
+
@staticmethod
|
|
655
|
+
def hypercore_index_to_system_address(index: int) -> str:
|
|
656
|
+
if index == 150:
|
|
657
|
+
return "0x2222222222222222222222222222222222222222"
|
|
658
|
+
|
|
659
|
+
hex_index = f"{index:x}"
|
|
660
|
+
padding_length = 42 - len("0x20") - len(hex_index)
|
|
661
|
+
result = "0x20" + "0" * padding_length + hex_index
|
|
662
|
+
return to_checksum_address(result)
|
|
663
|
+
|
|
664
|
+
async def hypercore_get_token_metadata(
|
|
665
|
+
self, token_address: str | None
|
|
666
|
+
) -> dict[str, Any] | None:
|
|
667
|
+
"""
|
|
668
|
+
Resolve spot token metadata from Hyperliquid spot meta by EVM contract address.
|
|
669
|
+
|
|
670
|
+
Special-case: native HYPE uses the 0-address and maps to tokens[150].
|
|
671
|
+
"""
|
|
672
|
+
token_addr = (token_address or ZERO_ADDRESS).strip()
|
|
673
|
+
token_addr_lower = token_addr.lower()
|
|
674
|
+
|
|
675
|
+
success, spot_meta = await self.get_spot_meta()
|
|
676
|
+
if not success or not isinstance(spot_meta, dict):
|
|
677
|
+
return None
|
|
678
|
+
|
|
679
|
+
tokens = spot_meta.get("tokens", [])
|
|
680
|
+
if not isinstance(tokens, list) or not tokens:
|
|
681
|
+
return None
|
|
682
|
+
|
|
683
|
+
if token_addr_lower == ZERO_ADDRESS.lower():
|
|
684
|
+
token = tokens[150] if len(tokens) > 150 else None
|
|
685
|
+
return token if isinstance(token, dict) else None
|
|
686
|
+
|
|
687
|
+
for token_data in tokens:
|
|
688
|
+
if not isinstance(token_data, dict):
|
|
689
|
+
continue
|
|
690
|
+
evm_contract = token_data.get("evmContract")
|
|
691
|
+
if not isinstance(evm_contract, dict):
|
|
692
|
+
continue
|
|
693
|
+
address = evm_contract.get("address")
|
|
694
|
+
if isinstance(address, str) and address.lower() == token_addr_lower:
|
|
695
|
+
return token_data
|
|
696
|
+
|
|
697
|
+
return None
|
|
698
|
+
|
|
699
|
+
async def hypercore_to_hyperevm(
|
|
700
|
+
self,
|
|
701
|
+
*,
|
|
702
|
+
amount: float,
|
|
703
|
+
address: str,
|
|
704
|
+
token_address: str | None = None,
|
|
705
|
+
) -> tuple[bool, dict[str, Any]]:
|
|
706
|
+
"""
|
|
707
|
+
Transfer a spot token from HyperCore (Hyperliquid spot) to HyperEVM.
|
|
708
|
+
|
|
709
|
+
Notes:
|
|
710
|
+
- destination is the token's HyperEVM system address (NOT the user's wallet)
|
|
711
|
+
- token is formatted as "name:tokenId" from spot meta
|
|
712
|
+
"""
|
|
713
|
+
token_data = await self.hypercore_get_token_metadata(token_address)
|
|
714
|
+
if not token_data:
|
|
715
|
+
return False, {
|
|
716
|
+
"status": "err",
|
|
717
|
+
"response": {"type": "error", "data": "Token not found in spot meta"},
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
try:
|
|
721
|
+
index = int(token_data.get("index"))
|
|
722
|
+
except (TypeError, ValueError):
|
|
723
|
+
return False, {
|
|
724
|
+
"status": "err",
|
|
725
|
+
"response": {"type": "error", "data": "Token metadata missing index"},
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
destination = self.hypercore_index_to_system_address(index)
|
|
729
|
+
name = token_data.get("name")
|
|
730
|
+
token_id = token_data.get("tokenId")
|
|
731
|
+
if not isinstance(name, str) or not name:
|
|
732
|
+
return False, {
|
|
733
|
+
"status": "err",
|
|
734
|
+
"response": {"type": "error", "data": "Token metadata missing name"},
|
|
735
|
+
}
|
|
736
|
+
if token_id is None:
|
|
737
|
+
return False, {
|
|
738
|
+
"status": "err",
|
|
739
|
+
"response": {"type": "error", "data": "Token metadata missing tokenId"},
|
|
740
|
+
}
|
|
741
|
+
token_string = f"{name}:{token_id}"
|
|
742
|
+
|
|
743
|
+
return await self.spot_transfer(
|
|
744
|
+
amount=float(amount),
|
|
745
|
+
destination=destination,
|
|
746
|
+
token=token_string,
|
|
747
|
+
address=address,
|
|
748
|
+
)
|
|
749
|
+
|
|
386
750
|
async def update_leverage(
|
|
387
751
|
self,
|
|
388
752
|
asset_id: int,
|
|
@@ -390,6 +754,12 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
390
754
|
is_cross: bool,
|
|
391
755
|
address: str,
|
|
392
756
|
) -> tuple[bool, dict[str, Any]]:
|
|
757
|
+
if self.simulation:
|
|
758
|
+
self.logger.info(
|
|
759
|
+
f"[SIMULATION] update_leverage: asset={asset_id}, leverage={leverage}"
|
|
760
|
+
)
|
|
761
|
+
return True, {"simulation": True, "status": "ok"}
|
|
762
|
+
|
|
393
763
|
if not self._executor:
|
|
394
764
|
raise NotImplementedError("No Hyperliquid executor configured.")
|
|
395
765
|
|
|
@@ -408,6 +778,10 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
408
778
|
amount: float,
|
|
409
779
|
address: str,
|
|
410
780
|
) -> tuple[bool, dict[str, Any]]:
|
|
781
|
+
if self.simulation:
|
|
782
|
+
self.logger.info(f"[SIMULATION] transfer_spot_to_perp: {amount} USDC")
|
|
783
|
+
return True, {"simulation": True, "status": "ok"}
|
|
784
|
+
|
|
411
785
|
if not self._executor:
|
|
412
786
|
raise NotImplementedError("No Hyperliquid executor configured.")
|
|
413
787
|
|
|
@@ -424,6 +798,10 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
424
798
|
amount: float,
|
|
425
799
|
address: str,
|
|
426
800
|
) -> tuple[bool, dict[str, Any]]:
|
|
801
|
+
if self.simulation:
|
|
802
|
+
self.logger.info(f"[SIMULATION] transfer_perp_to_spot: {amount} USDC")
|
|
803
|
+
return True, {"simulation": True, "status": "ok"}
|
|
804
|
+
|
|
427
805
|
if not self._executor:
|
|
428
806
|
raise NotImplementedError("No Hyperliquid executor configured.")
|
|
429
807
|
|
|
@@ -443,6 +821,13 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
443
821
|
size: float,
|
|
444
822
|
address: str,
|
|
445
823
|
) -> tuple[bool, dict[str, Any]]:
|
|
824
|
+
if self.simulation:
|
|
825
|
+
self.logger.info(
|
|
826
|
+
f"[SIMULATION] place_stop_loss: asset={asset_id}, "
|
|
827
|
+
f"trigger={trigger_price}, size={size}"
|
|
828
|
+
)
|
|
829
|
+
return True, {"simulation": True, "status": "ok"}
|
|
830
|
+
|
|
446
831
|
if not self._executor:
|
|
447
832
|
raise NotImplementedError("No Hyperliquid executor configured.")
|
|
448
833
|
|
|
@@ -465,6 +850,49 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
465
850
|
self.logger.error(f"Failed to fetch user_fills for {address}: {exc}")
|
|
466
851
|
return False, str(exc)
|
|
467
852
|
|
|
853
|
+
async def check_recent_liquidations(
|
|
854
|
+
self, address: str, since_ms: int
|
|
855
|
+
) -> tuple[bool, list[dict[str, Any]]]:
|
|
856
|
+
"""
|
|
857
|
+
Check if user was liquidated since a given timestamp.
|
|
858
|
+
|
|
859
|
+
Fills have an optional 'liquidation' field with:
|
|
860
|
+
- liquidatedUser: who got liquidated
|
|
861
|
+
- markPx: price at liquidation
|
|
862
|
+
- method: "market" or "backstop"
|
|
863
|
+
|
|
864
|
+
Args:
|
|
865
|
+
address: Wallet address
|
|
866
|
+
since_ms: Epoch milliseconds to check from
|
|
867
|
+
|
|
868
|
+
Returns:
|
|
869
|
+
(success, list of liquidation fills where user was liquidated)
|
|
870
|
+
"""
|
|
871
|
+
try:
|
|
872
|
+
now_ms = int(time.time() * 1000)
|
|
873
|
+
body = {
|
|
874
|
+
"type": "userFillsByTime",
|
|
875
|
+
"user": address,
|
|
876
|
+
"startTime": since_ms,
|
|
877
|
+
"endTime": now_ms,
|
|
878
|
+
}
|
|
879
|
+
data = self.info.post("/info", body)
|
|
880
|
+
fills = data if isinstance(data, list) else []
|
|
881
|
+
|
|
882
|
+
# Filter for liquidation fills where we were the liquidated user
|
|
883
|
+
liquidation_fills = [
|
|
884
|
+
f
|
|
885
|
+
for f in fills
|
|
886
|
+
if f.get("liquidation")
|
|
887
|
+
and f["liquidation"].get("liquidatedUser", "").lower()
|
|
888
|
+
== address.lower()
|
|
889
|
+
]
|
|
890
|
+
|
|
891
|
+
return True, liquidation_fills
|
|
892
|
+
except Exception as exc:
|
|
893
|
+
self.logger.error(f"Failed to check liquidations for {address}: {exc}")
|
|
894
|
+
return False, []
|
|
895
|
+
|
|
468
896
|
async def get_order_status(
|
|
469
897
|
self, address: str, order_id: int | str
|
|
470
898
|
) -> tuple[bool, dict[str, Any]]:
|
|
@@ -487,6 +915,18 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
487
915
|
async def get_frontend_open_orders(
|
|
488
916
|
self, address: str
|
|
489
917
|
) -> tuple[bool, list[dict[str, Any]]]:
|
|
918
|
+
"""
|
|
919
|
+
Get all open orders including trigger orders (stop-loss, take-profit).
|
|
920
|
+
|
|
921
|
+
Uses frontendOpenOrders endpoint which returns both limit and trigger orders
|
|
922
|
+
with full order details including orderType and triggerPx.
|
|
923
|
+
|
|
924
|
+
Args:
|
|
925
|
+
address: Wallet address
|
|
926
|
+
|
|
927
|
+
Returns:
|
|
928
|
+
List of open order records including trigger orders
|
|
929
|
+
"""
|
|
490
930
|
try:
|
|
491
931
|
data = self.info.frontend_open_orders(address)
|
|
492
932
|
return True, data if isinstance(data, list) else []
|
|
@@ -502,6 +942,15 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
502
942
|
amount: float,
|
|
503
943
|
address: str,
|
|
504
944
|
) -> tuple[bool, dict[str, Any]]:
|
|
945
|
+
"""
|
|
946
|
+
Withdraw USDC from Hyperliquid to Arbitrum.
|
|
947
|
+
|
|
948
|
+
Note: This is an L1 withdrawal handled by the Hyperliquid executor (signing required).
|
|
949
|
+
"""
|
|
950
|
+
if self.simulation:
|
|
951
|
+
self.logger.info(f"[SIMULATION] withdraw: {amount} USDC")
|
|
952
|
+
return True, {"simulation": True, "status": "ok"}
|
|
953
|
+
|
|
505
954
|
if not self._executor:
|
|
506
955
|
raise NotImplementedError("No Hyperliquid executor configured.")
|
|
507
956
|
|
|
@@ -512,22 +961,6 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
512
961
|
success = result.get("status") == "ok"
|
|
513
962
|
return success, result
|
|
514
963
|
|
|
515
|
-
# ------------------------------------------------------------------ #
|
|
516
|
-
# Health Check #
|
|
517
|
-
# ------------------------------------------------------------------ #
|
|
518
|
-
|
|
519
|
-
async def health_check(self) -> dict[str, Any]:
|
|
520
|
-
try:
|
|
521
|
-
success, meta = await self.get_meta_and_asset_ctxs()
|
|
522
|
-
if success and meta:
|
|
523
|
-
return {
|
|
524
|
-
"status": "healthy",
|
|
525
|
-
"perp_markets": len(meta[0].get("universe", [])) if meta else 0,
|
|
526
|
-
}
|
|
527
|
-
return {"status": "unhealthy", "error": "Failed to fetch metadata"}
|
|
528
|
-
except Exception as exc:
|
|
529
|
-
return {"status": "unhealthy", "error": str(exc)}
|
|
530
|
-
|
|
531
964
|
# ------------------------------------------------------------------ #
|
|
532
965
|
# Deposit/Withdrawal Helpers #
|
|
533
966
|
# ------------------------------------------------------------------ #
|
|
@@ -564,6 +997,13 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
564
997
|
max_fee_rate: str,
|
|
565
998
|
address: str,
|
|
566
999
|
) -> tuple[bool, dict[str, Any]]:
|
|
1000
|
+
if self.simulation:
|
|
1001
|
+
self.logger.info(
|
|
1002
|
+
f"[SIMULATION] approve_builder_fee: builder={builder}, "
|
|
1003
|
+
f"rate={max_fee_rate}, address={address}"
|
|
1004
|
+
)
|
|
1005
|
+
return True, {"simulation": True, "status": "ok"}
|
|
1006
|
+
|
|
567
1007
|
if not self._executor:
|
|
568
1008
|
raise NotImplementedError("No Hyperliquid executor configured.")
|
|
569
1009
|
|
|
@@ -576,6 +1016,47 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
576
1016
|
success = result.get("status") == "ok"
|
|
577
1017
|
return success, result
|
|
578
1018
|
|
|
1019
|
+
async def ensure_builder_fee_approved(
|
|
1020
|
+
self,
|
|
1021
|
+
address: str,
|
|
1022
|
+
builder_fee: dict[str, Any] | None = None,
|
|
1023
|
+
) -> tuple[bool, str]:
|
|
1024
|
+
if self.simulation:
|
|
1025
|
+
return True, "Simulation mode - builder fee not needed"
|
|
1026
|
+
|
|
1027
|
+
# Resolve fee config from parameter or config
|
|
1028
|
+
fee_config = builder_fee
|
|
1029
|
+
if not fee_config and isinstance(self.config, dict):
|
|
1030
|
+
fee_config = self.config.get("builder_fee")
|
|
1031
|
+
|
|
1032
|
+
if not fee_config or not isinstance(fee_config, dict):
|
|
1033
|
+
return True, "No builder fee configured"
|
|
1034
|
+
|
|
1035
|
+
builder = fee_config.get("b")
|
|
1036
|
+
required_fee = fee_config.get("f", 0)
|
|
1037
|
+
if not builder or not required_fee:
|
|
1038
|
+
return True, "Builder fee not configured"
|
|
1039
|
+
|
|
1040
|
+
# Check current approval
|
|
1041
|
+
try:
|
|
1042
|
+
ok, current_fee = await self.get_max_builder_fee(address, builder)
|
|
1043
|
+
if ok and int(current_fee) >= int(required_fee):
|
|
1044
|
+
return (
|
|
1045
|
+
True,
|
|
1046
|
+
f"Builder fee already approved ({current_fee} >= {required_fee})",
|
|
1047
|
+
)
|
|
1048
|
+
except Exception as e:
|
|
1049
|
+
logger.warning(
|
|
1050
|
+
f"Failed to check builder fee: {e}, proceeding with approval"
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
# Approve
|
|
1054
|
+
max_fee_rate = f"{int(required_fee) / 1000:.3f}%"
|
|
1055
|
+
ok, result = await self.approve_builder_fee(builder, max_fee_rate, address)
|
|
1056
|
+
if ok:
|
|
1057
|
+
return True, f"Builder fee approved: {max_fee_rate}"
|
|
1058
|
+
return False, f"Builder fee approval failed: {result}"
|
|
1059
|
+
|
|
579
1060
|
async def place_limit_order(
|
|
580
1061
|
self,
|
|
581
1062
|
asset_id: int,
|
|
@@ -587,6 +1068,32 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
587
1068
|
reduce_only: bool = False,
|
|
588
1069
|
builder: dict[str, Any] | None = None,
|
|
589
1070
|
) -> tuple[bool, dict[str, Any]]:
|
|
1071
|
+
"""
|
|
1072
|
+
Place a limit order (GTC - Good Till Cancelled).
|
|
1073
|
+
|
|
1074
|
+
Used for spot stop-loss orders in basis trading.
|
|
1075
|
+
|
|
1076
|
+
Args:
|
|
1077
|
+
asset_id: Asset ID (perp < 10000, spot >= 10000)
|
|
1078
|
+
is_buy: True for buy, False for sell
|
|
1079
|
+
price: Limit price
|
|
1080
|
+
size: Order size
|
|
1081
|
+
address: Wallet address
|
|
1082
|
+
reduce_only: If True, only reduces existing position
|
|
1083
|
+
builder: Builder fee config; if omitted, a mandatory default is applied.
|
|
1084
|
+
|
|
1085
|
+
Returns:
|
|
1086
|
+
(success, response_data or error_message)
|
|
1087
|
+
"""
|
|
1088
|
+
builder = self._mandatory_builder_fee(builder)
|
|
1089
|
+
|
|
1090
|
+
if self.simulation:
|
|
1091
|
+
self.logger.info(
|
|
1092
|
+
f"[SIMULATION] place_limit_order: asset={asset_id}, "
|
|
1093
|
+
f"is_buy={is_buy}, price={price}, size={size}"
|
|
1094
|
+
)
|
|
1095
|
+
return True, {"simulation": True, "status": "ok"}
|
|
1096
|
+
|
|
590
1097
|
if not self._executor:
|
|
591
1098
|
raise NotImplementedError("No Hyperliquid executor configured.")
|
|
592
1099
|
|
|
@@ -613,6 +1120,7 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
613
1120
|
) -> tuple[bool, float]:
|
|
614
1121
|
iterations = timeout_s // poll_interval_s
|
|
615
1122
|
|
|
1123
|
+
# Get initial balance
|
|
616
1124
|
success, initial_state = await self.get_user_state(address)
|
|
617
1125
|
if not success:
|
|
618
1126
|
self.logger.warning(f"Could not fetch initial state: {initial_state}")
|
|
@@ -650,8 +1158,9 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
650
1158
|
|
|
651
1159
|
self.logger.warning(
|
|
652
1160
|
f"Hyperliquid deposit not confirmed after {timeout_s}s. "
|
|
653
|
-
|
|
1161
|
+
"Deposits typically credit in < 1 minute (but can take longer)."
|
|
654
1162
|
)
|
|
1163
|
+
# Return current balance even if not confirmed
|
|
655
1164
|
success, state = await self.get_user_state(address)
|
|
656
1165
|
final_balance = (
|
|
657
1166
|
self.get_perp_margin_amount(state) if success else initial_balance
|
|
@@ -699,6 +1208,21 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
699
1208
|
max_poll_time_s: int = 30 * 60,
|
|
700
1209
|
poll_interval_s: int = 5,
|
|
701
1210
|
) -> tuple[bool, dict[str, float]]:
|
|
1211
|
+
"""
|
|
1212
|
+
Wait for a withdrawal to appear on-chain.
|
|
1213
|
+
|
|
1214
|
+
Polls Hyperliquid's ledger updates until a withdrawal is detected.
|
|
1215
|
+
Withdrawals typically take ~3-4 minutes to process (but can take longer).
|
|
1216
|
+
|
|
1217
|
+
Args:
|
|
1218
|
+
address: Wallet address
|
|
1219
|
+
lookback_s: How far back to look for withdrawals (small buffer for latency)
|
|
1220
|
+
max_poll_time_s: Maximum time to wait (default 30 minutes)
|
|
1221
|
+
poll_interval_s: Time between polls
|
|
1222
|
+
|
|
1223
|
+
Returns:
|
|
1224
|
+
(success, {tx_hash: usdc_amount}) - withdrawals found
|
|
1225
|
+
"""
|
|
702
1226
|
import time
|
|
703
1227
|
|
|
704
1228
|
start_time_ms = time.time() * 1000
|
|
@@ -720,7 +1244,7 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
720
1244
|
remaining_s = i * poll_interval_s
|
|
721
1245
|
self.logger.info(
|
|
722
1246
|
f"Waiting for withdrawal to appear on-chain... "
|
|
723
|
-
f"{remaining_s}s remaining (withdrawals often take
|
|
1247
|
+
f"{remaining_s}s remaining (withdrawals often take a few minutes)"
|
|
724
1248
|
)
|
|
725
1249
|
await asyncio.sleep(poll_interval_s)
|
|
726
1250
|
|