hyperquant 0.2__py3-none-any.whl → 0.3__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.
- hyperquant/__init__.py +4 -1
- hyperquant/broker/auth.py +51 -0
- hyperquant/broker/hyperliquid.py +572 -0
- hyperquant/broker/lib/hpstore.py +252 -0
- hyperquant/broker/lib/hyper_types.py +48 -0
- hyperquant/broker/models/hyperliquid.py +284 -0
- hyperquant/broker/models/ourbit.py +502 -0
- hyperquant/broker/ourbit.py +234 -0
- hyperquant/broker/ws.py +12 -0
- hyperquant/core.py +77 -26
- hyperquant/datavison/okx.py +177 -0
- hyperquant/notikit.py +124 -0
- {hyperquant-0.2.dist-info → hyperquant-0.3.dist-info}/METADATA +2 -1
- hyperquant-0.3.dist-info/RECORD +21 -0
- hyperquant-0.2.dist-info/RECORD +0 -11
- {hyperquant-0.2.dist-info → hyperquant-0.3.dist-info}/WHEEL +0 -0
hyperquant/__init__.py
CHANGED
@@ -0,0 +1,51 @@
|
|
1
|
+
import time
|
2
|
+
import hashlib
|
3
|
+
from typing import Any
|
4
|
+
from multidict import CIMultiDict
|
5
|
+
from yarl import URL
|
6
|
+
import pybotters
|
7
|
+
import json as pyjson
|
8
|
+
from urllib.parse import urlencode
|
9
|
+
|
10
|
+
|
11
|
+
def md5_hex(s: str) -> str:
|
12
|
+
return hashlib.md5(s.encode("utf-8")).hexdigest()
|
13
|
+
|
14
|
+
|
15
|
+
# 🔑 Ourbit 的鉴权函数
|
16
|
+
class Auth:
|
17
|
+
@staticmethod
|
18
|
+
def ourbit(args: tuple[str, URL], kwargs: dict[str, Any]) -> tuple[str, URL]:
|
19
|
+
method: str = args[0]
|
20
|
+
url: URL = args[1]
|
21
|
+
data = kwargs.get("data") or {}
|
22
|
+
headers: CIMultiDict = kwargs["headers"]
|
23
|
+
|
24
|
+
# 从 session 里取 token
|
25
|
+
session = kwargs["session"]
|
26
|
+
token = session.__dict__["_apis"][pybotters.auth.Hosts.items[url.host].name][0]
|
27
|
+
|
28
|
+
# 时间戳 & body
|
29
|
+
now_ms = int(time.time() * 1000)
|
30
|
+
raw_body_for_sign = data if isinstance(data, str) else pyjson.dumps(data, separators=(",", ":"), ensure_ascii=False)
|
31
|
+
|
32
|
+
# 签名
|
33
|
+
mid_hash = md5_hex(f"{token}{now_ms}")[7:]
|
34
|
+
final_hash = md5_hex(f"{now_ms}{raw_body_for_sign}{mid_hash}")
|
35
|
+
|
36
|
+
# 设置 headers
|
37
|
+
headers.update({
|
38
|
+
"Authorization": token,
|
39
|
+
"Language": "Chinese",
|
40
|
+
"language": "Chinese",
|
41
|
+
"Content-Type": "application/json",
|
42
|
+
"x-ourbit-sign": final_hash,
|
43
|
+
"x-ourbit-nonce": str(now_ms),
|
44
|
+
})
|
45
|
+
|
46
|
+
# 更新 kwargs.body,保证发出去的与签名一致
|
47
|
+
kwargs.update({"data": raw_body_for_sign})
|
48
|
+
|
49
|
+
return args
|
50
|
+
|
51
|
+
pybotters.auth.Hosts.items['futures.ourbit.com'] = pybotters.auth.Item("ourbit", Auth.ourbit)
|
@@ -0,0 +1,572 @@
|
|
1
|
+
# hyperliquid_trader_optimized.py
|
2
|
+
"""Async wrapper around Hyperliquid REST + WebSocket endpoints.
|
3
|
+
|
4
|
+
Key design goals
|
5
|
+
----------------
|
6
|
+
* **Single point of truth** – all endpoint paths & constants live at module scope.
|
7
|
+
* **Safety** – every public coroutine is fully‑typed and guarded with rich
|
8
|
+
error messages; internal state is protected by an `asyncio.Lock` where
|
9
|
+
necessary.
|
10
|
+
* **Performance** – expensive metadata is fetched once and cached; price/size
|
11
|
+
formatting uses the `decimal` module only when needed.
|
12
|
+
* **Ergonomics** – high‑level order helpers (`buy`, `sell`) are provided on top
|
13
|
+
of the generic `place_order` routine; context‑manager semantics make sure
|
14
|
+
network resources are cleaned up.
|
15
|
+
"""
|
16
|
+
from __future__ import annotations
|
17
|
+
|
18
|
+
import asyncio
|
19
|
+
import decimal
|
20
|
+
import itertools
|
21
|
+
import logging
|
22
|
+
from dataclasses import dataclass
|
23
|
+
import time
|
24
|
+
from typing import Any, Dict, Optional
|
25
|
+
|
26
|
+
import pybotters
|
27
|
+
from yarl import URL
|
28
|
+
|
29
|
+
from .models.hyperliquid import MyHyperStore
|
30
|
+
import uuid
|
31
|
+
|
32
|
+
|
33
|
+
def to_cloid(s: str) -> str:
|
34
|
+
"""
|
35
|
+
可逆地将字符串转为 Hyperliquid cloid 格式(要求字符串最多16字节,超出报错)。
|
36
|
+
:param s: 原始字符串
|
37
|
+
:return: 形如0x...的cloid字符串
|
38
|
+
"""
|
39
|
+
b = s.encode('utf-8')
|
40
|
+
if len(b) > 16:
|
41
|
+
raise ValueError("String too long for reversible cloid (max 16 bytes)")
|
42
|
+
# 补齐到16字节
|
43
|
+
b = b.ljust(16, b'\0')
|
44
|
+
return "0x" + b.hex()
|
45
|
+
|
46
|
+
def cloid_to_str(cloid: str) -> str:
|
47
|
+
"""
|
48
|
+
从cloid还原回原始字符串
|
49
|
+
:param cloid: 形如0x...的cloid字符串
|
50
|
+
:return: 原始字符串
|
51
|
+
"""
|
52
|
+
try:
|
53
|
+
if not (cloid.startswith("0x") and len(cloid) == 34):
|
54
|
+
raise ValueError("Invalid cloid format for reversal")
|
55
|
+
b = bytes.fromhex(cloid[2:])
|
56
|
+
return b.rstrip(b'\0').decode('utf-8')
|
57
|
+
except Exception as e:
|
58
|
+
return ''
|
59
|
+
|
60
|
+
|
61
|
+
__all__ = [
|
62
|
+
"HyperliquidTrader",
|
63
|
+
]
|
64
|
+
|
65
|
+
_API_BASE_MAIN = "https://api.hyperliquid.xyz"
|
66
|
+
_API_BASE_TEST = "https://api.hyperliquid-testnet.xyz"
|
67
|
+
_WSS_URL_MAIN = "wss://api.hyperliquid.xyz/ws"
|
68
|
+
_WSS_URL_TEST = "wss://api.hyperliquid-testnet.xyz/ws"
|
69
|
+
_INFO = "/info"
|
70
|
+
_EXCHANGE = "/exchange"
|
71
|
+
|
72
|
+
logger = logging.getLogger(__name__)
|
73
|
+
|
74
|
+
|
75
|
+
# ╭─────────────────────────────────────────────────────────────────────────╮
|
76
|
+
# │ Helpers │
|
77
|
+
# ╰─────────────────────────────────────────────────────────────────────────╯
|
78
|
+
@dataclass(frozen=True, slots=True)
|
79
|
+
class AssetMeta:
|
80
|
+
"""Metadata for a tradable asset."""
|
81
|
+
|
82
|
+
asset_id: int
|
83
|
+
name: str
|
84
|
+
sz_decimals: int
|
85
|
+
tick_size: float = None
|
86
|
+
|
87
|
+
@dataclass(frozen=True, slots=True)
|
88
|
+
class SpotAssetMeta:
|
89
|
+
"""Metadata for a tradable asset."""
|
90
|
+
|
91
|
+
asset_id: int # eg. 10000
|
92
|
+
name: str # eg. "#173"
|
93
|
+
sz_decimals: int # eg. 2
|
94
|
+
index: int # eg. 0
|
95
|
+
token_name: str # eg. "FEUSD"
|
96
|
+
mark_price: float = None
|
97
|
+
|
98
|
+
@dataclass
|
99
|
+
class OrderData():
|
100
|
+
o_id: str = ''
|
101
|
+
c_id: str = ''
|
102
|
+
name: str = ''
|
103
|
+
status: str = 'resting'
|
104
|
+
price: float = None
|
105
|
+
sz: float = None
|
106
|
+
|
107
|
+
|
108
|
+
_DECIMAL_CTX_5 = decimal.Context(prec=5)
|
109
|
+
|
110
|
+
def normalize_number(n):
|
111
|
+
# 能去掉小数点后多余的零
|
112
|
+
return format(decimal.Decimal(str(n)).normalize(), "f")
|
113
|
+
|
114
|
+
def _fmt_price(price: float, sz_decimals: int, *, max_decimals: int = 6) -> float:
|
115
|
+
"""
|
116
|
+
格式化价格:
|
117
|
+
- 大于100000直接取整数
|
118
|
+
- 其它情况保留5个有效数字,然后再按max_decimals-sz_decimals截断小数位
|
119
|
+
"""
|
120
|
+
if price > 100_000:
|
121
|
+
return str(round(price))
|
122
|
+
# 先保留5个有效数字,再截断小数位
|
123
|
+
price_5sf = float(f"{price:.5g}")
|
124
|
+
return normalize_number(round(price_5sf, max_decimals - sz_decimals))
|
125
|
+
|
126
|
+
def _fmt_size(sz: float, sz_decimals: int) -> float:
|
127
|
+
"""
|
128
|
+
格式化数量:直接按 sz_decimals 小数位截断
|
129
|
+
"""
|
130
|
+
return normalize_number(round(sz, sz_decimals))
|
131
|
+
|
132
|
+
|
133
|
+
# ╭─────────────────────────────────────────────────────────────────────────╮
|
134
|
+
# │ Public main class │
|
135
|
+
# ╰─────────────────────────────────────────────────────────────────────────╯
|
136
|
+
class HyperliquidTrader:
|
137
|
+
"""High‑level async client for Hyperliquid."""
|
138
|
+
|
139
|
+
def __init__(
|
140
|
+
self,
|
141
|
+
apis: str | dict | None = None,
|
142
|
+
*,
|
143
|
+
client: Optional[pybotters.Client] = None,
|
144
|
+
user_address: Optional[str] = None,
|
145
|
+
msg_callback: Optional[callable[[dict, pybotters.ws.WebSocketAppProtocol], None]] = None,
|
146
|
+
testnet: bool = False,
|
147
|
+
) -> None:
|
148
|
+
self._external_client = client is not None
|
149
|
+
self._client: pybotters.Client | None = client
|
150
|
+
self._apis = apis
|
151
|
+
self._user = user_address
|
152
|
+
self._msg_cb_user = msg_callback
|
153
|
+
|
154
|
+
self._testnet = testnet
|
155
|
+
self._api_base = _API_BASE_TEST if testnet else _API_BASE_MAIN
|
156
|
+
self._wss_url = _WSS_URL_TEST if testnet else _WSS_URL_MAIN
|
157
|
+
|
158
|
+
self._assets: dict[str, AssetMeta] = {}
|
159
|
+
self._spot_assets: dict[str, SpotAssetMeta] = {}
|
160
|
+
|
161
|
+
self._assets_with_name: dict[str, AssetMeta] = {}
|
162
|
+
self._spot_assets_with_name: dict[str, SpotAssetMeta] = {}
|
163
|
+
|
164
|
+
self._next_id = itertools.count().__next__ # fast thread‑safe counter
|
165
|
+
self._waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
166
|
+
self._waiter_lock = asyncio.Lock()
|
167
|
+
|
168
|
+
self._ws_app: Optional[pybotters.ws.WebSocketConnection] = None
|
169
|
+
self.store: MyHyperStore = MyHyperStore()
|
170
|
+
|
171
|
+
|
172
|
+
|
173
|
+
# ──────────────────────────────────────────────────────────────────────
|
174
|
+
# Lifecyle helpers
|
175
|
+
# ──────────────────────────────────────────────────────────────────────
|
176
|
+
async def __aenter__(self) -> "HyperliquidTrader":
|
177
|
+
if self._client is None:
|
178
|
+
self._client = await pybotters.Client(apis=self._apis, base_url=self._api_base).__aenter__()
|
179
|
+
|
180
|
+
await self._fetch_meta()
|
181
|
+
await self._fech_spot_meta()
|
182
|
+
|
183
|
+
self._ws_app:pybotters.WebSocketApp = await self._client.ws_connect(
|
184
|
+
self._wss_url,
|
185
|
+
send_json=[],
|
186
|
+
hdlr_json=self._dispatch_msg,
|
187
|
+
)
|
188
|
+
|
189
|
+
if self._user:
|
190
|
+
await self.store.initialize(
|
191
|
+
("orders", self._client.post(_INFO, data={"type": "openOrders", "user": self._user})),
|
192
|
+
("positions", self._client.post(_INFO, data={"type": "clearinghouseState", "user": self._user})),
|
193
|
+
)
|
194
|
+
self._client.ws_connect(
|
195
|
+
self._wss_url,
|
196
|
+
send_json=[
|
197
|
+
{
|
198
|
+
"method": "subscribe",
|
199
|
+
"subscription": {
|
200
|
+
"type": "orderUpdates",
|
201
|
+
"user": self._user,
|
202
|
+
},
|
203
|
+
},
|
204
|
+
{
|
205
|
+
"method": "subscribe",
|
206
|
+
"subscription": {
|
207
|
+
"type": "userFills",
|
208
|
+
"user": self._user,
|
209
|
+
},
|
210
|
+
},
|
211
|
+
{
|
212
|
+
"method": "subscribe",
|
213
|
+
"subscription": {
|
214
|
+
"type": "webData2",
|
215
|
+
"user": self._user,
|
216
|
+
},
|
217
|
+
}],
|
218
|
+
hdlr_json=self.store.onmessage,
|
219
|
+
)
|
220
|
+
|
221
|
+
return self
|
222
|
+
|
223
|
+
async def __aexit__(self, exc_type, exc, tb): # noqa: D401
|
224
|
+
if not self._external_client and self._client is not None:
|
225
|
+
await self._client.__aexit__(exc_type, exc, tb)
|
226
|
+
|
227
|
+
async def sync_orders(self):
|
228
|
+
await self.store.orders._clear()
|
229
|
+
await self.store.initialize(
|
230
|
+
("orders", self._client.post(_INFO, data={"type": "openOrders", "user": self._user})),
|
231
|
+
)
|
232
|
+
|
233
|
+
# ──────────────────────────────────────────────────────────────────────
|
234
|
+
# Internal – metadata & formatting helpers
|
235
|
+
# ──────────────────────────────────────────────────────────────────────
|
236
|
+
async def _fetch_meta(self) -> None:
|
237
|
+
assert self._client is not None # mypy
|
238
|
+
resp = await self._client.fetch("POST", _INFO, data={"type": "meta"})
|
239
|
+
if not resp.data:
|
240
|
+
raise RuntimeError(f"Failed to fetch meta: {resp.error}")
|
241
|
+
|
242
|
+
self._assets = {
|
243
|
+
d["name"]: AssetMeta(asset_id=i, name=d["name"], sz_decimals=d["szDecimals"], tick_size=10**(d["szDecimals"] - 6))
|
244
|
+
for i, d in enumerate(resp.data["universe"])
|
245
|
+
}
|
246
|
+
|
247
|
+
logger.debug("Loaded %d assets", len(self._assets))
|
248
|
+
|
249
|
+
async def _fech_spot_meta(self) -> None:
|
250
|
+
assert self._client is not None # mypy
|
251
|
+
resp = await self._client.fetch("POST", _INFO, data={"type": "spotMeta"})
|
252
|
+
if not resp.data:
|
253
|
+
raise RuntimeError(f"Failed to fetch meta: {resp.error}")
|
254
|
+
|
255
|
+
metadata = resp.data
|
256
|
+
|
257
|
+
tokens = metadata['tokens']
|
258
|
+
|
259
|
+
for u in metadata['universe']:
|
260
|
+
coin_name = u['name']
|
261
|
+
index = u['index']
|
262
|
+
tk_id = u['tokens'][0]
|
263
|
+
token_name = tokens[tk_id]['name']
|
264
|
+
szDecimals = tokens[tk_id]['szDecimals']
|
265
|
+
|
266
|
+
meta = SpotAssetMeta(
|
267
|
+
asset_id= 10000 + index,
|
268
|
+
name=coin_name,
|
269
|
+
sz_decimals=szDecimals,
|
270
|
+
index=index,
|
271
|
+
token_name=token_name,
|
272
|
+
mark_price=0.0,
|
273
|
+
)
|
274
|
+
self._spot_assets[token_name] = meta
|
275
|
+
self._spot_assets_with_name[coin_name] = meta
|
276
|
+
|
277
|
+
|
278
|
+
|
279
|
+
def _asset(self, symbol: str, is_spot: bool = False) -> AssetMeta:
|
280
|
+
try:
|
281
|
+
if is_spot:
|
282
|
+
return self._spot_assets[symbol]
|
283
|
+
else:
|
284
|
+
return self._assets[symbol]
|
285
|
+
except KeyError as exc:
|
286
|
+
raise ValueError(f"Unknown asset '{symbol}'. Have you called __aenter__()?") from exc
|
287
|
+
|
288
|
+
def fmt_price(self, price: float, symbol: str, is_spot: bool = False) -> float:
|
289
|
+
asset = self._asset(symbol, is_spot=is_spot)
|
290
|
+
return float(_fmt_price(price, asset.sz_decimals))
|
291
|
+
|
292
|
+
def fmt_size(self, size: float, symbol: str, is_spot: bool = False) -> float:
|
293
|
+
asset = self._asset(symbol, is_spot=is_spot)
|
294
|
+
return float(_fmt_size(size, asset.sz_decimals))
|
295
|
+
|
296
|
+
|
297
|
+
def sub_l2_book(self, symbol: str, is_spot: bool = False) -> None:
|
298
|
+
|
299
|
+
asset = self._asset(symbol, is_spot=is_spot)
|
300
|
+
|
301
|
+
self._client.ws_connect(
|
302
|
+
self._wss_url,
|
303
|
+
send_json={
|
304
|
+
"method": "subscribe",
|
305
|
+
"subscription": {
|
306
|
+
"type": "l2Book",
|
307
|
+
"coin": asset.name,
|
308
|
+
},
|
309
|
+
},
|
310
|
+
hdlr_json=self.store.onmessage
|
311
|
+
)
|
312
|
+
|
313
|
+
|
314
|
+
# ──────────────────────────────────────────────────────────────────────
|
315
|
+
# Internal – WebSocket message routing
|
316
|
+
# ──────────────────────────────────────────────────────────────────────
|
317
|
+
def _dispatch_msg(self, msg: dict[str, Any], wsapp): # noqa: ANN001
|
318
|
+
mid = msg.get("data", {}).get("id")
|
319
|
+
if mid is not None:
|
320
|
+
fut = self._waiters.pop(mid, None)
|
321
|
+
if fut and not fut.done():
|
322
|
+
fut.set_result(msg)
|
323
|
+
return
|
324
|
+
# fallback: hand over to user callback / buffer
|
325
|
+
if self._msg_cb_user is not None:
|
326
|
+
self._msg_cb_user(msg, wsapp)
|
327
|
+
else:
|
328
|
+
logger.debug("Unhandled WS message: %s", msg)
|
329
|
+
|
330
|
+
async def _wait_for_id(self, rid: int) -> dict[str, Any]:
|
331
|
+
async with self._waiter_lock:
|
332
|
+
fut = self._waiters[rid] = asyncio.get_event_loop().create_future()
|
333
|
+
return await fut
|
334
|
+
|
335
|
+
# ──────────────────────────────────────────────────────────────────────
|
336
|
+
# REST helpers
|
337
|
+
# ──────────────────────────────────────────────────────────────────────
|
338
|
+
async def _post(self, path: str, data: dict[str, Any]):
|
339
|
+
assert self._client is not None
|
340
|
+
info = await self._client.fetch("POST", path, data=data)
|
341
|
+
info.response.raise_for_status()
|
342
|
+
return info.data
|
343
|
+
|
344
|
+
# ──────────────────────────────────────────────────────────────────────
|
345
|
+
# Public API – account
|
346
|
+
# ──────────────────────────────────────────────────────────────────────
|
347
|
+
async def balances(self, user: Optional[str] = None): # todo support spot
|
348
|
+
try:
|
349
|
+
user = user or self._user
|
350
|
+
if user is None:
|
351
|
+
raise ValueError("User address required – pass it now or in constructor")
|
352
|
+
data = await self._post(_INFO, {"type": "clearinghouseState", "user": user})
|
353
|
+
match data:
|
354
|
+
case pybotters.NotJSONContent():
|
355
|
+
print('可能参数有误')
|
356
|
+
return None
|
357
|
+
return data
|
358
|
+
except Exception as e:
|
359
|
+
print(f"Error fetching balances: {e}")
|
360
|
+
return None
|
361
|
+
|
362
|
+
async def open_orders(self, user: Optional[str] = None):
|
363
|
+
user = user or self._user
|
364
|
+
if user is None:
|
365
|
+
raise ValueError("User address required – pass it now or in constructor")
|
366
|
+
return await self._post(_INFO, {"type": "openOrders", "user": user})
|
367
|
+
|
368
|
+
async def cancel_order(
|
369
|
+
self,
|
370
|
+
asset: str,
|
371
|
+
order_id: str,
|
372
|
+
*,
|
373
|
+
use_ws: bool = False,
|
374
|
+
is_spot: bool = False,
|
375
|
+
) -> dict[str, Any]:
|
376
|
+
|
377
|
+
meta = self._asset(asset, is_spot=is_spot)
|
378
|
+
|
379
|
+
payload = {
|
380
|
+
"action": {
|
381
|
+
"type": "cancel",
|
382
|
+
"cancels": [{"a": meta.asset_id, "o": order_id}],
|
383
|
+
}
|
384
|
+
}
|
385
|
+
|
386
|
+
|
387
|
+
|
388
|
+
if not use_ws:
|
389
|
+
return await self._post(_EXCHANGE, payload)
|
390
|
+
|
391
|
+
signed = await self._ws_sign(payload)
|
392
|
+
assert self._ws_app is not None
|
393
|
+
await self._ws_app.current_ws.send_json(signed)
|
394
|
+
return order_id
|
395
|
+
|
396
|
+
async def cancel_all_orders(
|
397
|
+
self,
|
398
|
+
asset: Optional[str] = None,
|
399
|
+
*,
|
400
|
+
use_ws: bool = False,
|
401
|
+
is_spot: bool = False,
|
402
|
+
) -> dict[str, Any]:
|
403
|
+
# [{'coin': '@153', 'side': 'A', 'limitPx': '0.9904', 'sz': '202.33', 'oid': 93287495240, 'timestamp': 1747118448026, 'origSz': '202.33', 'cloid': '0x441f6c3b8dde4ccfb34a24ec23419f2d'}, {'coin': '@153', 'side': 'B', 'limitPx': '0.9864', 'sz': '202.76', 'oid': 93278072490, 'timestamp': 1747115006552, 'origSz': '202.76', 'cloid': '0x90fac8c56d224a20b4fd0ab6cf4eae5e'}]
|
404
|
+
orders = await self.open_orders()
|
405
|
+
|
406
|
+
if asset:
|
407
|
+
meta = self._asset(asset, is_spot=is_spot)
|
408
|
+
orders = [o for o in orders if o['coin'] == meta.name]
|
409
|
+
|
410
|
+
if is_spot:
|
411
|
+
for o in orders:
|
412
|
+
o['asset_id'] = self._spot_assets_with_name[o['coin']].asset_id
|
413
|
+
else:
|
414
|
+
for o in orders:
|
415
|
+
o['asset_id'] = self._asset(o['coin']).asset_id
|
416
|
+
|
417
|
+
# 构建payload
|
418
|
+
payload = {
|
419
|
+
"action": {
|
420
|
+
"type": "cancel",
|
421
|
+
"cancels": [
|
422
|
+
{"a": o['asset_id'], "o": o['oid']}
|
423
|
+
for o in orders
|
424
|
+
],
|
425
|
+
}
|
426
|
+
}
|
427
|
+
print(payload)
|
428
|
+
|
429
|
+
if not use_ws:
|
430
|
+
return await self._post(_EXCHANGE, payload)
|
431
|
+
signed = await self._ws_sign(payload)
|
432
|
+
assert self._ws_app is not None
|
433
|
+
await self._ws_app.current_ws.send_json(signed)
|
434
|
+
return orders
|
435
|
+
|
436
|
+
|
437
|
+
async def get_mid(self, asset: str, is_spot: bool = False) -> float:
|
438
|
+
"""Get the mid price of an asset."""
|
439
|
+
meta = self._asset(asset, is_spot=is_spot)
|
440
|
+
book = await self._post(_INFO, {"type": "l2Book", "coin": meta.name})
|
441
|
+
bid = float(book["levels"][0][0]["px"])
|
442
|
+
ask = float(book["levels"][1][0]["px"])
|
443
|
+
return float(_fmt_price((bid + ask) / 2, meta.sz_decimals))
|
444
|
+
|
445
|
+
async def get_books(self, asset: str, is_spot: bool = False) -> list[float]:
|
446
|
+
"""Get the ask prices of an asset."""
|
447
|
+
meta = self._asset(asset, is_spot=is_spot)
|
448
|
+
book = await self._post(_INFO, {"type": "l2Book", "coin": meta.name})
|
449
|
+
levels = book["levels"]
|
450
|
+
bids = levels[0]
|
451
|
+
asks = levels[1]
|
452
|
+
return {
|
453
|
+
"bids": bids,
|
454
|
+
"asks": asks,
|
455
|
+
}
|
456
|
+
|
457
|
+
# ──────────────────────────────────────────────────────────────────────
|
458
|
+
# Public API – trading
|
459
|
+
# ──────────────────────────────────────────────────────────────────────
|
460
|
+
async def place_order(
|
461
|
+
self,
|
462
|
+
asset: str,
|
463
|
+
*,
|
464
|
+
side: str = "buy", # "buy" | "sell"
|
465
|
+
order_type: str = "market", # "market" | "limit"
|
466
|
+
size: float,
|
467
|
+
slippage: float = 0.02,
|
468
|
+
price: Optional[float] = None, # required for limit orders
|
469
|
+
use_ws: bool = False,
|
470
|
+
is_spot: bool = False,
|
471
|
+
reduce_only: bool = False, # whether to place a reduce‑only order
|
472
|
+
cloid: Optional[str] = None,
|
473
|
+
) -> OrderData:
|
474
|
+
"""`place_order` 下订单
|
475
|
+
|
476
|
+
返回值:
|
477
|
+
'OrderData'
|
478
|
+
"""
|
479
|
+
meta = self._asset(asset, is_spot=is_spot)
|
480
|
+
is_buy = side.lower() == "buy"
|
481
|
+
|
482
|
+
if order_type == "limit":
|
483
|
+
if price is None:
|
484
|
+
raise ValueError("price must be supplied for limit orders")
|
485
|
+
price_str = _fmt_price(price, meta.sz_decimals)
|
486
|
+
tif = "Gtc"
|
487
|
+
else:
|
488
|
+
# emulate market by crossing the spread via mid ± slippage
|
489
|
+
if price is None:
|
490
|
+
mid = await self.get_mid(asset, is_spot=is_spot)
|
491
|
+
else:
|
492
|
+
mid = price # allow user‑supplied mid for testing
|
493
|
+
crossed = mid * (1 + slippage) if is_buy else mid * (1 - slippage)
|
494
|
+
price_str = _fmt_price(crossed, meta.sz_decimals)
|
495
|
+
tif = "Ioc"
|
496
|
+
|
497
|
+
size_str = _fmt_size(size, meta.sz_decimals)
|
498
|
+
# print(f'下单 @ size_str: {size_str}, price_str: {price_str}, asset: {asset}, is_spot: {is_spot}')
|
499
|
+
order_payload = {
|
500
|
+
"action": {
|
501
|
+
"type": "order",
|
502
|
+
"orders": [
|
503
|
+
{
|
504
|
+
"a": meta.asset_id,
|
505
|
+
"b": is_buy,
|
506
|
+
"p": price_str,
|
507
|
+
"s": size_str,
|
508
|
+
"r": reduce_only,
|
509
|
+
"t": {"limit": {"tif": tif}},
|
510
|
+
}
|
511
|
+
],
|
512
|
+
"grouping": "na",
|
513
|
+
}
|
514
|
+
}
|
515
|
+
if cloid is not None:
|
516
|
+
if not cloid.startswith("0x"):
|
517
|
+
cloid = to_cloid(cloid)
|
518
|
+
|
519
|
+
order_payload["action"]["orders"][0]["c"] = cloid
|
520
|
+
|
521
|
+
# print(f"Placing order: {order_payload}")
|
522
|
+
# print(order_payload)
|
523
|
+
|
524
|
+
if not use_ws:
|
525
|
+
ret = await self._post(_EXCHANGE, order_payload)
|
526
|
+
print(ret)
|
527
|
+
if 'error' in str(ret) or 'err' in str(ret):
|
528
|
+
raise RuntimeError(f"Failed to place order: {ret}")
|
529
|
+
elif 'filled' in str(ret):
|
530
|
+
return OrderData(
|
531
|
+
o_id=ret['response']['data']['statuses'][0]['filled']['oid'],
|
532
|
+
c_id=cloid,
|
533
|
+
name=asset,
|
534
|
+
status='filled',
|
535
|
+
price=float(ret['response']['data']['statuses'][0]['filled']['avgPx']),
|
536
|
+
sz=float(ret['response']['data']['statuses'][0]['filled']['totalSz']),
|
537
|
+
)
|
538
|
+
elif 'resting' in str(ret):
|
539
|
+
return OrderData(
|
540
|
+
o_id=ret['response']['data']['statuses'][0]['resting']['oid'],
|
541
|
+
c_id=cloid,
|
542
|
+
price=float(price_str),
|
543
|
+
name=asset,
|
544
|
+
status='resting',
|
545
|
+
)
|
546
|
+
|
547
|
+
# else – signed WS flow
|
548
|
+
signed = await self._ws_sign(order_payload)
|
549
|
+
assert self._ws_app is not None
|
550
|
+
await self._ws_app.current_ws.send_json(signed)
|
551
|
+
return OrderData(o_id=cloid, name=asset, price=float(price_str))
|
552
|
+
|
553
|
+
# Convenience wrappers -------------------------------------------------
|
554
|
+
async def buy(self, asset: str, **kw):
|
555
|
+
return await self.place_order(asset, side="buy", **kw)
|
556
|
+
|
557
|
+
async def sell(self, asset: str, **kw):
|
558
|
+
return await self.place_order(asset, side="sell", **kw)
|
559
|
+
|
560
|
+
# ──────────────────────────────────────────────────────────────────────
|
561
|
+
# Internal – signing helper
|
562
|
+
# ──────────────────────────────────────────────────────────────────────
|
563
|
+
async def _ws_sign(self, payload): # noqa: ANN001 – hyperliquid internal format
|
564
|
+
# mimic pybotters signing util for WS‑POST messages
|
565
|
+
url = URL(f"{self._api_base}/abc")
|
566
|
+
pybotters.Auth.hyperliquid((None, url), {"data": payload, "session": self._client._session}) # type: ignore[attr-defined]
|
567
|
+
rid = self._next_id()
|
568
|
+
return {
|
569
|
+
"method": "post",
|
570
|
+
"id": rid,
|
571
|
+
"request": {"type": "action", "payload": payload},
|
572
|
+
}
|