wayfinder-paths 0.1.22__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/__init__.py +0 -4
- wayfinder_paths/adapters/balance_adapter/README.md +0 -1
- wayfinder_paths/adapters/balance_adapter/adapter.py +313 -167
- wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +41 -124
- 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/README.md +22 -75
- wayfinder_paths/adapters/brap_adapter/adapter.py +187 -576
- wayfinder_paths/adapters/brap_adapter/examples.json +21 -140
- wayfinder_paths/adapters/brap_adapter/manifest.yaml +9 -0
- wayfinder_paths/adapters/brap_adapter/test_adapter.py +6 -234
- wayfinder_paths/adapters/hyperlend_adapter/adapter.py +180 -92
- wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +9 -0
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +82 -14
- wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +2 -9
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +586 -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/README.md +4 -1
- wayfinder_paths/adapters/ledger_adapter/adapter.py +3 -3
- 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 +649 -547
- wayfinder_paths/adapters/moonwell_adapter/manifest.yaml +14 -0
- wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +160 -239
- 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/adapter.py +14 -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/__init__.py +0 -3
- wayfinder_paths/core/adapters/BaseAdapter.py +0 -25
- wayfinder_paths/core/adapters/models.py +17 -7
- wayfinder_paths/core/clients/BRAPClient.py +4 -1
- wayfinder_paths/core/clients/ClientManager.py +0 -7
- wayfinder_paths/core/clients/LedgerClient.py +196 -172
- wayfinder_paths/core/clients/TokenClient.py +47 -1
- wayfinder_paths/core/clients/WayfinderClient.py +1 -3
- wayfinder_paths/core/clients/__init__.py +0 -5
- wayfinder_paths/core/clients/protocols.py +21 -35
- wayfinder_paths/core/clients/test_ledger_client.py +448 -0
- wayfinder_paths/core/config.py +10 -162
- wayfinder_paths/core/constants/__init__.py +73 -2
- wayfinder_paths/core/constants/base.py +8 -17
- wayfinder_paths/core/constants/chains.py +36 -0
- wayfinder_paths/core/constants/contracts.py +52 -0
- 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/constants/tokens.py +9 -0
- wayfinder_paths/core/engine/manifest.py +66 -0
- wayfinder_paths/core/strategies/Strategy.py +0 -71
- wayfinder_paths/core/strategies/__init__.py +10 -1
- wayfinder_paths/core/strategies/opa_loop.py +167 -0
- wayfinder_paths/core/utils/evm_helpers.py +5 -15
- wayfinder_paths/core/utils/test_transaction.py +289 -0
- wayfinder_paths/core/utils/tokens.py +28 -0
- wayfinder_paths/core/utils/transaction.py +57 -8
- wayfinder_paths/core/utils/web3.py +8 -3
- 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/enso.py +1 -2
- wayfinder_paths/policies/hyper_evm.py +6 -3
- wayfinder_paths/policies/hyperlend.py +1 -2
- wayfinder_paths/policies/hyperliquid.py +1 -1
- wayfinder_paths/policies/lifi.py +18 -0
- wayfinder_paths/policies/moonwell.py +12 -7
- wayfinder_paths/policies/prjx.py +1 -3
- wayfinder_paths/policies/util.py +8 -2
- wayfinder_paths/run_strategy.py +97 -300
- wayfinder_paths/strategies/basis_trading_strategy/constants.py +3 -1
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +47 -133
- 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/{templates/strategy → strategies/boros_hype_strategy}/test_strategy.py +99 -63
- 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 +15 -23
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +27 -62
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +84 -58
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +5 -15
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +69 -164
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +43 -76
- 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.22.dist-info → wayfinder_paths-0.1.24.dist-info}/WHEEL +1 -1
- wayfinder_paths/core/clients/WalletClient.py +0 -41
- wayfinder_paths/core/engine/StrategyJob.py +0 -110
- wayfinder_paths/core/services/test_local_evm_txn.py +0 -145
- wayfinder_paths/scripts/create_strategy.py +0 -139
- wayfinder_paths/scripts/make_wallets.py +0 -142
- wayfinder_paths/templates/adapter/README.md +0 -150
- wayfinder_paths/templates/adapter/adapter.py +0 -16
- wayfinder_paths/templates/adapter/examples.json +0 -8
- wayfinder_paths/templates/adapter/test_adapter.py +0 -30
- wayfinder_paths/templates/strategy/README.md +0 -186
- wayfinder_paths/templates/strategy/examples.json +0 -11
- wayfinder_paths/templates/strategy/strategy.py +0 -35
- wayfinder_paths/tests/test_smoke_manifest.py +0 -63
- wayfinder_paths-0.1.22.dist-info/METADATA +0 -355
- wayfinder_paths-0.1.22.dist-info/RECORD +0 -129
- /wayfinder_paths/{scripts → mcp/state}/__init__.py +0 -0
- {wayfinder_paths-0.1.22.dist-info → wayfinder_paths-0.1.24.dist-info}/LICENSE +0 -0
|
@@ -2,20 +2,34 @@ 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
|
|
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,
|
|
23
|
+
)
|
|
8
24
|
|
|
9
25
|
if TYPE_CHECKING:
|
|
10
26
|
from wayfinder_paths.core.clients.protocols import (
|
|
11
27
|
HyperliquidExecutorProtocol as HyperliquidExecutor,
|
|
12
28
|
)
|
|
13
29
|
|
|
14
|
-
#
|
|
15
|
-
HYPERLIQUID_BRIDGE_ADDRESS =
|
|
16
|
-
|
|
17
|
-
# USDC contract on Arbitrum
|
|
18
|
-
ARBITRUM_USDC_ADDRESS = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"
|
|
30
|
+
# Re-export Bridge2 constants for backwards compatibility.
|
|
31
|
+
HYPERLIQUID_BRIDGE_ADDRESS = _HYPERLIQUID_BRIDGE_ADDRESS
|
|
32
|
+
ARBITRUM_USDC_ADDRESS = _ARBITRUM_USDC_ADDRESS
|
|
19
33
|
|
|
20
34
|
try:
|
|
21
35
|
from hyperliquid.info import Info
|
|
@@ -28,36 +42,26 @@ except ImportError:
|
|
|
28
42
|
constants = None
|
|
29
43
|
|
|
30
44
|
|
|
31
|
-
class SimpleCache:
|
|
32
|
-
def __init__(self):
|
|
33
|
-
self._cache: dict[str, Any] = {}
|
|
34
|
-
self._expiry: dict[str, float] = {}
|
|
35
|
-
|
|
36
|
-
def get(self, key: str) -> Any | None:
|
|
37
|
-
if key in self._cache:
|
|
38
|
-
if time.time() < self._expiry.get(key, 0):
|
|
39
|
-
return self._cache[key]
|
|
40
|
-
del self._cache[key]
|
|
41
|
-
if key in self._expiry:
|
|
42
|
-
del self._expiry[key]
|
|
43
|
-
return None
|
|
44
|
-
|
|
45
|
-
def set(self, key: str, value: Any, timeout: int = 300) -> None:
|
|
46
|
-
self._cache[key] = value
|
|
47
|
-
self._expiry[key] = time.time() + timeout
|
|
48
|
-
|
|
49
|
-
def clear(self) -> None:
|
|
50
|
-
self._cache.clear()
|
|
51
|
-
self._expiry.clear()
|
|
52
|
-
|
|
53
|
-
|
|
54
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
|
+
|
|
55
58
|
adapter_type = "HYPERLIQUID"
|
|
56
59
|
|
|
57
60
|
def __init__(
|
|
58
61
|
self,
|
|
59
62
|
config: dict[str, Any] | None = None,
|
|
60
63
|
*,
|
|
64
|
+
simulation: bool = False,
|
|
61
65
|
executor: HyperliquidExecutor | None = None,
|
|
62
66
|
) -> None:
|
|
63
67
|
super().__init__("hyperliquid_adapter", config)
|
|
@@ -68,9 +72,11 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
68
72
|
"Install with: poetry add hyperliquid"
|
|
69
73
|
)
|
|
70
74
|
|
|
71
|
-
self.
|
|
75
|
+
self.simulation = simulation
|
|
76
|
+
self._cache = Cache(Cache.MEMORY)
|
|
72
77
|
self._executor = executor
|
|
73
78
|
|
|
79
|
+
# Initialize Hyperliquid Info client
|
|
74
80
|
self.info = Info(constants.MAINNET_API_URL, skip_ws=True)
|
|
75
81
|
|
|
76
82
|
# Cache asset mappings after first fetch
|
|
@@ -94,13 +100,14 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
94
100
|
|
|
95
101
|
async def get_meta_and_asset_ctxs(self) -> tuple[bool, Any]:
|
|
96
102
|
cache_key = "hl_meta_and_asset_ctxs"
|
|
97
|
-
cached = self._cache.get(cache_key)
|
|
103
|
+
cached = await self._cache.get(cache_key)
|
|
98
104
|
if cached:
|
|
99
105
|
return True, cached
|
|
100
106
|
|
|
101
107
|
try:
|
|
102
108
|
data = self.info.meta_and_asset_ctxs()
|
|
103
|
-
|
|
109
|
+
# Cache for 1 minute
|
|
110
|
+
await self._cache.set(cache_key, data, ttl=60)
|
|
104
111
|
return True, data
|
|
105
112
|
except Exception as exc:
|
|
106
113
|
self.logger.error(f"Failed to fetch meta_and_asset_ctxs: {exc}")
|
|
@@ -108,25 +115,85 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
108
115
|
|
|
109
116
|
async def get_spot_meta(self) -> tuple[bool, Any]:
|
|
110
117
|
cache_key = "hl_spot_meta"
|
|
111
|
-
cached = self._cache.get(cache_key)
|
|
118
|
+
cached = await self._cache.get(cache_key)
|
|
112
119
|
if cached:
|
|
113
120
|
return True, cached
|
|
114
121
|
|
|
115
122
|
try:
|
|
123
|
+
# Handle both callable and property access patterns
|
|
116
124
|
spot_meta = self.info.spot_meta
|
|
117
125
|
if callable(spot_meta):
|
|
118
126
|
data = spot_meta()
|
|
119
127
|
else:
|
|
120
128
|
data = spot_meta
|
|
121
|
-
self._cache.set(cache_key, data,
|
|
129
|
+
await self._cache.set(cache_key, data, ttl=60)
|
|
122
130
|
return True, data
|
|
123
131
|
except Exception as exc:
|
|
124
132
|
self.logger.error(f"Failed to fetch spot_meta: {exc}")
|
|
125
133
|
return False, str(exc)
|
|
126
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
|
+
|
|
127
194
|
async def get_spot_assets(self) -> tuple[bool, dict[str, int]]:
|
|
128
195
|
cache_key = "hl_spot_assets"
|
|
129
|
-
cached = self._cache.get(cache_key)
|
|
196
|
+
cached = await self._cache.get(cache_key)
|
|
130
197
|
if cached:
|
|
131
198
|
return True, cached
|
|
132
199
|
|
|
@@ -146,6 +213,7 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
146
213
|
|
|
147
214
|
base_idx, quote_idx = pair_tokens[0], pair_tokens[1]
|
|
148
215
|
|
|
216
|
+
# Get token names
|
|
149
217
|
base_info = tokens[base_idx] if base_idx < len(tokens) else {}
|
|
150
218
|
quote_info = tokens[quote_idx] if quote_idx < len(tokens) else {}
|
|
151
219
|
|
|
@@ -156,16 +224,19 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
156
224
|
spot_asset_id = pair.get("index", 0) + 10000
|
|
157
225
|
response[name] = spot_asset_id
|
|
158
226
|
|
|
159
|
-
|
|
227
|
+
# Cache for 5 min
|
|
228
|
+
await self._cache.set(cache_key, response, ttl=300)
|
|
160
229
|
return True, response
|
|
161
230
|
|
|
162
231
|
except Exception as exc:
|
|
163
232
|
self.logger.error(f"Failed to get spot assets: {exc}")
|
|
164
233
|
return False, {}
|
|
165
234
|
|
|
166
|
-
def get_spot_asset_id(
|
|
235
|
+
async def get_spot_asset_id(
|
|
236
|
+
self, base_coin: str, quote_coin: str = "USDC"
|
|
237
|
+
) -> int | None:
|
|
167
238
|
cache_key = "hl_spot_assets"
|
|
168
|
-
cached = self._cache.get(cache_key)
|
|
239
|
+
cached = await self._cache.get(cache_key)
|
|
169
240
|
if cached:
|
|
170
241
|
pair_name = f"{base_coin}/{quote_coin}"
|
|
171
242
|
return cached.get(pair_name)
|
|
@@ -228,9 +299,63 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
228
299
|
self.logger.error(f"Failed to fetch spot_user_state for {address}: {exc}")
|
|
229
300
|
return False, str(exc)
|
|
230
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
|
+
|
|
231
356
|
async def get_margin_table(self, margin_table_id: int) -> tuple[bool, list[dict]]:
|
|
232
357
|
cache_key = f"hl_margin_table_{margin_table_id}"
|
|
233
|
-
cached = self._cache.get(cache_key)
|
|
358
|
+
cached = await self._cache.get(cache_key)
|
|
234
359
|
if cached:
|
|
235
360
|
return True, cached
|
|
236
361
|
|
|
@@ -243,7 +368,7 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
243
368
|
except Exception: # noqa: BLE001 - try alternate payload key
|
|
244
369
|
body = {"type": "marginTable", "marginTableId": int(margin_table_id)}
|
|
245
370
|
data = self.info.post("/info", body)
|
|
246
|
-
self._cache.set(cache_key, data,
|
|
371
|
+
await self._cache.set(cache_key, data, ttl=86400) # Cache for 24h
|
|
247
372
|
return True, data
|
|
248
373
|
except Exception as exc:
|
|
249
374
|
self.logger.error(f"Failed to fetch margin_table {margin_table_id}: {exc}")
|
|
@@ -284,6 +409,7 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
284
409
|
|
|
285
410
|
@property
|
|
286
411
|
def coin_to_asset(self) -> dict[str, int]:
|
|
412
|
+
"""Get coin name to asset ID mapping (perps only)."""
|
|
287
413
|
if self._coin_to_asset is None:
|
|
288
414
|
self._coin_to_asset = dict(self.info.coin_to_asset)
|
|
289
415
|
return self._coin_to_asset
|
|
@@ -296,10 +422,10 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
296
422
|
f"Unknown asset_id {asset_id}: missing szDecimals"
|
|
297
423
|
) from None
|
|
298
424
|
|
|
299
|
-
def refresh_mappings(self) -> None:
|
|
425
|
+
async def refresh_mappings(self) -> None:
|
|
300
426
|
self._asset_to_sz_decimals = None
|
|
301
427
|
self._coin_to_asset = None
|
|
302
|
-
self._cache.clear()
|
|
428
|
+
await self._cache.clear()
|
|
303
429
|
|
|
304
430
|
# ------------------------------------------------------------------ #
|
|
305
431
|
# Utility Methods #
|
|
@@ -315,8 +441,6 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
315
441
|
|
|
316
442
|
def get_valid_order_size(self, asset_id: int, size: float) -> float:
|
|
317
443
|
decimals = self.get_sz_decimals(asset_id)
|
|
318
|
-
from decimal import ROUND_DOWN, Decimal
|
|
319
|
-
|
|
320
444
|
step = Decimal(10) ** (-decimals)
|
|
321
445
|
if size <= 0:
|
|
322
446
|
return 0.0
|
|
@@ -329,6 +453,49 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
329
453
|
# Execution Methods (require signing callback) #
|
|
330
454
|
# ------------------------------------------------------------------ #
|
|
331
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
|
+
|
|
332
499
|
async def place_market_order(
|
|
333
500
|
self,
|
|
334
501
|
asset_id: int,
|
|
@@ -341,6 +508,31 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
341
508
|
cloid: str | None = None,
|
|
342
509
|
builder: dict[str, Any] | None = None,
|
|
343
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
|
+
|
|
344
536
|
if not self._executor:
|
|
345
537
|
raise NotImplementedError(
|
|
346
538
|
"No Hyperliquid executor configured. "
|
|
@@ -358,7 +550,17 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
358
550
|
builder=builder,
|
|
359
551
|
)
|
|
360
552
|
|
|
553
|
+
# Check both the API status and the order statuses for errors
|
|
361
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
|
|
362
564
|
return success, result
|
|
363
565
|
|
|
364
566
|
async def cancel_order(
|
|
@@ -367,6 +569,12 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
367
569
|
order_id: int | str,
|
|
368
570
|
address: str,
|
|
369
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
|
+
|
|
370
578
|
if not self._executor:
|
|
371
579
|
raise NotImplementedError(
|
|
372
580
|
"No Hyperliquid executor configured. "
|
|
@@ -382,6 +590,163 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
382
590
|
success = result.get("status") == "ok"
|
|
383
591
|
return success, result
|
|
384
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
|
+
|
|
385
750
|
async def update_leverage(
|
|
386
751
|
self,
|
|
387
752
|
asset_id: int,
|
|
@@ -389,6 +754,12 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
389
754
|
is_cross: bool,
|
|
390
755
|
address: str,
|
|
391
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
|
+
|
|
392
763
|
if not self._executor:
|
|
393
764
|
raise NotImplementedError("No Hyperliquid executor configured.")
|
|
394
765
|
|
|
@@ -407,6 +778,10 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
407
778
|
amount: float,
|
|
408
779
|
address: str,
|
|
409
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
|
+
|
|
410
785
|
if not self._executor:
|
|
411
786
|
raise NotImplementedError("No Hyperliquid executor configured.")
|
|
412
787
|
|
|
@@ -423,6 +798,10 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
423
798
|
amount: float,
|
|
424
799
|
address: str,
|
|
425
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
|
+
|
|
426
805
|
if not self._executor:
|
|
427
806
|
raise NotImplementedError("No Hyperliquid executor configured.")
|
|
428
807
|
|
|
@@ -442,6 +821,13 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
442
821
|
size: float,
|
|
443
822
|
address: str,
|
|
444
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
|
+
|
|
445
831
|
if not self._executor:
|
|
446
832
|
raise NotImplementedError("No Hyperliquid executor configured.")
|
|
447
833
|
|
|
@@ -464,6 +850,49 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
464
850
|
self.logger.error(f"Failed to fetch user_fills for {address}: {exc}")
|
|
465
851
|
return False, str(exc)
|
|
466
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
|
+
|
|
467
896
|
async def get_order_status(
|
|
468
897
|
self, address: str, order_id: int | str
|
|
469
898
|
) -> tuple[bool, dict[str, Any]]:
|
|
@@ -486,6 +915,18 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
486
915
|
async def get_frontend_open_orders(
|
|
487
916
|
self, address: str
|
|
488
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
|
+
"""
|
|
489
930
|
try:
|
|
490
931
|
data = self.info.frontend_open_orders(address)
|
|
491
932
|
return True, data if isinstance(data, list) else []
|
|
@@ -501,6 +942,15 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
501
942
|
amount: float,
|
|
502
943
|
address: str,
|
|
503
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
|
+
|
|
504
954
|
if not self._executor:
|
|
505
955
|
raise NotImplementedError("No Hyperliquid executor configured.")
|
|
506
956
|
|
|
@@ -511,22 +961,6 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
511
961
|
success = result.get("status") == "ok"
|
|
512
962
|
return success, result
|
|
513
963
|
|
|
514
|
-
# ------------------------------------------------------------------ #
|
|
515
|
-
# Health Check #
|
|
516
|
-
# ------------------------------------------------------------------ #
|
|
517
|
-
|
|
518
|
-
async def health_check(self) -> dict[str, Any]:
|
|
519
|
-
try:
|
|
520
|
-
success, meta = await self.get_meta_and_asset_ctxs()
|
|
521
|
-
if success and meta:
|
|
522
|
-
return {
|
|
523
|
-
"status": "healthy",
|
|
524
|
-
"perp_markets": len(meta[0].get("universe", [])) if meta else 0,
|
|
525
|
-
}
|
|
526
|
-
return {"status": "unhealthy", "error": "Failed to fetch metadata"}
|
|
527
|
-
except Exception as exc:
|
|
528
|
-
return {"status": "unhealthy", "error": str(exc)}
|
|
529
|
-
|
|
530
964
|
# ------------------------------------------------------------------ #
|
|
531
965
|
# Deposit/Withdrawal Helpers #
|
|
532
966
|
# ------------------------------------------------------------------ #
|
|
@@ -563,6 +997,13 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
563
997
|
max_fee_rate: str,
|
|
564
998
|
address: str,
|
|
565
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
|
+
|
|
566
1007
|
if not self._executor:
|
|
567
1008
|
raise NotImplementedError("No Hyperliquid executor configured.")
|
|
568
1009
|
|
|
@@ -575,6 +1016,47 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
575
1016
|
success = result.get("status") == "ok"
|
|
576
1017
|
return success, result
|
|
577
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
|
+
|
|
578
1060
|
async def place_limit_order(
|
|
579
1061
|
self,
|
|
580
1062
|
asset_id: int,
|
|
@@ -586,6 +1068,32 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
586
1068
|
reduce_only: bool = False,
|
|
587
1069
|
builder: dict[str, Any] | None = None,
|
|
588
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
|
+
|
|
589
1097
|
if not self._executor:
|
|
590
1098
|
raise NotImplementedError("No Hyperliquid executor configured.")
|
|
591
1099
|
|
|
@@ -612,6 +1120,7 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
612
1120
|
) -> tuple[bool, float]:
|
|
613
1121
|
iterations = timeout_s // poll_interval_s
|
|
614
1122
|
|
|
1123
|
+
# Get initial balance
|
|
615
1124
|
success, initial_state = await self.get_user_state(address)
|
|
616
1125
|
if not success:
|
|
617
1126
|
self.logger.warning(f"Could not fetch initial state: {initial_state}")
|
|
@@ -649,8 +1158,9 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
649
1158
|
|
|
650
1159
|
self.logger.warning(
|
|
651
1160
|
f"Hyperliquid deposit not confirmed after {timeout_s}s. "
|
|
652
|
-
|
|
1161
|
+
"Deposits typically credit in < 1 minute (but can take longer)."
|
|
653
1162
|
)
|
|
1163
|
+
# Return current balance even if not confirmed
|
|
654
1164
|
success, state = await self.get_user_state(address)
|
|
655
1165
|
final_balance = (
|
|
656
1166
|
self.get_perp_margin_amount(state) if success else initial_balance
|
|
@@ -698,6 +1208,21 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
698
1208
|
max_poll_time_s: int = 30 * 60,
|
|
699
1209
|
poll_interval_s: int = 5,
|
|
700
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
|
+
"""
|
|
701
1226
|
import time
|
|
702
1227
|
|
|
703
1228
|
start_time_ms = time.time() * 1000
|
|
@@ -719,7 +1244,7 @@ class HyperliquidAdapter(BaseAdapter):
|
|
|
719
1244
|
remaining_s = i * poll_interval_s
|
|
720
1245
|
self.logger.info(
|
|
721
1246
|
f"Waiting for withdrawal to appear on-chain... "
|
|
722
|
-
f"{remaining_s}s remaining (withdrawals often take
|
|
1247
|
+
f"{remaining_s}s remaining (withdrawals often take a few minutes)"
|
|
723
1248
|
)
|
|
724
1249
|
await asyncio.sleep(poll_interval_s)
|
|
725
1250
|
|