hyperquant 0.92__py3-none-any.whl → 0.94__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 CHANGED
@@ -198,6 +198,57 @@ class Auth:
198
198
 
199
199
  return args
200
200
 
201
+ @staticmethod
202
+ def bitmart(args: tuple[str, URL], kwargs: dict[str, Any]) -> tuple[str, URL]:
203
+ method: str = args[0]
204
+ url: URL = args[1]
205
+ headers: CIMultiDict = kwargs["headers"]
206
+
207
+ session = kwargs["session"]
208
+ host_key = url.host
209
+ try:
210
+ api_name = pybotters.auth.Hosts.items[host_key].name
211
+ except (KeyError, AttributeError):
212
+ api_name = host_key
213
+
214
+ creds = session.__dict__.get("_apis", {}).get(api_name)
215
+ if not creds or len(creds) < 3:
216
+ raise RuntimeError("Bitmart credentials (accessKey, accessSalt, device) are required")
217
+
218
+ access_key = creds[0]
219
+ access_salt = creds[1]
220
+ access_salt = access_salt.decode("utf-8")
221
+ device = creds[2]
222
+ extra_cookie = creds[3] if len(creds) > 3 else None
223
+
224
+ cookie_parts = [
225
+ f"accessKey={access_key}",
226
+ f"accessSalt={access_salt}",
227
+ "hasDelegation=false",
228
+ "delegationType=0",
229
+ "delegationTypeList=[]",
230
+ ]
231
+ if extra_cookie:
232
+ if isinstance(extra_cookie, str) and extra_cookie:
233
+ cookie_parts.append(extra_cookie.strip(";"))
234
+
235
+ headers["cookie"] = "; ".join(cookie_parts)
236
+
237
+ headers.setdefault("x-bm-client", "WEB")
238
+ headers.setdefault("x-bm-contract", "2")
239
+ headers.setdefault("x-bm-device", device)
240
+ headers.setdefault("x-bm-timezone", "Asia/Shanghai")
241
+ headers.setdefault("x-bm-timezone-offset", "-480")
242
+ headers.setdefault("x-bm-tag", "")
243
+ headers.setdefault("x-bm-version", "5e13905")
244
+ headers.setdefault('User-Agent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0')
245
+
246
+ ua = headers.get("User-Agent") or headers.get("user-agent")
247
+ if ua:
248
+ headers.setdefault("x-bm-ua", ua)
249
+
250
+ return args
251
+
201
252
  @staticmethod
202
253
  def coinw(args: tuple[str, URL], kwargs: dict[str, Any]) -> tuple[str, URL]:
203
254
  method: str = args[0]
