hyperquant 0.1.9__tar.gz → 0.3__tar.gz

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.
Files changed (30) hide show
  1. {hyperquant-0.1.9 → hyperquant-0.3}/.gitignore +0 -1
  2. {hyperquant-0.1.9 → hyperquant-0.3}/PKG-INFO +3 -2
  3. hyperquant-0.3/data/logs/notikit.log +0 -0
  4. {hyperquant-0.1.9 → hyperquant-0.3}/pyproject.toml +3 -2
  5. {hyperquant-0.1.9 → hyperquant-0.3}/src/hyperquant/__init__.py +4 -1
  6. hyperquant-0.3/src/hyperquant/broker/auth.py +51 -0
  7. hyperquant-0.3/src/hyperquant/broker/hyperliquid.py +572 -0
  8. hyperquant-0.3/src/hyperquant/broker/lib/hpstore.py +252 -0
  9. hyperquant-0.3/src/hyperquant/broker/lib/hyper_types.py +48 -0
  10. hyperquant-0.3/src/hyperquant/broker/models/hyperliquid.py +284 -0
  11. hyperquant-0.3/src/hyperquant/broker/models/ourbit.py +502 -0
  12. hyperquant-0.3/src/hyperquant/broker/ourbit.py +234 -0
  13. hyperquant-0.3/src/hyperquant/broker/ws.py +12 -0
  14. {hyperquant-0.1.9 → hyperquant-0.3}/src/hyperquant/core.py +77 -26
  15. hyperquant-0.3/src/hyperquant/datavison/okx.py +177 -0
  16. {hyperquant-0.1.9 → hyperquant-0.3}/src/hyperquant/db.py +0 -1
  17. hyperquant-0.3/src/hyperquant/notikit.py +124 -0
  18. hyperquant-0.3/test.py +106 -0
  19. {hyperquant-0.1.9 → hyperquant-0.3}/uv.lock +17 -2
  20. hyperquant-0.1.9/test.py +0 -44
  21. {hyperquant-0.1.9 → hyperquant-0.3}/.python-version +0 -0
  22. {hyperquant-0.1.9 → hyperquant-0.3}/README.md +0 -0
  23. {hyperquant-0.1.9 → hyperquant-0.3}/pub.sh +0 -0
  24. {hyperquant-0.1.9 → hyperquant-0.3}/requirements-dev.lock +0 -0
  25. {hyperquant-0.1.9 → hyperquant-0.3}/requirements.lock +0 -0
  26. {hyperquant-0.1.9 → hyperquant-0.3}/src/hyperquant/datavison/_util.py +0 -0
  27. {hyperquant-0.1.9 → hyperquant-0.3}/src/hyperquant/datavison/binance.py +0 -0
  28. {hyperquant-0.1.9 → hyperquant-0.3}/src/hyperquant/datavison/coinglass.py +0 -0
  29. {hyperquant-0.1.9 → hyperquant-0.3}/src/hyperquant/draw.py +0 -0
  30. {hyperquant-0.1.9 → hyperquant-0.3}/src/hyperquant/logkit.py +0 -0
@@ -10,7 +10,6 @@ dist/
10
10
  downloads/
11
11
  eggs/
12
12
  .eggs/
13
- lib/
14
13
  lib64/
15
14
  parts/
16
15
  sdist/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperquant
3
- Version: 0.1.9
3
+ Version: 0.3
4
4
  Summary: A minimal yet hyper-efficient backtesting framework for quantitative trading
5
5
  Project-URL: Homepage, https://github.com/yourusername/hyperquant
6
6
  Project-URL: Issues, https://github.com/yourusername/hyperquant/issues
@@ -13,12 +13,13 @@ Classifier: License :: OSI Approved :: MIT License
13
13
  Classifier: Programming Language :: Python :: 3
14
14
  Classifier: Topic :: Office/Business :: Financial :: Investment
15
15
  Requires-Python: >=3.9
16
- Requires-Dist: aiohttp>=3.11.16
16
+ Requires-Dist: aiohttp>=3.10.4
17
17
  Requires-Dist: colorama>=0.4.6
18
18
  Requires-Dist: cryptography>=44.0.2
19
19
  Requires-Dist: duckdb>=1.2.2
20
20
  Requires-Dist: numpy>=1.21.0
21
21
  Requires-Dist: pandas>=2.2.3
22
+ Requires-Dist: pybotters>=1.9.1
22
23
  Requires-Dist: pyecharts>=2.0.8
23
24
  Description-Content-Type: text/markdown
24
25
 
File without changes
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hyperquant"
3
- version = "0.1.9"
3
+ version = "0.3"
4
4
  description = "A minimal yet hyper-efficient backtesting framework for quantitative trading"
5
5
  authors = [
6
6
  { name = "MissinA", email = "1421329142@qq.com" }
@@ -9,10 +9,11 @@ dependencies = [
9
9
  "pyecharts>=2.0.8",
10
10
  "pandas>=2.2.3",
11
11
  "colorama>=0.4.6",
12
- "aiohttp>=3.11.16",
12
+ "aiohttp>=3.10.4",
13
13
  "cryptography>=44.0.2",
14
14
  "numpy>=1.21.0", # Added numpy as a new dependency
15
15
  "duckdb>=1.2.2",
16
+ "pybotters>=1.9.1",
16
17
  ]
17
18
  readme = "README.md"
18
19
  requires-python = ">=3.9"
@@ -2,4 +2,7 @@ from .core import *
2
2
  from .draw import *
3
3
  from .logkit import *
4
4
  from .datavison import *
5
- __version__ = "0.1.0"
5
+ from .notikit import *
6
+ __version__ = "0.1.0"
7
+
8
+ from .broker import auth, ws
@@ -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
+ }