hyperquant 0.6__py3-none-any.whl → 0.8__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/broker/auth.py +144 -9
- hyperquant/broker/bitget.py +311 -0
- hyperquant/broker/edgex.py +331 -14
- hyperquant/broker/lbank.py +588 -0
- hyperquant/broker/lib/edgex_sign.py +455 -0
- hyperquant/broker/lib/util.py +22 -0
- hyperquant/broker/models/bitget.py +359 -0
- hyperquant/broker/models/edgex.py +545 -5
- hyperquant/broker/models/lbank.py +557 -0
- hyperquant/broker/ws.py +21 -3
- {hyperquant-0.6.dist-info → hyperquant-0.8.dist-info}/METADATA +1 -1
- {hyperquant-0.6.dist-info → hyperquant-0.8.dist-info}/RECORD +13 -7
- {hyperquant-0.6.dist-info → hyperquant-0.8.dist-info}/WHEEL +0 -0
hyperquant/broker/auth.py
CHANGED
@@ -1,7 +1,10 @@
|
|
1
|
+
import base64
|
2
|
+
import hmac
|
3
|
+
import urllib.parse
|
1
4
|
import time
|
2
5
|
import hashlib
|
3
6
|
from typing import Any
|
4
|
-
from aiohttp import ClientWebSocketResponse
|
7
|
+
from aiohttp import ClientWebSocketResponse, FormData, JsonPayload
|
5
8
|
from multidict import CIMultiDict
|
6
9
|
from yarl import URL
|
7
10
|
import pybotters
|
@@ -13,8 +16,106 @@ def md5_hex(s: str) -> str:
|
|
13
16
|
return hashlib.md5(s.encode("utf-8")).hexdigest()
|
14
17
|
|
15
18
|
|
19
|
+
|
20
|
+
def serialize(obj, prefix=''):
|
21
|
+
"""
|
22
|
+
Python 版 UK/v:递归排序 + urlencode + 展平
|
23
|
+
"""
|
24
|
+
def _serialize(obj, prefix=''):
|
25
|
+
if obj is None:
|
26
|
+
return []
|
27
|
+
if isinstance(obj, dict):
|
28
|
+
items = []
|
29
|
+
for k in sorted(obj.keys()):
|
30
|
+
v = obj[k]
|
31
|
+
n = f"{prefix}[{k}]" if prefix else k
|
32
|
+
items.extend(_serialize(v, n))
|
33
|
+
return items
|
34
|
+
elif isinstance(obj, list):
|
35
|
+
# JS style: output key once, then join values by &
|
36
|
+
values = []
|
37
|
+
for v in obj:
|
38
|
+
if isinstance(v, dict):
|
39
|
+
# Recursively serialize dict, but drop key= part (just use value part)
|
40
|
+
sub = _serialize(v, prefix)
|
41
|
+
# sub is a list of key=value, but we want only value part
|
42
|
+
for s in sub:
|
43
|
+
# s is like 'key=value', need only value
|
44
|
+
parts = s.split('=', 1)
|
45
|
+
if len(parts) == 2:
|
46
|
+
values.append(parts[1])
|
47
|
+
else:
|
48
|
+
values.append(parts[0])
|
49
|
+
else:
|
50
|
+
# Handle booleans and empty strings
|
51
|
+
if isinstance(v, bool):
|
52
|
+
val = "true" if v else "false"
|
53
|
+
elif v == "":
|
54
|
+
val = ""
|
55
|
+
else:
|
56
|
+
val = str(v)
|
57
|
+
values.append(val)
|
58
|
+
return [f"{urllib.parse.quote(str(prefix))}={'&'.join(values)}"]
|
59
|
+
else:
|
60
|
+
# Handle booleans and empty strings
|
61
|
+
if isinstance(obj, bool):
|
62
|
+
val = "true" if obj else "false"
|
63
|
+
elif obj == "":
|
64
|
+
val = ""
|
65
|
+
else:
|
66
|
+
val = str(obj)
|
67
|
+
return [f"{urllib.parse.quote(str(prefix))}={val}"]
|
68
|
+
return "&".join(_serialize(obj, prefix))
|
69
|
+
|
16
70
|
# 🔑 Ourbit 的鉴权函数
|
17
71
|
class Auth:
|
72
|
+
@staticmethod
|
73
|
+
def edgex(args: tuple[str, URL], kwargs: dict[str, Any]) -> tuple[str, URL]:
|
74
|
+
method: str = args[0]
|
75
|
+
url: URL = args[1]
|
76
|
+
data = kwargs.get("data") or {}
|
77
|
+
headers: CIMultiDict = kwargs["headers"]
|
78
|
+
|
79
|
+
session = kwargs["session"]
|
80
|
+
api_key:str = session.__dict__["_apis"][pybotters.auth.Hosts.items[url.host].name][0]
|
81
|
+
secret = session.__dict__["_apis"][pybotters.auth.Hosts.items[url.host].name][1]
|
82
|
+
passphrase:str = session.__dict__["_apis"][pybotters.auth.Hosts.items[url.host].name][2]
|
83
|
+
passphrase = passphrase.split("-")[0]
|
84
|
+
timestamp = str(int(time.time() * 1000))
|
85
|
+
# timestamp = "1758535055061"
|
86
|
+
|
87
|
+
raw_body = ""
|
88
|
+
if data and method.upper() in ["POST", "PUT", "PATCH"] and data:
|
89
|
+
raw_body = serialize(data)
|
90
|
+
else:
|
91
|
+
raw_body = serialize(dict(url.query.items()))
|
92
|
+
|
93
|
+
|
94
|
+
secret_quoted = urllib.parse.quote(secret, safe="")
|
95
|
+
b64_secret = base64.b64encode(secret_quoted.encode("utf-8")).decode()
|
96
|
+
message = f"{timestamp}{method.upper()}{url.raw_path}{raw_body}"
|
97
|
+
sign = hmac.new(b64_secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
98
|
+
|
99
|
+
sigh_header = {
|
100
|
+
"X-edgeX-Api-Key": api_key,
|
101
|
+
"X-edgeX-Passphrase": passphrase,
|
102
|
+
"X-edgeX-Signature": sign,
|
103
|
+
"X-edgeX-Timestamp": timestamp,
|
104
|
+
}
|
105
|
+
# ws单独进行签名
|
106
|
+
if headers.get("Upgrade") == "websocket":
|
107
|
+
json_str = pyjson.dumps(sigh_header, separators=(",", ":"))
|
108
|
+
b64_str = base64.b64encode(json_str.encode("utf-8")).decode("utf-8")
|
109
|
+
b64_str.replace("=", "")
|
110
|
+
headers.update({"Sec-WebSocket-Protocol": b64_str})
|
111
|
+
else:
|
112
|
+
headers.update(sigh_header)
|
113
|
+
|
114
|
+
if data:
|
115
|
+
kwargs.update({"data": JsonPayload(data)})
|
116
|
+
|
117
|
+
return args
|
118
|
+
|
18
119
|
@staticmethod
|
19
120
|
def ourbit(args: tuple[str, URL], kwargs: dict[str, Any]) -> tuple[str, URL]:
|
20
121
|
method: str = args[0]
|
@@ -67,18 +168,35 @@ class Auth:
|
|
67
168
|
token = session.__dict__["_apis"][pybotters.auth.Hosts.items[url.host].name][0]
|
68
169
|
cookie = f"uc_token={token}; u_id={token}; "
|
69
170
|
headers.update({"cookie": cookie})
|
171
|
+
return args
|
70
172
|
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
# url = url.with_query(q)
|
173
|
+
@staticmethod
|
174
|
+
def lbank(args: tuple[str, URL], kwargs: dict[str, Any]) -> tuple[str, URL]:
|
175
|
+
method: str = args[0]
|
176
|
+
url: URL = args[1]
|
177
|
+
data = kwargs.get("data") or {}
|
178
|
+
headers: CIMultiDict = kwargs["headers"]
|
78
179
|
|
180
|
+
# 从 session 里取 api_key & secret
|
181
|
+
session = kwargs["session"]
|
182
|
+
token = session.__dict__["_apis"][pybotters.auth.Hosts.items[url.host].name][0]
|
79
183
|
|
80
|
-
return args
|
81
184
|
|
185
|
+
# 设置 headers
|
186
|
+
headers.update(
|
187
|
+
{
|
188
|
+
"ex-language": 'zh-TW',
|
189
|
+
"ex-token": token,
|
190
|
+
"source": "4",
|
191
|
+
"versionflage": "true",
|
192
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0"
|
193
|
+
}
|
194
|
+
)
|
195
|
+
|
196
|
+
# 更新 kwargs.body,保证发出去的与签名一致
|
197
|
+
# kwargs.update({"data": raw_body_for_sign})
|
198
|
+
|
199
|
+
return args
|
82
200
|
|
83
201
|
pybotters.auth.Hosts.items["futures.ourbit.com"] = pybotters.auth.Item(
|
84
202
|
"ourbit", Auth.ourbit
|
@@ -86,3 +204,20 @@ pybotters.auth.Hosts.items["futures.ourbit.com"] = pybotters.auth.Item(
|
|
86
204
|
pybotters.auth.Hosts.items["www.ourbit.com"] = pybotters.auth.Item(
|
87
205
|
"ourbit", Auth.ourbit_spot
|
88
206
|
)
|
207
|
+
|
208
|
+
pybotters.auth.Hosts.items["www.ourbit.com"] = pybotters.auth.Item(
|
209
|
+
"ourbit", Auth.ourbit_spot
|
210
|
+
)
|
211
|
+
|
212
|
+
pybotters.auth.Hosts.items["pro.edgex.exchange"] = pybotters.auth.Item(
|
213
|
+
"edgex", Auth.edgex
|
214
|
+
)
|
215
|
+
|
216
|
+
|
217
|
+
pybotters.auth.Hosts.items["quote.edgex.exchange"] = pybotters.auth.Item(
|
218
|
+
"edgex", Auth.edgex
|
219
|
+
)
|
220
|
+
|
221
|
+
pybotters.auth.Hosts.items["uuapi.rerrkvifj.com"] = pybotters.auth.Item(
|
222
|
+
"lbank", Auth.lbank
|
223
|
+
)
|
@@ -0,0 +1,311 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import asyncio
|
4
|
+
import logging
|
5
|
+
import uuid
|
6
|
+
from typing import Any, Literal
|
7
|
+
|
8
|
+
import pybotters
|
9
|
+
|
10
|
+
from .models.bitget import BitgetDataStore
|
11
|
+
from .lib.util import fmt_value
|
12
|
+
|
13
|
+
logger = logging.getLogger(__name__)
|
14
|
+
|
15
|
+
|
16
|
+
class Bitget:
|
17
|
+
"""Bitget public/privileged client (REST + WS).
|
18
|
+
|
19
|
+
默认只支持单向持仓(One-way mode)。
|
20
|
+
"""
|
21
|
+
|
22
|
+
def __init__(
|
23
|
+
self,
|
24
|
+
client: pybotters.Client,
|
25
|
+
*,
|
26
|
+
rest_api: str | None = None,
|
27
|
+
ws_url: str | None = None,
|
28
|
+
) -> None:
|
29
|
+
self.client = client
|
30
|
+
self.store = BitgetDataStore()
|
31
|
+
|
32
|
+
self.rest_api = rest_api or "https://api.bitget.com"
|
33
|
+
self.ws_url = ws_url or "wss://ws.bitget.com/v2/ws/public"
|
34
|
+
self.ws_url_private = ws_url or "wss://ws.bitget.com/v2/ws/private"
|
35
|
+
|
36
|
+
self.ws_app = None
|
37
|
+
self.has_sub_personal = False
|
38
|
+
|
39
|
+
|
40
|
+
async def __aenter__(self) -> "Bitget":
|
41
|
+
await self.update("detail")
|
42
|
+
return self
|
43
|
+
|
44
|
+
async def __aexit__(self, exc_type, exc, tb) -> None: # pragma: no cover - symmetry
|
45
|
+
pass
|
46
|
+
|
47
|
+
async def sub_personal(self) -> None:
|
48
|
+
sub_msg = {
|
49
|
+
"op": "subscribe",
|
50
|
+
"args": [
|
51
|
+
{"instType": "USDT-FUTURES", "channel": "orders", "instId": "default"},
|
52
|
+
{
|
53
|
+
"instType": "USDT-FUTURES",
|
54
|
+
"channel": "positions",
|
55
|
+
"instId": "default",
|
56
|
+
},
|
57
|
+
{"instType": "USDT-FUTURES", "channel": "account", "coin": "default"},
|
58
|
+
],
|
59
|
+
}
|
60
|
+
self.ws_app = await self._ensure_private_ws()
|
61
|
+
|
62
|
+
|
63
|
+
await self.ws_app.current_ws.send_json(sub_msg)
|
64
|
+
|
65
|
+
|
66
|
+
self.has_sub_personal = True
|
67
|
+
|
68
|
+
async def update(
|
69
|
+
self,
|
70
|
+
update_type: Literal["detail", "ticker", "all"] = "all",
|
71
|
+
) -> None:
|
72
|
+
"""Refresh cached REST resources."""
|
73
|
+
|
74
|
+
requests: list[Any] = []
|
75
|
+
|
76
|
+
if update_type in {"detail", "all"}:
|
77
|
+
requests.append(
|
78
|
+
self.client.get(
|
79
|
+
f"{self.rest_api}/api/v2/mix/market/contracts",
|
80
|
+
params={"productType": "usdt-futures"},
|
81
|
+
)
|
82
|
+
)
|
83
|
+
|
84
|
+
if update_type in {"ticker", "all"}:
|
85
|
+
requests.append(
|
86
|
+
self.client.get(
|
87
|
+
f"{self.rest_api}/api/v2/mix/market/tickers",
|
88
|
+
params={"productType": "usdt-futures"},
|
89
|
+
)
|
90
|
+
)
|
91
|
+
|
92
|
+
if not requests:
|
93
|
+
raise ValueError(f"update_type err: {update_type}")
|
94
|
+
|
95
|
+
await self.store.initialize(*requests)
|
96
|
+
|
97
|
+
async def place_order(
|
98
|
+
self,
|
99
|
+
symbol: str,
|
100
|
+
*,
|
101
|
+
direction: Literal["buy", "sell", "long", "short", "0", "1"],
|
102
|
+
volume: float,
|
103
|
+
price: float | None = None,
|
104
|
+
order_type: Literal[
|
105
|
+
"market",
|
106
|
+
"limit_gtc",
|
107
|
+
"limit_ioc",
|
108
|
+
"limit_fok",
|
109
|
+
"limit_post_only",
|
110
|
+
"limit",
|
111
|
+
] = "market",
|
112
|
+
margin_mode: Literal["isolated", "crossed"] = "crossed",
|
113
|
+
product_type: str = "USDT-FUTURES",
|
114
|
+
margin_coin: str = "USDT",
|
115
|
+
reduce_only: bool | None = None,
|
116
|
+
offset_flag: Literal["open", "close", "0", "1"] | None = None,
|
117
|
+
client_order_id: str | None = None
|
118
|
+
) -> dict[str, Any] | None:
|
119
|
+
"""
|
120
|
+
请求成功返回示例:
|
121
|
+
|
122
|
+
.. code:: json
|
123
|
+
|
124
|
+
{
|
125
|
+
"clientOid": "121211212122",
|
126
|
+
"orderId": "121211212122"
|
127
|
+
}
|
128
|
+
"""
|
129
|
+
|
130
|
+
side = self._normalize_direction(direction)
|
131
|
+
order_type_code, force_code = self._resolve_order_type(order_type)
|
132
|
+
|
133
|
+
if reduce_only is None:
|
134
|
+
reduce_only = self._normalize_offset(offset_flag)
|
135
|
+
|
136
|
+
detail = self._get_detail_entry(symbol)
|
137
|
+
volume_str = self._format_with_step(
|
138
|
+
volume, detail.get("step_size") or detail.get("stepSize")
|
139
|
+
)
|
140
|
+
|
141
|
+
payload: dict[str, Any] = {
|
142
|
+
"symbol": symbol,
|
143
|
+
"productType": product_type,
|
144
|
+
"marginMode": margin_mode,
|
145
|
+
"marginCoin": margin_coin,
|
146
|
+
"side": side,
|
147
|
+
"size": volume_str,
|
148
|
+
"orderType": order_type_code,
|
149
|
+
}
|
150
|
+
|
151
|
+
if force_code:
|
152
|
+
payload["force"] = force_code
|
153
|
+
|
154
|
+
if order_type_code == "limit":
|
155
|
+
if price is None:
|
156
|
+
raise ValueError("price is required for Bitget limit orders")
|
157
|
+
payload["price"] = self._format_with_step(
|
158
|
+
price,
|
159
|
+
detail.get("tick_size") or detail.get("tickSize"),
|
160
|
+
)
|
161
|
+
elif price is not None:
|
162
|
+
logger.debug("Price %.8f ignored for market order", price)
|
163
|
+
|
164
|
+
if reduce_only is True:
|
165
|
+
payload["reduceOnly"] = "YES"
|
166
|
+
elif reduce_only is False:
|
167
|
+
payload["reduceOnly"] = "NO"
|
168
|
+
|
169
|
+
if client_order_id:
|
170
|
+
payload["clientOid"] = client_order_id
|
171
|
+
|
172
|
+
res = await self.client.post(
|
173
|
+
f"{self.rest_api}/api/v2/mix/order/place-order",
|
174
|
+
data=payload,
|
175
|
+
)
|
176
|
+
data = await res.json()
|
177
|
+
return self._ensure_ok("place_order", data)
|
178
|
+
|
179
|
+
async def cancel_order(
|
180
|
+
self,
|
181
|
+
order_sys_id: str,
|
182
|
+
*,
|
183
|
+
symbol: str,
|
184
|
+
margin_mode: Literal["isolated", "crossed"],
|
185
|
+
product_type: str = "USDT-FUTURES",
|
186
|
+
margin_coin: str = "USDT",
|
187
|
+
client_order_id: str | None = None,
|
188
|
+
) -> dict[str, Any]:
|
189
|
+
"""Cancel an order via ``POST /api/v2/mix/order/cancel-order``."""
|
190
|
+
|
191
|
+
payload = {
|
192
|
+
"symbol": symbol,
|
193
|
+
"productType": product_type,
|
194
|
+
"marginMode": margin_mode,
|
195
|
+
"marginCoin": margin_coin,
|
196
|
+
}
|
197
|
+
|
198
|
+
if client_order_id:
|
199
|
+
payload["clientOid"] = client_order_id
|
200
|
+
else:
|
201
|
+
payload["orderId"] = order_sys_id
|
202
|
+
|
203
|
+
res = await self.client.post(
|
204
|
+
f"{self.rest_api}/api/v2/mix/order/cancel-order",
|
205
|
+
data=payload,
|
206
|
+
)
|
207
|
+
data = await res.json()
|
208
|
+
return self._ensure_ok("cancel_order", data)
|
209
|
+
|
210
|
+
async def sub_orderbook(self, symbols: list[str], channel: str = "books1") -> None:
|
211
|
+
"""Subscribe to Bitget order-book snapshots/updates."""
|
212
|
+
|
213
|
+
submsg = {"op": "subscribe", "args": []}
|
214
|
+
for symbol in symbols:
|
215
|
+
submsg["args"].append(
|
216
|
+
{"instType": "USDT-FUTURES", "channel": channel, "instId": symbol}
|
217
|
+
)
|
218
|
+
|
219
|
+
self.client.ws_connect(
|
220
|
+
self.ws_url,
|
221
|
+
send_json=submsg,
|
222
|
+
hdlr_json=self.store.onmessage,
|
223
|
+
)
|
224
|
+
|
225
|
+
def _get_detail_entry(self, symbol: str) -> dict[str, Any]:
|
226
|
+
detail = self.store.detail.get({"symbol": symbol})
|
227
|
+
if not detail:
|
228
|
+
raise ValueError(
|
229
|
+
f"Unknown Bitget instrument: {symbol}. Call update('detail') first or provide valid symbol."
|
230
|
+
)
|
231
|
+
return detail
|
232
|
+
|
233
|
+
async def _ensure_private_ws(self):
|
234
|
+
wsqueue = pybotters.WebSocketQueue()
|
235
|
+
ws_app = self.client.ws_connect(
|
236
|
+
self.ws_url_private,
|
237
|
+
hdlr_json=self.store.onmessage,
|
238
|
+
)
|
239
|
+
# async for msg in wsqueue:
|
240
|
+
# print(msg)
|
241
|
+
|
242
|
+
await ws_app._event.wait()
|
243
|
+
await ws_app.current_ws._wait_authtask()
|
244
|
+
return ws_app
|
245
|
+
|
246
|
+
@staticmethod
|
247
|
+
def _format_with_step(value: float, step: Any) -> str:
|
248
|
+
if step in (None, 0, "0"):
|
249
|
+
return str(value)
|
250
|
+
try:
|
251
|
+
step_float = float(step)
|
252
|
+
except (TypeError, ValueError): # pragma: no cover - defensive guard
|
253
|
+
return str(value)
|
254
|
+
if step_float <= 0:
|
255
|
+
return str(value)
|
256
|
+
return fmt_value(value, step_float)
|
257
|
+
|
258
|
+
@staticmethod
|
259
|
+
def _normalize_direction(direction: str) -> str:
|
260
|
+
mapping = {
|
261
|
+
"buy": "buy",
|
262
|
+
"long": "buy",
|
263
|
+
"0": "buy",
|
264
|
+
"sell": "sell",
|
265
|
+
"short": "sell",
|
266
|
+
"1": "sell",
|
267
|
+
}
|
268
|
+
key = str(direction).lower()
|
269
|
+
try:
|
270
|
+
return mapping[key]
|
271
|
+
except KeyError as exc: # pragma: no cover - guard
|
272
|
+
raise ValueError(f"Unsupported direction: {direction}") from exc
|
273
|
+
|
274
|
+
@staticmethod
|
275
|
+
def _normalize_offset(
|
276
|
+
offset: Literal["open", "close", "0", "1"] | None,
|
277
|
+
) -> bool | None:
|
278
|
+
if offset is None:
|
279
|
+
return None
|
280
|
+
mapping = {
|
281
|
+
"open": False,
|
282
|
+
"0": False,
|
283
|
+
"close": True,
|
284
|
+
"1": True,
|
285
|
+
}
|
286
|
+
key = str(offset).lower()
|
287
|
+
if key in mapping:
|
288
|
+
return mapping[key]
|
289
|
+
raise ValueError(f"Unsupported offset_flag: {offset}")
|
290
|
+
|
291
|
+
@staticmethod
|
292
|
+
def _resolve_order_type(order_type: str) -> tuple[str, str | None]:
|
293
|
+
mapping = {
|
294
|
+
"market": ("market", None),
|
295
|
+
"limit": ("limit", "gtc"),
|
296
|
+
"limit_gtc": ("limit", "gtc"),
|
297
|
+
"limit_ioc": ("limit", "ioc"),
|
298
|
+
"limit_fok": ("limit", "fok"),
|
299
|
+
"limit_post_only": ("limit", "post_only"),
|
300
|
+
}
|
301
|
+
key = str(order_type).lower()
|
302
|
+
try:
|
303
|
+
return mapping[key]
|
304
|
+
except KeyError as exc: # pragma: no cover - guard
|
305
|
+
raise ValueError(f"Unsupported order_type: {order_type}") from exc
|
306
|
+
|
307
|
+
@staticmethod
|
308
|
+
def _ensure_ok(operation: str, data: Any) -> dict[str, Any]:
|
309
|
+
if not isinstance(data, dict) or data.get("code") != "00000":
|
310
|
+
raise RuntimeError(f"{operation} failed: {data}")
|
311
|
+
return data.get("data") or {}
|