@@ -294,3 +345,7 @@ pybotters.auth.Hosts.items["uuapi.rerrkvifj.com"] = pybotters.auth.Item(
294
345
  pybotters.auth.Hosts.items["api.coinw.com"] = pybotters.auth.Item(
295
346
  "coinw", Auth.coinw
296
347
  )
348
+
349
+ pybotters.auth.Hosts.items["derivatives.bitmart.com"] = pybotters.auth.Item(
350
+ "bitmart", Auth.bitmart
351
+ )
@@ -0,0 +1,471 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import random
6
+ import time
7
+ from typing import Any, Literal, Sequence
8
+
9
+ import pybotters
10
+
11
+ from .models.bitmart import BitmartDataStore
12
+ from .lib.util import fmt_value
13
+
14
+ class Bitmart:
15
+ """Bitmart 合约交易(REST + WebSocket)。"""
16
+
17
+ def __init__(
18
+ self,
19
+ client: pybotters.Client,
20
+ *,
21
+ public_api: str | None = None,
22
+ forward_api: str | None = None,
23
+ ws_url: str | None = None,
24
+ account_index: int | None = None,
25
+ apis: str = None
26
+ ) -> None:
27
+ self.client = client
28
+ self.store = BitmartDataStore()
29
+
30
+ self.public_api = public_api or "https://contract-v2.bitmart.com"
31
+ self.private_api = "https://derivatives.bitmart.com"
32
+ self.forward_api = f'{self.private_api}/gw-api/contract-tiger/forward'
33
+ self.ws_url = ws_url or "wss://contract-ws-v2.bitmart.com/v1/ifcontract/realTime"
34
+
35
+ self.account_index = account_index
36
+ self.apis = apis
37
+
38
+ async def __aenter__(self) -> "Bitmart":
39
+ await self.update("detail")
40
+ asyncio.create_task(self.auto_refresh())
41
+ return self
42
+
43
+ async def auto_refresh(self, sec=3600, test=False) -> None:
44
+ """每隔一小时刷新token"""
45
+ client = self.client
46
+ while not client._session.closed:
47
+
48
+ await asyncio.sleep(sec)
49
+
50
+ if client._session.__dict__["_apis"].get("bitmart") is None:
51
+ continue
52
+
53
+ # 执行请求
54
+ res = await client.post(
55
+ f"{self.private_api}/gw-api/gateway/token/v2/renew",
56
+ )
57
+
58
+ print(await res.text())
59
+ resp:dict = await res.json()
60
+ if resp.get("success") is False:
61
+ raise ValueError(f"Bitmart refreshToken error: {resp}")
62
+
63
+ data:dict = resp.get("data", {})
64
+ new_token = data.get("accessToken")
65
+ secret = data.get("accessSalt")
66
+
67
+ # 加载原来的apis
68
+ apis_dict = client._load_apis(self.apis)
69
+
70
+ device = apis_dict['bitmart'][2]
71
+
72
+ apis_dict["bitmart"] = [new_token, secret, device]
73
+
74
+ client._session.__dict__["_apis"] = client._encode_apis(apis_dict)
75
+
76
+ if test:
77
+ print("Bitmart token refreshed.")
78
+ break
79
+
80
+ async def __aexit__(self, exc_type, exc, tb) -> None: # pragma: no cover - symmetry
81
+ return None
82
+
83
+ def get_contract_id(self, symbol: str) -> str | None:
84
+ """Resolve contract ID from cached detail data."""
85
+ detail = (
86
+ self.store.detail.get({"name": symbol})
87
+ or self.store.detail.get({"display_name": symbol})
88
+ or self.store.detail.get({"contract_id": symbol})
89
+ )
90
+ if detail is None:
91
+ return None
92
+ contract_id = detail.get("contract_id")
93
+ if contract_id is None:
94
+ return None
95
+ return str(contract_id)
96
+
97
+ def _get_detail_entry(
98
+ self,
99
+ *,
100
+ symbol: str | None = None,
101
+ market_index: int | None = None,
102
+ ) -> dict[str, Any] | None:
103
+ if symbol:
104
+ entry = (
105
+ self.store.detail.get({"name": symbol})
106
+ or self.store.detail.get({"display_name": symbol})
107
+ )
108
+ if entry:
109
+ return entry
110
+
111
+ if market_index is not None:
112
+ entries = self.store.detail.find({"contract_id": market_index})
113
+ if entries:
114
+ return entries[0]
115
+ entries = self.store.detail.find({"contract_id": str(market_index)})
116
+ if entries:
117
+ return entries[0]
118
+
119
+ return None
120
+
121
+ @staticmethod
122
+ def _normalize_enum(
123
+ value: int | str,
124
+ mapping: dict[str, int],
125
+ field: str,
126
+ ) -> int:
127
+ if isinstance(value, str):
128
+ key = value.lower()
129
+ try:
130
+ return mapping[key]
131
+ except KeyError as exc:
132
+ raise ValueError(f"Unsupported {field}: {value}") from exc
133
+ try:
134
+ return int(value)
135
+ except (TypeError, ValueError) as exc:
136
+ raise ValueError(f"Unsupported {field}: {value}") from exc
137
+
138
+ async def update(
139
+ self,
140
+ update_type: Literal[
141
+ "detail",
142
+ "orders",
143
+ "positions",
144
+ "balances",
145
+ "account",
146
+ "all",
147
+ "history_orders",
148
+ ] = "all",
149
+ *,
150
+ orders_params: dict[str, Any] | None = None,
151
+ positions_params: dict[str, Any] | None = None,
152
+ ) -> None:
153
+ """Refresh cached REST resources."""
154
+
155
+ tasks: dict[str, Any] = {}
156
+
157
+ include_detail = update_type in {"detail", "all"}
158
+ include_orders = update_type in {"orders", "all"}
159
+ include_positions = update_type in {"positions", "all"}
160
+ include_balances = update_type in {"balances", "account", "all"}
161
+ include_history_orders = update_type in {"history_orders"}
162
+
163
+ if include_detail:
164
+ tasks["detail"] = self.client.get(f"{self.public_api}/v1/ifcontract/contracts_all")
165
+
166
+ if include_orders:
167
+ params = {
168
+ "status": 3,
169
+ "size": 200,
170
+ "orderType": 0,
171
+ "offset": 0,
172
+ "direction": 0,
173
+ "type": 1,
174
+ }
175
+ if orders_params:
176
+ params.update(orders_params)
177
+ tasks["orders"] = self.client.get(
178
+ f"{self.forward_api}/v1/ifcontract/userAllOrders",
179
+ params=params,
180
+ )
181
+
182
+ if include_positions:
183
+ params = {"status": 1}
184
+ if positions_params:
185
+ params.update(positions_params)
186
+ tasks["positions"] = self.client.get(
187
+ f"{self.forward_api}/v1/ifcontract/userPositions",
188
+ params=params,
189
+ )
190
+
191
+ if include_balances:
192
+ tasks["balances"] = self.client.get(
193
+ f"{self.forward_api}/v1/ifcontract/copy/trade/user/info",
194
+ )
195
+
196
+ if include_history_orders:
197
+ d_params = {"offset": 0, "status": 60, "size": 20, "type": 1}
198
+ d_params.update(orders_params or {})
199
+ tasks["history_orders"] = self.client.get(
200
+ f"{self.forward_api}/v1/ifcontract/userAllOrders",
201
+ params=d_params,
202
+ )
203
+
204
+ if not tasks:
205
+ raise ValueError(f"Unsupported update_type: {update_type}")
206
+
207
+ results: dict[str, Any] = {}
208
+ for key, req in tasks.items():
209
+ res = await req
210
+ if res.content_type and "json" in res.content_type:
211
+ results[key] = await res.json()
212
+ else:
213
+ text = await res.text()
214
+ try:
215
+ results[key] = json.loads(text)
216
+ except json.JSONDecodeError as exc:
217
+ raise ValueError(
218
+ f"Unexpected response format for {key}: {res.content_type} {text[:200]}"
219
+ ) from exc
220
+
221
+ if "detail" in results:
222
+ resp = results["detail"]
223
+ if resp.get("success") is False or resp.get("errno") not in (None, "OK"):
224
+ raise ValueError(f"Bitmart detail API error: {resp}")
225
+ self.store.detail._onresponse(resp)
226
+ for entry in self.store.detail.find():
227
+ contract_id = entry.get("contract_id")
228
+ symbol = entry.get("name") or entry.get("display_name")
229
+ if contract_id is None or symbol is None:
230
+ continue
231
+ self.store.book.id_to_symbol[str(contract_id)] = str(symbol)
232
+
233
+ if "orders" in results:
234
+ resp = results["orders"]
235
+ if resp.get("success") is False or resp.get("errno") not in (None, "OK"):
236
+ raise ValueError(f"Bitmart orders API error: {resp}")
237
+ self.store.orders._onresponse(resp)
238
+
239
+ if "positions" in results:
240
+ resp = results["positions"]
241
+ if resp.get("success") is False or resp.get("errno") not in (None, "OK"):
242
+ raise ValueError(f"Bitmart positions API error: {resp}")
243
+ self.store.positions._onresponse(resp)
244
+
245
+ if "balances" in results:
246
+ resp = results["balances"]
247
+ if resp.get("success") is False or resp.get("errno") not in (None, "OK"):
248
+ raise ValueError(f"Bitmart balances API error: {resp}")
249
+ self.store.balances._onresponse(resp)
250
+
251
+ if "history_orders" in results:
252
+ resp = results["history_orders"]
253
+ if resp.get("success") is False or resp.get("errno") not in (None, "OK"):
254
+ raise ValueError(f"Bitmart history_orders API error: {resp}")
255
+ self.store.orders._onresponse(resp)
256
+
257
+ async def sub_orderbook(
258
+ self,
259
+ symbols: Sequence[str] | str,
260
+ *,
261
+ depth: str = "Depth",
262
+ depth_limit: int | None = None,
263
+ ) -> pybotters.ws.WebSocketApp:
264
+ """Subscribe order book channel(s)."""
265
+
266
+ if isinstance(symbols, str):
267
+ symbols = [symbols]
268
+
269
+ if not symbols:
270
+ raise ValueError("symbols must not be empty")
271
+
272
+ missing = [sym for sym in symbols if self.get_contract_id(sym) is None]
273
+ if missing:
274
+ await self.update("detail")
275
+ still_missing = [sym for sym in missing if self.get_contract_id(sym) is None]
276
+ if still_missing:
277
+ raise ValueError(f"Unknown symbols: {', '.join(still_missing)}")
278
+
279
+ if depth_limit is not None:
280
+ self.store.book.limit = depth_limit
281
+
282
+ channels: list[str] = []
283
+ for symbol in symbols:
284
+ contract_id = self.get_contract_id(symbol)
285
+ if contract_id is None:
286
+ continue
287
+ self.store.book.id_to_symbol[str(contract_id)] = symbol
288
+ channels.append(f"{depth}:{contract_id}")
289
+
290
+ if not channels:
291
+ raise ValueError("No channels resolved for subscription")
292
+
293
+ payload = {"action": "subscribe", "args": channels}
294
+ # print(payload)
295
+
296
+ ws_app = self.client.ws_connect(
297
+ self.ws_url,
298
+ send_json=payload,
299
+ hdlr_json=self.store.onmessage,
300
+ autoping=False,
301
+ )
302
+ await ws_app._event.wait()
303
+ return ws_app
304
+
305
+ def gen_order_id(self):
306
+ ts = int(time.time() * 1000) # 13位毫秒时间戳
307
+ rand = random.randint(100000, 999999) # 6位随机数
308
+ return int(f"{ts}{rand}")
309
+
310
+ async def place_order(
311
+ self,
312
+ symbol: str,
313
+ *,
314
+ category: Literal[1,2,"limit","market"] = "limit",
315
+ price: float,
316
+ qty: float,
317
+ way: Literal[1, 2, 3, 4, "open_long", "close_short", "close_long", "open_short", "buy", "sell"] = "open_long",
318
+ mode: Literal[1, 2, 3, 4, "gtc", "ioc", "fok", "maker_only", "maker-only", "post_only"] = "gtc",
319
+ open_type: Literal[1, 2, "cross", "isolated"] = "isolated",
320
+ leverage: int | str = 10,
321
+ reverse_vol: int | float = 0,
322
+ trigger_price: float | None = None,
323
+ custom_id: int | str | None = None,
324
+ extra_params: dict[str, Any] | None = None,
325
+ ) -> dict[str, Any]:
326
+ """Submit an order via ``submitOrder``.
327
+ """
328
+
329
+ contract_id = self.get_contract_id(symbol)
330
+ if contract_id is None:
331
+ raise ValueError(f"Unknown symbol: {symbol}")
332
+ contract_id_int = int(contract_id)
333
+
334
+ detail = self._get_detail_entry(symbol=symbol, market_index=contract_id_int)
335
+ if detail is None:
336
+ await self.update("detail")
337
+ detail = self._get_detail_entry(symbol=symbol, market_index=contract_id_int)
338
+ if detail is None:
339
+ raise ValueError(f"Market metadata unavailable for symbol: {symbol}")
340
+
341
+
342
+ contract_size_str = detail.get("contract_size") or detail.get("vol_unit") or "1"
343
+ try:
344
+ contract_size_val = float(contract_size_str)
345
+ except (TypeError, ValueError):
346
+ contract_size_val = 1.0
347
+ if contract_size_val <= 0:
348
+ raise ValueError(f"Invalid contract_size for {symbol}: {contract_size_str}")
349
+
350
+ contracts_float = float(qty) / contract_size_val
351
+ contracts_int = int(round(contracts_float))
352
+ if contracts_int <= 0:
353
+ raise ValueError(
354
+ f"qty too small for contract size ({contract_size_val}): qty={qty}"
355
+ )
356
+ if abs(contracts_float - contracts_int) > 1e-8:
357
+ raise ValueError(
358
+ f"qty must be a multiple of contract_size ({contract_size_val}). "
359
+ f"Received qty={qty} -> {contracts_float} contracts."
360
+ )
361
+
362
+ price_unit = detail.get("price_unit") or 1
363
+ adjusted_price = float(fmt_value(price, price_unit))
364
+
365
+ category = self._normalize_enum(
366
+ category,
367
+ {
368
+ "limit": 1,
369
+ "market": 2,
370
+ },
371
+ "category",
372
+ )
373
+
374
+ way_value = self._normalize_enum(
375
+ way,
376
+ {
377
+ "open_long": 1,
378
+ "close_short": 2,
379
+ "close_long": 3,
380
+ "open_short": 4,
381
+ "buy": 1,
382
+ "sell": 4,
383
+ },
384
+ "way",
385
+ )
386
+ mode_value = self._normalize_enum(
387
+ mode,
388
+ {
389
+ "gtc": 1,
390
+ "fok": 2,
391
+ "ioc": 3,
392
+ "maker_only": 4,
393
+ "maker-only": 4,
394
+ "post_only": 4,
395
+ },
396
+ "mode",
397
+ )
398
+ open_type_value = self._normalize_enum(
399
+ open_type,
400
+ {
401
+ "cross": 1,
402
+ "isolated": 2,
403
+ },
404
+ "open_type",
405
+ )
406
+
407
+ payload: dict[str, Any] = {
408
+ "place_all_order": False,
409
+ "contract_id": contract_id_int,
410
+ "category": category,
411
+ "price": adjusted_price,
412
+ "vol": contracts_int,
413
+ "way": way_value,
414
+ "mode": mode_value,
415
+ "open_type": open_type_value,
416
+ "leverage": leverage,
417
+ "reverse_vol": reverse_vol,
418
+ }
419
+
420
+ if trigger_price is not None:
421
+ payload["trigger_price"] = trigger_price
422
+
423
+ payload["custom_id"] = custom_id or self.gen_order_id()
424
+
425
+ if extra_params:
426
+ payload.update(extra_params)
427
+
428
+ # print(payload)
429
+ # exit()
430
+
431
+ res = await self.client.post(
432
+ f"{self.forward_api}/v1/ifcontract/submitOrder",
433
+ json=payload,
434
+ )
435
+ resp = await res.json()
436
+
437
+ if resp.get("success") is False or resp.get("errno") not in (None, "OK"):
438
+ raise ValueError(f"Bitmart submitOrder error: {resp}")
439
+ return resp
440
+
441
+ async def cancel_order(
442
+ self,
443
+ symbol: str,
444
+ order_ids: Sequence[int | str],
445
+ *,
446
+ nonce: int | None = None,
447
+ ) -> dict[str, Any]:
448
+ """Cancel one or multiple orders."""
449
+
450
+ contract_id = self.get_contract_id(symbol)
451
+ if contract_id is None:
452
+ raise ValueError(f"Unknown symbol: {symbol}")
453
+
454
+ payload = {
455
+ "orders": [
456
+ {
457
+ "contract_id": int(contract_id),
458
+ "orders": [int(order_id) for order_id in order_ids],
459
+ }
460
+ ],
461
+ "nonce": nonce if nonce is not None else int(time.time()),
462
+ }
463
+
464
+ res = await self.client.post(
465
+ f"{self.forward_api}/v1/ifcontract/cancelOrders",
466
+ json=payload,
467
+ )
468
+ resp = await res.json()
469
+ if resp.get("success") is False or resp.get("errno") not in (None, "OK"):
470
+ raise ValueError(f"Bitmart cancelOrders error: {resp}")
471
+ return resp
@@ -239,7 +239,7 @@ class Coinup:
239
239
  },
240
240
  }
241
241
  )
242
- print(payloads)
242
+ # print(payloads)
243
243
 
244
244
  if not payloads:
245
245
  raise ValueError("channels must not be empty")