hyperquant 0.2__py3-none-any.whl → 0.22__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 +1 -0
- hyperquant/broker/hyperliquid.py +538 -0
- hyperquant/datavison/okx.py +177 -0
- hyperquant/notikit.py +104 -0
- {hyperquant-0.2.dist-info → hyperquant-0.22.dist-info}/METADATA +2 -1
- {hyperquant-0.2.dist-info → hyperquant-0.22.dist-info}/RECORD +7 -4
- {hyperquant-0.2.dist-info → hyperquant-0.22.dist-info}/WHEEL +0 -0
hyperquant/__init__.py
CHANGED
@@ -0,0 +1,538 @@
|
|
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 .lib.hpstore import MyHyperStore
|
30
|
+
from .lib.hyper_types import AccountBalance
|
31
|
+
|
32
|
+
import uuid
|
33
|
+
|
34
|
+
|
35
|
+
__all__ = [
|
36
|
+
"HyperliquidTrader",
|
37
|
+
]
|
38
|
+
|
39
|
+
_API_BASE_MAIN = "https://api.hyperliquid.xyz"
|
40
|
+
_API_BASE_TEST = "https://api.hyperliquid-testnet.xyz"
|
41
|
+
_WSS_URL_MAIN = "wss://api.hyperliquid.xyz/ws"
|
42
|
+
_WSS_URL_TEST = "wss://api.hyperliquid-testnet.xyz/ws"
|
43
|
+
_INFO = "/info"
|
44
|
+
_EXCHANGE = "/exchange"
|
45
|
+
|
46
|
+
logger = logging.getLogger(__name__)
|
47
|
+
|
48
|
+
|
49
|
+
# ╭─────────────────────────────────────────────────────────────────────────╮
|
50
|
+
# │ Helpers │
|
51
|
+
# ╰─────────────────────────────────────────────────────────────────────────╯
|
52
|
+
@dataclass(frozen=True, slots=True)
|
53
|
+
class AssetMeta:
|
54
|
+
"""Metadata for a tradable asset."""
|
55
|
+
|
56
|
+
asset_id: int
|
57
|
+
name: str
|
58
|
+
sz_decimals: int
|
59
|
+
|
60
|
+
@dataclass(frozen=True, slots=True)
|
61
|
+
class SpotAssetMeta:
|
62
|
+
"""Metadata for a tradable asset."""
|
63
|
+
|
64
|
+
asset_id: int # eg. 10000
|
65
|
+
name: str # eg. "#173"
|
66
|
+
sz_decimals: int # eg. 2
|
67
|
+
index: int # eg. 0
|
68
|
+
token_name: str # eg. "FEUSD"
|
69
|
+
mark_price: float = None
|
70
|
+
|
71
|
+
@dataclass
|
72
|
+
class OrderData():
|
73
|
+
o_id: str = ''
|
74
|
+
c_id: str = ''
|
75
|
+
name: str = ''
|
76
|
+
status: str = 'resting'
|
77
|
+
price: float = None
|
78
|
+
sz: float = None
|
79
|
+
|
80
|
+
|
81
|
+
_DECIMAL_CTX_5 = decimal.Context(prec=5)
|
82
|
+
|
83
|
+
|
84
|
+
def _normalize_number(number: str) -> str:
|
85
|
+
"""Normalize a number.
|
86
|
+
|
87
|
+
e.g. "3300.0" -> "3300"
|
88
|
+
|
89
|
+
Hyperliquid API expects normalized numbers. Otherwise, `L1 error` will occur.
|
90
|
+
"""
|
91
|
+
return format(decimal.Decimal(number).normalize(), "f")
|
92
|
+
|
93
|
+
def _fmt_price(price: float, sz_decimals: int, *, max_decimals: int = 6) -> str:
|
94
|
+
"""Format *price* according to Hyperliquid rules.
|
95
|
+
|
96
|
+
* For `price >= 1` keep five significant digits.
|
97
|
+
* For small prices keep *max_decimals - sz_decimals* after the dot.
|
98
|
+
"""
|
99
|
+
if price >= 1:
|
100
|
+
return format(_DECIMAL_CTX_5.create_decimal(price).normalize(), "f")
|
101
|
+
|
102
|
+
decimal_places = max_decimals - sz_decimals
|
103
|
+
return _normalize_number(format(decimal.Decimal(price).quantize(decimal.Decimal(f"1e-{decimal_places}")), "f"))
|
104
|
+
|
105
|
+
|
106
|
+
def _fmt_size(size: float, sz_decimals: int) -> str:
|
107
|
+
return _normalize_number(format(decimal.Decimal(size).quantize(decimal.Decimal(f"1e-{sz_decimals}")), "f"))
|
108
|
+
|
109
|
+
|
110
|
+
# ╭─────────────────────────────────────────────────────────────────────────╮
|
111
|
+
# │ Public main class │
|
112
|
+
# ╰─────────────────────────────────────────────────────────────────────────╯
|
113
|
+
class HyperliquidTrader:
|
114
|
+
"""High‑level async client for Hyperliquid."""
|
115
|
+
|
116
|
+
def __init__(
|
117
|
+
self,
|
118
|
+
apis: str | dict | None = None,
|
119
|
+
*,
|
120
|
+
client: Optional[pybotters.Client] = None,
|
121
|
+
user_address: Optional[str] = None,
|
122
|
+
msg_callback: Optional[callable[[dict, pybotters.ws.WebSocketAppProtocol], None]] = None,
|
123
|
+
testnet: bool = False,
|
124
|
+
) -> None:
|
125
|
+
self._external_client = client is not None
|
126
|
+
self._client: pybotters.Client | None = client
|
127
|
+
self._apis = apis
|
128
|
+
self._user = user_address
|
129
|
+
self._msg_cb_user = msg_callback
|
130
|
+
|
131
|
+
self._testnet = testnet
|
132
|
+
self._api_base = _API_BASE_TEST if testnet else _API_BASE_MAIN
|
133
|
+
self._wss_url = _WSS_URL_TEST if testnet else _WSS_URL_MAIN
|
134
|
+
|
135
|
+
self._assets: dict[str, AssetMeta] = {}
|
136
|
+
self._spot_assets: dict[str, SpotAssetMeta] = {}
|
137
|
+
|
138
|
+
self._assets_with_name: dict[str, AssetMeta] = {}
|
139
|
+
self._spot_assets_with_name: dict[str, SpotAssetMeta] = {}
|
140
|
+
|
141
|
+
self._next_id = itertools.count().__next__ # fast thread‑safe counter
|
142
|
+
self._waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
143
|
+
self._waiter_lock = asyncio.Lock()
|
144
|
+
|
145
|
+
self._ws_app: Optional[pybotters.ws.WebSocketConnection] = None
|
146
|
+
self.store: MyHyperStore = MyHyperStore()
|
147
|
+
|
148
|
+
|
149
|
+
# ──────────────────────────────────────────────────────────────────────
|
150
|
+
# Lifecyle helpers
|
151
|
+
# ──────────────────────────────────────────────────────────────────────
|
152
|
+
async def __aenter__(self) -> "HyperliquidTrader":
|
153
|
+
if self._client is None:
|
154
|
+
self._client = await pybotters.Client(apis=self._apis, base_url=self._api_base).__aenter__()
|
155
|
+
|
156
|
+
await self._fetch_meta()
|
157
|
+
await self._fech_spot_meta()
|
158
|
+
|
159
|
+
self._ws_app:pybotters.WebSocketApp = await self._client.ws_connect(
|
160
|
+
self._wss_url,
|
161
|
+
send_json=[],
|
162
|
+
hdlr_json=self._dispatch_msg,
|
163
|
+
)
|
164
|
+
|
165
|
+
if self._user:
|
166
|
+
await self.store.initialize(
|
167
|
+
("orders", self._client.post(_INFO, data={"type": "openOrders", "user": self._user})),
|
168
|
+
)
|
169
|
+
self._client.ws_connect(
|
170
|
+
self._wss_url,
|
171
|
+
send_json=[
|
172
|
+
{
|
173
|
+
"method": "subscribe",
|
174
|
+
"subscription": {
|
175
|
+
"type": "orderUpdates",
|
176
|
+
"user": self._user,
|
177
|
+
},
|
178
|
+
},
|
179
|
+
{
|
180
|
+
"method": "subscribe",
|
181
|
+
"subscription": {
|
182
|
+
"type": "userFills",
|
183
|
+
"user": self._user,
|
184
|
+
},
|
185
|
+
},
|
186
|
+
{
|
187
|
+
"method": "subscribe",
|
188
|
+
"subscription": {
|
189
|
+
"type": "webData2",
|
190
|
+
"user": self._user,
|
191
|
+
},
|
192
|
+
}],
|
193
|
+
hdlr_json=self.store.onmessage,
|
194
|
+
)
|
195
|
+
|
196
|
+
return self
|
197
|
+
|
198
|
+
async def __aexit__(self, exc_type, exc, tb): # noqa: D401
|
199
|
+
if not self._external_client and self._client is not None:
|
200
|
+
await self._client.__aexit__(exc_type, exc, tb)
|
201
|
+
|
202
|
+
# ──────────────────────────────────────────────────────────────────────
|
203
|
+
# Internal – metadata & formatting helpers
|
204
|
+
# ──────────────────────────────────────────────────────────────────────
|
205
|
+
async def _fetch_meta(self) -> None:
|
206
|
+
assert self._client is not None # mypy
|
207
|
+
resp = await self._client.fetch("POST", _INFO, data={"type": "meta"})
|
208
|
+
if not resp.data:
|
209
|
+
raise RuntimeError(f"Failed to fetch meta: {resp.error}")
|
210
|
+
|
211
|
+
self._assets = {
|
212
|
+
d["name"]: AssetMeta(asset_id=i, name=d["name"], sz_decimals=d["szDecimals"])
|
213
|
+
for i, d in enumerate(resp.data["universe"])
|
214
|
+
}
|
215
|
+
|
216
|
+
logger.debug("Loaded %d assets", len(self._assets))
|
217
|
+
|
218
|
+
async def _fech_spot_meta(self) -> None:
|
219
|
+
assert self._client is not None # mypy
|
220
|
+
resp = await self._client.fetch("POST", _INFO, data={"type": "spotMeta"})
|
221
|
+
if not resp.data:
|
222
|
+
raise RuntimeError(f"Failed to fetch meta: {resp.error}")
|
223
|
+
|
224
|
+
metadata = resp.data
|
225
|
+
|
226
|
+
tokens = metadata['tokens']
|
227
|
+
|
228
|
+
for u in metadata['universe']:
|
229
|
+
coin_name = u['name']
|
230
|
+
index = u['index']
|
231
|
+
tk_id = u['tokens'][0]
|
232
|
+
token_name = tokens[tk_id]['name']
|
233
|
+
szDecimals = tokens[tk_id]['szDecimals']
|
234
|
+
|
235
|
+
meta = SpotAssetMeta(
|
236
|
+
asset_id= 10000 + index,
|
237
|
+
name=coin_name,
|
238
|
+
sz_decimals=szDecimals,
|
239
|
+
index=index,
|
240
|
+
token_name=token_name,
|
241
|
+
mark_price=0.0,
|
242
|
+
)
|
243
|
+
self._spot_assets[token_name] = meta
|
244
|
+
self._spot_assets_with_name[coin_name] = meta
|
245
|
+
|
246
|
+
|
247
|
+
|
248
|
+
def _asset(self, symbol: str, is_spot: bool = False) -> AssetMeta:
|
249
|
+
try:
|
250
|
+
if is_spot:
|
251
|
+
return self._spot_assets[symbol]
|
252
|
+
else:
|
253
|
+
return self._assets[symbol]
|
254
|
+
except KeyError as exc:
|
255
|
+
raise ValueError(f"Unknown asset '{symbol}'. Have you called __aenter__()?") from exc
|
256
|
+
|
257
|
+
def fmt_price(self, price: float, symbol: str, is_spot: bool = False) -> float:
|
258
|
+
asset = self._asset(symbol, is_spot=is_spot)
|
259
|
+
return float(_fmt_price(price, asset.sz_decimals))
|
260
|
+
|
261
|
+
def fmt_size(self, size: float, symbol: str, is_spot: bool = False) -> float:
|
262
|
+
asset = self._asset(symbol, is_spot=is_spot)
|
263
|
+
return float(_fmt_size(size, asset.sz_decimals))
|
264
|
+
|
265
|
+
|
266
|
+
def sub_l2_book(self, symbol: str, is_spot: bool = False) -> None:
|
267
|
+
|
268
|
+
asset = self._asset(symbol, is_spot=is_spot)
|
269
|
+
|
270
|
+
self._client.ws_connect(
|
271
|
+
self._wss_url,
|
272
|
+
send_json={
|
273
|
+
"method": "subscribe",
|
274
|
+
"subscription": {
|
275
|
+
"type": "l2Book",
|
276
|
+
"coin": asset.name,
|
277
|
+
},
|
278
|
+
},
|
279
|
+
hdlr_json=self.store.onmessage
|
280
|
+
)
|
281
|
+
|
282
|
+
|
283
|
+
# ──────────────────────────────────────────────────────────────────────
|
284
|
+
# Internal – WebSocket message routing
|
285
|
+
# ──────────────────────────────────────────────────────────────────────
|
286
|
+
def _dispatch_msg(self, msg: dict[str, Any], wsapp): # noqa: ANN001
|
287
|
+
mid = msg.get("data", {}).get("id")
|
288
|
+
if mid is not None:
|
289
|
+
fut = self._waiters.pop(mid, None)
|
290
|
+
if fut and not fut.done():
|
291
|
+
fut.set_result(msg)
|
292
|
+
return
|
293
|
+
# fallback: hand over to user callback / buffer
|
294
|
+
if self._msg_cb_user is not None:
|
295
|
+
self._msg_cb_user(msg, wsapp)
|
296
|
+
else:
|
297
|
+
logger.debug("Unhandled WS message: %s", msg)
|
298
|
+
|
299
|
+
async def _wait_for_id(self, rid: int) -> dict[str, Any]:
|
300
|
+
async with self._waiter_lock:
|
301
|
+
fut = self._waiters[rid] = asyncio.get_event_loop().create_future()
|
302
|
+
return await fut
|
303
|
+
|
304
|
+
# ──────────────────────────────────────────────────────────────────────
|
305
|
+
# REST helpers
|
306
|
+
# ──────────────────────────────────────────────────────────────────────
|
307
|
+
async def _post(self, path: str, data: dict[str, Any]):
|
308
|
+
assert self._client is not None
|
309
|
+
info = await self._client.fetch("POST", path, data=data)
|
310
|
+
info.response.raise_for_status()
|
311
|
+
return info.data
|
312
|
+
|
313
|
+
# ──────────────────────────────────────────────────────────────────────
|
314
|
+
# Public API – account
|
315
|
+
# ──────────────────────────────────────────────────────────────────────
|
316
|
+
async def balances(self, user: Optional[str] = None) -> AccountBalance | None: # todo support spot
|
317
|
+
try:
|
318
|
+
user = user or self._user
|
319
|
+
if user is None:
|
320
|
+
raise ValueError("User address required – pass it now or in constructor")
|
321
|
+
data = await self._post(_INFO, {"type": "clearinghouseState", "user": user})
|
322
|
+
match data:
|
323
|
+
case pybotters.NotJSONContent():
|
324
|
+
print('可能参数有误')
|
325
|
+
return None
|
326
|
+
return data
|
327
|
+
except Exception as e:
|
328
|
+
print(f"Error fetching balances: {e}")
|
329
|
+
return None
|
330
|
+
|
331
|
+
async def open_orders(self, user: Optional[str] = None):
|
332
|
+
user = user or self._user
|
333
|
+
if user is None:
|
334
|
+
raise ValueError("User address required – pass it now or in constructor")
|
335
|
+
return await self._post(_INFO, {"type": "openOrders", "user": user})
|
336
|
+
|
337
|
+
async def cancel_order(
|
338
|
+
self,
|
339
|
+
asset: str,
|
340
|
+
order_id: str,
|
341
|
+
*,
|
342
|
+
use_ws: bool = False,
|
343
|
+
is_spot: bool = False,
|
344
|
+
) -> dict[str, Any]:
|
345
|
+
|
346
|
+
meta = self._asset(asset, is_spot=is_spot)
|
347
|
+
|
348
|
+
payload = {
|
349
|
+
"action": {
|
350
|
+
"type": "cancel",
|
351
|
+
"cancels": [{"a": meta.asset_id, "o": order_id}],
|
352
|
+
}
|
353
|
+
}
|
354
|
+
|
355
|
+
|
356
|
+
|
357
|
+
if not use_ws:
|
358
|
+
return await self._post(_EXCHANGE, payload)
|
359
|
+
|
360
|
+
signed = await self._ws_sign(payload)
|
361
|
+
assert self._ws_app is not None
|
362
|
+
await self._ws_app.current_ws.send_json(signed)
|
363
|
+
return order_id
|
364
|
+
|
365
|
+
async def cancel_all_orders(
|
366
|
+
self,
|
367
|
+
asset: Optional[str] = None,
|
368
|
+
*,
|
369
|
+
use_ws: bool = False,
|
370
|
+
is_spot: bool = False,
|
371
|
+
) -> dict[str, Any]:
|
372
|
+
# [{'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'}]
|
373
|
+
orders = await self.open_orders()
|
374
|
+
|
375
|
+
if asset:
|
376
|
+
meta = self._asset(asset, is_spot=is_spot)
|
377
|
+
orders = [o for o in orders if o['coin'] == meta.name]
|
378
|
+
|
379
|
+
if is_spot:
|
380
|
+
for o in orders:
|
381
|
+
o['asset_id'] = self._spot_assets_with_name[o['coin']].asset_id
|
382
|
+
else:
|
383
|
+
for o in orders:
|
384
|
+
o['asset_id'] = self._asset(o['coin']).asset_id
|
385
|
+
|
386
|
+
# 构建payload
|
387
|
+
payload = {
|
388
|
+
"action": {
|
389
|
+
"type": "cancel",
|
390
|
+
"cancels": [
|
391
|
+
{"a": o['asset_id'], "o": o['oid']}
|
392
|
+
for o in orders
|
393
|
+
],
|
394
|
+
}
|
395
|
+
}
|
396
|
+
print(payload)
|
397
|
+
|
398
|
+
if not use_ws:
|
399
|
+
return await self._post(_EXCHANGE, payload)
|
400
|
+
signed = await self._ws_sign(payload)
|
401
|
+
assert self._ws_app is not None
|
402
|
+
await self._ws_app.current_ws.send_json(signed)
|
403
|
+
return orders
|
404
|
+
|
405
|
+
|
406
|
+
async def get_mid(self, asset: str, is_spot: bool = False) -> float:
|
407
|
+
"""Get the mid price of an asset."""
|
408
|
+
meta = self._asset(asset, is_spot=is_spot)
|
409
|
+
book = await self._post(_INFO, {"type": "l2Book", "coin": meta.name})
|
410
|
+
bid = float(book["levels"][0][0]["px"])
|
411
|
+
ask = float(book["levels"][1][0]["px"])
|
412
|
+
return float(_fmt_price((bid + ask) / 2, meta.sz_decimals))
|
413
|
+
|
414
|
+
async def get_books(self, asset: str, is_spot: bool = False) -> list[float]:
|
415
|
+
"""Get the ask prices of an asset."""
|
416
|
+
meta = self._asset(asset, is_spot=is_spot)
|
417
|
+
book = await self._post(_INFO, {"type": "l2Book", "coin": meta.name})
|
418
|
+
levels = book["levels"]
|
419
|
+
bids = levels[0]
|
420
|
+
asks = levels[1]
|
421
|
+
return {
|
422
|
+
"bids": bids,
|
423
|
+
"asks": asks,
|
424
|
+
}
|
425
|
+
|
426
|
+
# ──────────────────────────────────────────────────────────────────────
|
427
|
+
# Public API – trading
|
428
|
+
# ──────────────────────────────────────────────────────────────────────
|
429
|
+
async def place_order(
|
430
|
+
self,
|
431
|
+
asset: str,
|
432
|
+
*,
|
433
|
+
side: str = "buy", # "buy" | "sell"
|
434
|
+
order_type: str = "market", # "market" | "limit"
|
435
|
+
size: float,
|
436
|
+
slippage: float = 0.02,
|
437
|
+
price: Optional[float] = None, # required for limit orders
|
438
|
+
use_ws: bool = False,
|
439
|
+
is_spot: bool = False,
|
440
|
+
) -> OrderData:
|
441
|
+
"""`place_order` 下订单
|
442
|
+
|
443
|
+
返回值:
|
444
|
+
'OrderData'
|
445
|
+
"""
|
446
|
+
meta = self._asset(asset, is_spot=is_spot)
|
447
|
+
is_buy = side.lower() == "buy"
|
448
|
+
|
449
|
+
if order_type == "limit":
|
450
|
+
if price is None:
|
451
|
+
raise ValueError("price must be supplied for limit orders")
|
452
|
+
price_str = _fmt_price(price, meta.sz_decimals)
|
453
|
+
tif = "Gtc"
|
454
|
+
else:
|
455
|
+
# emulate market by crossing the spread via mid ± slippage
|
456
|
+
if price is None:
|
457
|
+
mid = await self.get_mid(asset, is_spot=is_spot)
|
458
|
+
else:
|
459
|
+
mid = price # allow user‑supplied mid for testing
|
460
|
+
crossed = mid * (1 + slippage) if is_buy else mid * (1 - slippage)
|
461
|
+
price_str = _fmt_price(crossed, meta.sz_decimals)
|
462
|
+
tif = "Ioc"
|
463
|
+
|
464
|
+
size_str = _fmt_size(size, meta.sz_decimals)
|
465
|
+
order_payload = {
|
466
|
+
"action": {
|
467
|
+
"type": "order",
|
468
|
+
"orders": [
|
469
|
+
{
|
470
|
+
"a": meta.asset_id,
|
471
|
+
"b": is_buy,
|
472
|
+
"p": price_str,
|
473
|
+
"s": size_str,
|
474
|
+
"r": False,
|
475
|
+
"t": {"limit": {"tif": tif}},
|
476
|
+
}
|
477
|
+
],
|
478
|
+
"grouping": "na",
|
479
|
+
}
|
480
|
+
}
|
481
|
+
# 创建时间戳cid
|
482
|
+
# Generate a 128-bit client order ID as a hex string (0x-prefixed, 32 hex chars)
|
483
|
+
cloid = uuid.uuid4().hex
|
484
|
+
cloid = "0x" + cloid
|
485
|
+
|
486
|
+
order_payload["action"]["orders"][0]["c"] = cloid
|
487
|
+
|
488
|
+
# print(f"Placing order: {order_payload}")
|
489
|
+
print(order_payload)
|
490
|
+
|
491
|
+
if not use_ws:
|
492
|
+
ret = await self._post(_EXCHANGE, order_payload)
|
493
|
+
if 'error' in str(ret):
|
494
|
+
raise RuntimeError(f"Failed to place order: {ret}")
|
495
|
+
elif 'filled' in str(ret):
|
496
|
+
return OrderData(
|
497
|
+
o_id=ret['response']['data']['statuses'][0]['filled']['oid'],
|
498
|
+
c_id=cloid,
|
499
|
+
name=asset,
|
500
|
+
status='filled',
|
501
|
+
price=float(ret['response']['data']['statuses'][0]['filled']['avgPx']),
|
502
|
+
sz=float(ret['response']['data']['statuses'][0]['filled']['totalSz']),
|
503
|
+
)
|
504
|
+
elif 'resting' in str(ret):
|
505
|
+
return OrderData(
|
506
|
+
o_id=ret['response']['data']['statuses'][0]['resting']['oid'],
|
507
|
+
c_id=cloid,
|
508
|
+
price=float(price_str),
|
509
|
+
name=asset,
|
510
|
+
status='resting',
|
511
|
+
)
|
512
|
+
|
513
|
+
# else – signed WS flow
|
514
|
+
signed = await self._ws_sign(order_payload)
|
515
|
+
assert self._ws_app is not None
|
516
|
+
await self._ws_app.current_ws.send_json(signed)
|
517
|
+
return OrderData(o_id=cloid, name=asset, price=float(price_str))
|
518
|
+
|
519
|
+
# Convenience wrappers -------------------------------------------------
|
520
|
+
async def buy(self, asset: str, **kw):
|
521
|
+
return await self.place_order(asset, side="buy", **kw)
|
522
|
+
|
523
|
+
async def sell(self, asset: str, **kw):
|
524
|
+
return await self.place_order(asset, side="sell", **kw)
|
525
|
+
|
526
|
+
# ──────────────────────────────────────────────────────────────────────
|
527
|
+
# Internal – signing helper
|
528
|
+
# ──────────────────────────────────────────────────────────────────────
|
529
|
+
async def _ws_sign(self, payload): # noqa: ANN001 – hyperliquid internal format
|
530
|
+
# mimic pybotters signing util for WS‑POST messages
|
531
|
+
url = URL(f"{self._api_base}/abc")
|
532
|
+
pybotters.Auth.hyperliquid((None, url), {"data": payload, "session": self._client._session}) # type: ignore[attr-defined]
|
533
|
+
rid = self._next_id()
|
534
|
+
return {
|
535
|
+
"method": "post",
|
536
|
+
"id": rid,
|
537
|
+
"request": {"type": "action", "payload": payload},
|
538
|
+
}
|
@@ -0,0 +1,177 @@
|
|
1
|
+
import aiohttp
|
2
|
+
import asyncio
|
3
|
+
import pandas as pd
|
4
|
+
import time
|
5
|
+
from datetime import datetime, timezone
|
6
|
+
from ._util import _to_milliseconds # 确保时间转换函数可用
|
7
|
+
|
8
|
+
class OKX:
|
9
|
+
def __init__(self) -> None:
|
10
|
+
self.session = aiohttp.ClientSession()
|
11
|
+
self.base_url = "https://www.okx.com/api/v5/market"
|
12
|
+
|
13
|
+
async def get_klines(self, symbol: str, interval: str, start_time, end_time=None, limit: int = 100):
|
14
|
+
"""
|
15
|
+
获取 OKX 永续合约 K 线数据,带时间过滤,从 end_time 向 start_time 方向翻页。
|
16
|
+
|
17
|
+
:param symbol: 交易对, 如 'BTC-USDT'
|
18
|
+
:param interval: K 线间隔, 如 '1m', '15m', '1H', '4H', '1D'
|
19
|
+
:param start_time: 开始时间(datetime 或 毫秒)
|
20
|
+
:param end_time: 结束时间(datetime 或 毫秒), 可选
|
21
|
+
:param limit: 每次请求最大数量(OKX 最大 300)
|
22
|
+
:return: DataFrame 格式的 K 线数据,按时间升序
|
23
|
+
"""
|
24
|
+
if 'h' in interval or 'd' in interval:
|
25
|
+
interval = interval.upper() # 确保间隔是大写格式
|
26
|
+
|
27
|
+
url = f"{self.base_url}/history-candles"
|
28
|
+
all_rows = []
|
29
|
+
# 转换 start_time 和 end_time 到毫秒时间戳
|
30
|
+
if isinstance(start_time, (int, float)):
|
31
|
+
start_ts = int(start_time)
|
32
|
+
else:
|
33
|
+
# 处理 datetime 对象
|
34
|
+
start_ts = int(start_time.timestamp() * 1000)
|
35
|
+
if end_time:
|
36
|
+
if isinstance(end_time, (int, float)):
|
37
|
+
end_ts = int(end_time)
|
38
|
+
else:
|
39
|
+
end_ts = int(end_time.timestamp() * 1000)
|
40
|
+
else:
|
41
|
+
# 如果没有指定结束时间,就用当前时间戳
|
42
|
+
end_ts = int(time.time() * 1000)
|
43
|
+
|
44
|
+
# 每次请求最多返回 limit=300
|
45
|
+
batch_limit = min(limit, 300)
|
46
|
+
# 初始 after 参数为 end_ts,向过去翻页
|
47
|
+
current_after = end_ts
|
48
|
+
|
49
|
+
while True:
|
50
|
+
params = {
|
51
|
+
"instId": symbol,
|
52
|
+
"bar": interval,
|
53
|
+
"limit": str(batch_limit),
|
54
|
+
"after": str(current_after)
|
55
|
+
}
|
56
|
+
# 发送请求
|
57
|
+
async with self.session.get(url, params=params) as resp:
|
58
|
+
data = await resp.json()
|
59
|
+
if not data or data.get("code") != "0" or not data.get("data"):
|
60
|
+
# 返回错误或无数据,结束循环
|
61
|
+
break
|
62
|
+
buf = data["data"] # 每条是 [ts, o, h, l, c, vol, volCcy, volCcyQuote, confirm]
|
63
|
+
|
64
|
+
# 本批数据按时间从新到旧排列, 最后一条是最旧的
|
65
|
+
rows_this_batch = []
|
66
|
+
for item in buf:
|
67
|
+
ts = int(item[0])
|
68
|
+
# 如果已经早于 start_ts,就跳过,并认为后面更旧,也可以结束循环
|
69
|
+
if ts < start_ts:
|
70
|
+
continue
|
71
|
+
# 如果某些条目时间超出 end_ts,也跳过
|
72
|
+
if ts > end_ts:
|
73
|
+
continue
|
74
|
+
# 解析数值字段
|
75
|
+
dt = pd.to_datetime(ts, unit='ms', utc=True)
|
76
|
+
o = float(item[1]); h = float(item[2]); l = float(item[3]); c = float(item[4]); vol = float(item[5])
|
77
|
+
# 按需把每个 K 线封装为字典,后续转换为 DataFrame
|
78
|
+
rows_this_batch.append({
|
79
|
+
"symbol": symbol,
|
80
|
+
"open_time": dt,
|
81
|
+
"open": o,
|
82
|
+
"high": h,
|
83
|
+
"low": l,
|
84
|
+
"close": c,
|
85
|
+
"volume": vol,
|
86
|
+
"interval": interval,
|
87
|
+
"confirm": item[8]
|
88
|
+
})
|
89
|
+
|
90
|
+
if not rows_this_batch:
|
91
|
+
# 本批没有符合时间范围的数据,直接结束
|
92
|
+
break
|
93
|
+
|
94
|
+
# 累积本批符合条件的行
|
95
|
+
all_rows.extend(rows_this_batch)
|
96
|
+
|
97
|
+
# 检查是否到达 start_ts 之前:buf 最后一项是最旧
|
98
|
+
oldest_ts = int(buf[-1][0])
|
99
|
+
if oldest_ts < start_ts:
|
100
|
+
# 已经翻到 start_time 范围之前,结束循环
|
101
|
+
break
|
102
|
+
|
103
|
+
# 否则,更新 after = oldest_ts,继续向过去翻页
|
104
|
+
current_after = oldest_ts
|
105
|
+
# 为了不触发速率限制,稍做休眠(根据需要可以调整或删除)
|
106
|
+
|
107
|
+
# 如果累积到数据,则转换为 DataFrame;否则返回空 DataFrame
|
108
|
+
if all_rows:
|
109
|
+
df = pd.DataFrame(all_rows)
|
110
|
+
# 去重、按时间排序
|
111
|
+
df.drop_duplicates(subset=["open_time"], inplace=True)
|
112
|
+
df.sort_values("open_time", inplace=True)
|
113
|
+
df.reset_index(drop=True, inplace=True)
|
114
|
+
return df
|
115
|
+
else:
|
116
|
+
return pd.DataFrame()
|
117
|
+
|
118
|
+
async def get_index_klines(self, pair: str, interval: str, start_time, end_time=None, limit: int = 100):
|
119
|
+
"""
|
120
|
+
获取OKX指数K线数据(自动分批)
|
121
|
+
|
122
|
+
:param pair: 指数名称, 如 'BTC-USD'
|
123
|
+
:param interval: K线间隔, 如 '1m', '1H', '1D'
|
124
|
+
:param start_time: 开始时间(毫秒时间戳/datetime/date)
|
125
|
+
:param end_time: 结束时间(毫秒时间戳/datetime/date)
|
126
|
+
:param limit: 每次请求最大数量(OKX最大300)
|
127
|
+
:return: DataFrame格式的指数K线
|
128
|
+
"""
|
129
|
+
url = f"{self.base_url}/index-candles"
|
130
|
+
all_klines = []
|
131
|
+
ms_start = _to_milliseconds(start_time)
|
132
|
+
ms_end = _to_milliseconds(end_time) if end_time else None
|
133
|
+
|
134
|
+
params = {
|
135
|
+
"instId": pair,
|
136
|
+
"bar": interval,
|
137
|
+
"limit": min(limit, 300),
|
138
|
+
"after": ms_start
|
139
|
+
}
|
140
|
+
if ms_end:
|
141
|
+
params["before"] = ms_end
|
142
|
+
|
143
|
+
while True:
|
144
|
+
async with self.session.get(url, params=params) as resp:
|
145
|
+
data = await resp.json()
|
146
|
+
if data['code'] != "0":
|
147
|
+
raise Exception(f"OKX API Error: {data['msg']} (Code {data['code']})")
|
148
|
+
|
149
|
+
klines = data['data']
|
150
|
+
if not klines:
|
151
|
+
break
|
152
|
+
|
153
|
+
all_klines.extend(klines)
|
154
|
+
|
155
|
+
if len(klines) < params["limit"]:
|
156
|
+
break
|
157
|
+
|
158
|
+
last_ts = int(klines[-1][0])
|
159
|
+
params["after"] = last_ts
|
160
|
+
|
161
|
+
# 数据转换
|
162
|
+
columns = ["open_time", "open", "high", "low", "close", "confirm"]
|
163
|
+
df = pd.DataFrame(all_klines, columns=columns)
|
164
|
+
|
165
|
+
df["open_time"] = pd.to_datetime(df["open_time"].astype(int), unit="ms")
|
166
|
+
num_cols = ["open", "high", "low", "close"]
|
167
|
+
df[num_cols] = df[num_cols].apply(pd.to_numeric, errors="coerce")
|
168
|
+
|
169
|
+
return df.sort_values("open_time").reset_index(drop=True)
|
170
|
+
|
171
|
+
async def close(self):
|
172
|
+
"""关闭会话"""
|
173
|
+
await self.session.close()
|
174
|
+
|
175
|
+
# 使用示例
|
176
|
+
# async with OKXSwap() as okx:
|
177
|
+
# df = await okx.get_klines("BTC-USDT", "1H", datetime(2023,1,1))
|
hyperquant/notikit.py
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
"""
|
2
|
+
通知类的功能简单封装,非必要别修改 :)
|
3
|
+
只要知道怎么使用以下函数:
|
4
|
+
- send_wecom_msg
|
5
|
+
- send_wecom_img
|
6
|
+
|
7
|
+
Binance期现套利 | 邢不行 | 2024分享会
|
8
|
+
author: 邢不行
|
9
|
+
微信: xbx6660
|
10
|
+
"""
|
11
|
+
import base64
|
12
|
+
import hashlib
|
13
|
+
import os.path
|
14
|
+
import requests
|
15
|
+
import json
|
16
|
+
import traceback
|
17
|
+
from datetime import datetime
|
18
|
+
|
19
|
+
from hyperquant.logkit import get_logger
|
20
|
+
logger = get_logger('notikit', './data/logs/notikit.log', show_time=True)
|
21
|
+
|
22
|
+
|
23
|
+
|
24
|
+
proxy = {}
|
25
|
+
|
26
|
+
|
27
|
+
def handle_exception(e: Exception, msg: str = '') -> None:
|
28
|
+
logger.error(f"{msg}:{e}")
|
29
|
+
logger.error(e)
|
30
|
+
logger.error(traceback.format_exc())
|
31
|
+
|
32
|
+
|
33
|
+
# 企业微信通知
|
34
|
+
def send_wecom_msg(content, webhook_url):
|
35
|
+
if not webhook_url:
|
36
|
+
logger.warning('未配置wecom_webhook_url,不发送信息')
|
37
|
+
return
|
38
|
+
if not content:
|
39
|
+
logger.warning('未配置content,不发送信息')
|
40
|
+
return
|
41
|
+
try:
|
42
|
+
data = {
|
43
|
+
"msgtype": "text",
|
44
|
+
"text": {
|
45
|
+
"content": content + '\n' + datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
46
|
+
}
|
47
|
+
}
|
48
|
+
r = requests.post(webhook_url, data=json.dumps(data), timeout=10, proxies=proxy)
|
49
|
+
logger.info(f'调用企业微信接口返回: {r.text}')
|
50
|
+
logger.ok('成功发送企业微信')
|
51
|
+
except Exception as e:
|
52
|
+
handle_exception(e, '发送企业微信失败')
|
53
|
+
|
54
|
+
|
55
|
+
# 上传图片,解析bytes
|
56
|
+
class MyEncoder(json.JSONEncoder):
|
57
|
+
def default(self, obj):
|
58
|
+
"""
|
59
|
+
只要检查到了是bytes类型的数据就把它转为str类型
|
60
|
+
:param obj:
|
61
|
+
:return:
|
62
|
+
"""
|
63
|
+
if isinstance(obj, bytes):
|
64
|
+
return str(obj, encoding='utf-8')
|
65
|
+
return json.JSONEncoder.default(self, obj)
|
66
|
+
|
67
|
+
|
68
|
+
# 企业微信发送图片
|
69
|
+
def send_wecom_img(file_path, webhook_url):
|
70
|
+
"""
|
71
|
+
企业微信发送图片
|
72
|
+
:param file_path: 图片地址
|
73
|
+
:param webhook_url: 企业微信webhook网址
|
74
|
+
:return:
|
75
|
+
"""
|
76
|
+
if not os.path.exists(file_path):
|
77
|
+
logger.warning('找不到图片')
|
78
|
+
return
|
79
|
+
if not webhook_url:
|
80
|
+
logger.warning('未配置wecom_webhook_url,不发送信息')
|
81
|
+
return
|
82
|
+
try:
|
83
|
+
with open(file_path, 'rb') as f:
|
84
|
+
image_content = f.read()
|
85
|
+
image_base64 = base64.b64encode(image_content).decode('utf-8')
|
86
|
+
md5 = hashlib.md5()
|
87
|
+
md5.update(image_content)
|
88
|
+
image_md5 = md5.hexdigest()
|
89
|
+
data = {
|
90
|
+
'msgtype': 'image',
|
91
|
+
'image': {
|
92
|
+
'base64': image_base64,
|
93
|
+
'md5': image_md5
|
94
|
+
}
|
95
|
+
}
|
96
|
+
# 服务器上传bytes图片的时候,json.dumps解析会出错,需要自己手动去转一下
|
97
|
+
r = requests.post(webhook_url, data=json.dumps(data, cls=MyEncoder, indent=4), timeout=10, proxies=proxy)
|
98
|
+
logger.info(f'调用企业微信接口返回: {r.text}')
|
99
|
+
logger.ok('成功发送企业微信图片')
|
100
|
+
except Exception as e:
|
101
|
+
handle_exception(e, '发送企业微信图片失败')
|
102
|
+
finally:
|
103
|
+
if os.path.exists(file_path):
|
104
|
+
os.remove(file_path)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: hyperquant
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.22
|
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
|
@@ -19,6 +19,7 @@ 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.8.2
|
22
23
|
Requires-Dist: pyecharts>=2.0.8
|
23
24
|
Description-Content-Type: text/markdown
|
24
25
|
|
@@ -1,11 +1,14 @@
|
|
1
|
-
hyperquant/__init__.py,sha256=
|
1
|
+
hyperquant/__init__.py,sha256=gUuAPVpg5k8X_dpda5OpqmMyZ-ZXNQq-xwx-6JR5Jr4,131
|
2
2
|
hyperquant/core.py,sha256=vKv8KElo1eGhr_aw0I-j6ZxPOneDx86KqAoOI-wbq0A,18838
|
3
3
|
hyperquant/db.py,sha256=i2TjkCbmH4Uxo7UTDvOYBfy973gLcGexdzuT_YcSeIE,6678
|
4
4
|
hyperquant/draw.py,sha256=up_lQ3pHeVLoNOyh9vPjgNwjD0M-6_IetSGviQUgjhY,54624
|
5
5
|
hyperquant/logkit.py,sha256=WALpXpIA3Ywr5DxKKK3k5EKubZ2h-ISGfc5dUReQUBQ,7795
|
6
|
+
hyperquant/notikit.py,sha256=IztRn_qSBPf9IbDeQaruW_QSqs7TMB3h-V6obIuA1Mo,3090
|
7
|
+
hyperquant/broker/hyperliquid.py,sha256=mSBVfmjcv6ciI1vWrmHYwBOTHrg-NQrwcyVFUXYEgVw,21998
|
6
8
|
hyperquant/datavison/_util.py,sha256=92qk4vO856RqycO0YqEIHJlEg-W9XKapDVqAMxe6rbw,533
|
7
9
|
hyperquant/datavison/binance.py,sha256=3yNKTqvt_vUQcxzeX4ocMsI5k6Q6gLZrvgXxAEad6Kc,5001
|
8
10
|
hyperquant/datavison/coinglass.py,sha256=PEjdjISP9QUKD_xzXNzhJ9WFDTlkBrRQlVL-5pxD5mo,10482
|
9
|
-
hyperquant
|
10
|
-
hyperquant-0.
|
11
|
-
hyperquant-0.
|
11
|
+
hyperquant/datavison/okx.py,sha256=yg8WrdQ7wgWHNAInIgsWPM47N3Wkfr253169IPAycAY,6898
|
12
|
+
hyperquant-0.22.dist-info/METADATA,sha256=7Q9d1SaurAVVr9nOv-vnESvINj2pjqjVu_kboAqM1Ug,4317
|
13
|
+
hyperquant-0.22.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
14
|
+
hyperquant-0.22.dist-info/RECORD,,
|
File without changes
|