hyperquant 1.21__py3-none-any.whl → 1.24__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.

Potentially problematic release.


This version of hyperquant might be problematic. Click here for more details.

hyperquant/broker/auth.py CHANGED
@@ -249,6 +249,102 @@ class Auth:
249
249
 
250
250
  return args
251
251
 
252
+ @staticmethod
253
+ def bitmart_api(args: tuple[str, URL], kwargs: dict[str, Any]) -> tuple[str, URL]:
254
+ """Bitmart OpenAPI (futures v2) signing for api-cloud-v2.bitmart.com.
255
+
256
+ Spec per docs:
257
+ X-BM-SIGN = hex_lower(HMAC_SHA256(secret, timestamp + '#' + memo + '#' + payload_string))
258
+ - For POST: payload_string is the JSON body string
259
+ - For GET: payload_string is the query string (if any), otherwise empty
260
+ Headers required: X-BM-KEY, X-BM-TIMESTAMP, X-BM-SIGN
261
+ """
262
+ method: str = args[0]
263
+ url: URL = args[1]
264
+ headers: CIMultiDict = kwargs["headers"]
265
+
266
+ session = kwargs["session"]
267
+ try:
268
+ api_name = pybotters.auth.Hosts.items[url.host].name
269
+ except (KeyError, AttributeError):
270
+ api_name = url.host
271
+
272
+ creds = session.__dict__.get("_apis", {}).get(api_name)
273
+ if not creds or len(creds) < 3:
274
+ raise RuntimeError("Bitmart API credentials (access_key, secret, memo) are required")
275
+
276
+ access_key = creds[0]
277
+ secret = creds[1]
278
+ memo = creds[2]
279
+ if isinstance(secret, str):
280
+ secret_bytes = secret.encode("utf-8")
281
+ else:
282
+ secret_bytes = secret
283
+ if isinstance(memo, bytes):
284
+ memo = memo.decode("utf-8")
285
+
286
+ timestamp = str(int(time.time() * 1000))
287
+ method_upper = method.upper()
288
+
289
+ # Build query string
290
+ params = kwargs.get("params")
291
+ if isinstance(params, dict) and params:
292
+ query_items = [f"{k}={v}" for k, v in params.items() if v is not None]
293
+ query_string = "&".join(query_items)
294
+ else:
295
+ query_string = url.query_string
296
+
297
+ # Build body string for signing and ensure sending matches signature
298
+ body = None
299
+ body_str = ""
300
+ if method_upper == "GET":
301
+ body_str = query_string or ""
302
+ else:
303
+ # Prefer original JSON object if present for deterministic signing
304
+ if kwargs.get("json") is not None:
305
+ body = kwargs.get("json")
306
+ else:
307
+ body = kwargs.get("data")
308
+
309
+ # If upstream already turned JSON into JsonPayload, extract its value
310
+ if isinstance(body, JsonPayload):
311
+ body_value = getattr(body, "_value", None)
312
+ else:
313
+ body_value = body
314
+
315
+ if isinstance(body_value, (dict, list)):
316
+ # Compact JSON to avoid whitespace discrepancies and sign exact bytes we send
317
+ body_str = pyjson.dumps(body_value, separators=(",", ":"), ensure_ascii=False)
318
+ kwargs["data"] = body_str
319
+ kwargs.pop("json", None)
320
+ elif isinstance(body_value, (str, bytes)):
321
+ # Sign and send exactly this string/bytes
322
+ body_str = body_value.decode("utf-8") if isinstance(body_value, bytes) else body_value
323
+ kwargs["data"] = body_str
324
+ kwargs.pop("json", None)
325
+ elif body_value is None:
326
+ body_str = ""
327
+ else:
328
+ # Fallback: string conversion (should still be JSON-like)
329
+ body_str = str(body_value)
330
+ kwargs["data"] = body_str
331
+ kwargs.pop("json", None)
332
+
333
+ # Prehash format: timestamp#memo#payload
334
+ message = f"{timestamp}#{memo}#{body_str}"
335
+ signature_hex = hmac.new(secret_bytes, message.encode("utf-8"), hashlib.sha256).hexdigest()
336
+
337
+ headers.update(
338
+ {
339
+ "X-BM-KEY": access_key,
340
+ "X-BM-TIMESTAMP": timestamp,
341
+ "X-BM-SIGN": signature_hex,
342
+ "Content-Type": "application/json; charset=UTF-8",
343
+ }
344
+ )
345
+
346
+ return args
347
+
252
348
  @staticmethod
253
349
  def coinw(args: tuple[str, URL], kwargs: dict[str, Any]) -> tuple[str, URL]:
254
350
  method: str = args[0]
@@ -348,4 +444,8 @@ pybotters.auth.Hosts.items["api.coinw.com"] = pybotters.auth.Item(
348
444
 
349
445
  pybotters.auth.Hosts.items["derivatives.bitmart.com"] = pybotters.auth.Item(
350
446
  "bitmart", Auth.bitmart
351
- )
447
+ )
448
+
449
+ pybotters.auth.Hosts.items["api-cloud-v2.bitmart.com"] = pybotters.auth.Item(
450
+ "bitmart_api", Auth.bitmart_api
451
+ )
@@ -10,6 +10,41 @@ import pybotters
10
10
 
11
11
  from .models.bitmart import BitmartDataStore
12
12
 
13
+
14
+ class Book():
15
+ def __init__(self):
16
+ self.limit: int | None = None
17
+ self.store = {}
18
+
19
+ def on_message(self, msg: dict[str, Any], ws=None) -> None:
20
+ data = msg.get("data")
21
+ if not isinstance(data, dict):
22
+ return
23
+ symbol = data.get("symbol")
24
+ self.store[symbol] = data
25
+
26
+ def find(self, query: dict[str, Any]) -> dict[str, Any] | None:
27
+ s = query.get("s")
28
+ S = query.get("S")
29
+ item = self.store.get(s)
30
+ if item:
31
+ if S == "a":
32
+ return [{"s": s, "S": "a", "p": item["asks"][0]['price'], "q": item["asks"][0]['vol']}]
33
+ elif S == "b":
34
+ return [{"s": s, "S": "b", "p": item["bids"][0]['price'], "q": item["bids"][0]['vol']}]
35
+ else:
36
+ return []
37
+
38
+ class BitmartDataStore2(BitmartDataStore):
39
+ def _init(self):
40
+ self.bk = Book()
41
+ return super()._init()
42
+
43
+ @property
44
+ def book(self) -> Book:
45
+ return self.bk
46
+
47
+
13
48
  class Bitmart:
14
49
  """Bitmart 合约交易(REST + WebSocket)。"""
15
50
 
@@ -24,17 +59,18 @@ class Bitmart:
24
59
  apis: str = None
25
60
  ) -> None:
26
61
  self.client = client
27
- self.store = BitmartDataStore()
62
+ self.store = BitmartDataStore2()
28
63
 
29
64
  self.public_api = public_api or "https://contract-v2.bitmart.com"
30
65
  self.private_api = "https://derivatives.bitmart.com"
31
66
  self.forward_api = f'{self.private_api}/gw-api/contract-tiger/forward'
32
67
  self.ws_url = ws_url or "wss://contract-ws-v2.bitmart.com/v1/ifcontract/realTime"
33
68
  self.api_ws_url = "wss://openapi-ws-v2.bitmart.com/api?protocol=1.1"
34
-
69
+ self.api_url = "https://api-cloud-v2.bitmart.com"
35
70
  self.account_index = account_index
36
71
  self.apis = apis
37
72
  self.symbol_to_contract_id: dict[str, str] = {}
73
+ self.book = Book()
38
74
 
39
75
  async def __aenter__(self) -> "Bitmart":
40
76
  await self.update("detail")
@@ -244,7 +280,6 @@ class Bitmart:
244
280
  symbol = entry.get("name") or entry.get("display_name")
245
281
  if contract_id is None or symbol is None:
246
282
  continue
247
- self.store.book.id_to_symbol[str(contract_id)] = str(symbol)
248
283
 
249
284
  if "orders" in results:
250
285
  resp = results["orders"]
@@ -280,69 +315,41 @@ class Bitmart:
280
315
  self,
281
316
  symbols: Sequence[str] | str,
282
317
  *,
283
- depth: str = "Depth",
284
318
  depth_limit: int | None = None,
285
- use_api_ws: bool = True,
286
319
  ) -> pybotters.ws.WebSocketApp:
287
320
  """Subscribe order book channel(s)."""
288
321
 
289
322
  if isinstance(symbols, str):
290
323
  symbols = [symbols]
324
+
291
325
 
292
326
  if not symbols:
293
327
  raise ValueError("symbols must not be empty")
294
328
  if depth_limit is not None:
295
329
  self.store.book.limit = depth_limit
296
- if not use_api_ws:
297
- missing = [sym for sym in symbols if self.get_contract_id(sym) is None]
298
- if missing:
299
- await self.update("detail")
300
- still_missing = [sym for sym in missing if self.get_contract_id(sym) is None]
301
- if still_missing:
302
- raise ValueError(f"Unknown symbols: {', '.join(still_missing)}")
303
-
304
-
305
-
306
- channels: list[str] = []
307
- for symbol in symbols:
308
- contract_id = self.get_contract_id(symbol)
309
- if contract_id is None:
310
- continue
311
- self.store.book.id_to_symbol[str(contract_id)] = symbol
312
- channels.append(f"{depth}:{contract_id}")
313
-
314
- if not channels:
315
- raise ValueError("No channels resolved for subscription")
316
-
317
- payload = {"action": "subscribe", "args": channels}
318
- # print(payload)
330
+
331
+ hdlr_json = self.store.book.on_message
319
332
 
320
- ws_app = self.client.ws_connect(
321
- self.api_ws_url,
322
- send_json=payload,
323
- hdlr_json=self.store.onmessage,
324
- )
325
- else:
326
- channels: list[str] = []
327
- for symbol in symbols:
328
- channels.append(f"futures/depthAll5:{symbol}@100ms")
333
+ channels: list[str] = []
334
+ for symbol in symbols:
335
+ channels.append(f"futures/depthAll5:{symbol}@100ms")
329
336
 
330
- if not channels:
331
- raise ValueError("No channels resolved for subscription")
337
+ if not channels:
338
+ raise ValueError("No channels resolved for subscription")
332
339
 
333
- payload = {"action": "subscribe", "args": channels}
334
- # print(payload)
340
+ payload = {"action": "subscribe", "args": channels}
335
341
 
336
- ws_app = self.client.ws_connect(
337
- self.api_ws_url,
338
- send_json=payload,
339
- hdlr_json=self.store.onmessage,
340
- autoping=False,
341
- )
342
+ ws_app = self.client.ws_connect(
343
+ self.api_ws_url,
344
+ send_json=payload,
345
+ hdlr_json=hdlr_json,
346
+ autoping=False,
347
+ )
342
348
 
343
349
  await ws_app._event.wait()
344
350
  return ws_app
345
351
 
352
+
346
353
  def gen_order_id(self):
347
354
  ts = int(time.time() * 1000) # 13位毫秒时间戳
348
355
  rand = random.randint(100000, 999999) # 6位随机数
@@ -364,6 +371,7 @@ class Bitmart:
364
371
  trigger_price: float | None = None,
365
372
  custom_id: int | str | None = None,
366
373
  extra_params: dict[str, Any] | None = None,
374
+ use_api: bool = False,
367
375
  ) -> int:
368
376
  """Submit an order via ``submitOrder``.
369
377
  返回值: order_id (int)
@@ -462,42 +470,124 @@ class Bitmart:
462
470
  "open_type",
463
471
  )
464
472
 
465
- payload: dict[str, Any] = {
466
- "place_all_order": False,
467
- "contract_id": contract_id_int,
468
- "category": category,
469
- "price": price_fmt,
470
- "vol": contracts_int,
471
- "way": way_value,
472
- "mode": mode_value,
473
- "open_type": open_type_value,
474
- "leverage": leverage,
475
- "reverse_vol": reverse_vol,
476
- }
473
+ if use_api:
474
+ # Official API path
475
+ order_type_str = "limit" if category == 1 else "market"
476
+ open_type_str = "cross" if open_type_value == 1 else "isolated"
477
+ client_oid = str(custom_id or self.gen_order_id())
478
+ api_payload: dict[str, Any] = {
479
+ "symbol": symbol,
480
+ "client_order_id": client_oid,
481
+ "side": way_value,
482
+ "type": order_type_str,
483
+ "mode": mode_value,
484
+ "leverage": str(leverage),
485
+ "open_type": open_type_str,
486
+ "size": int(contracts_int),
487
+ }
488
+ if order_type_str == "limit":
489
+ api_payload["price"] = price_fmt
490
+ if extra_params:
491
+ api_payload.update(extra_params)
492
+ # Ensure leverage is synchronized via official API before placing order
493
+ try:
494
+ lev_payload = {
495
+ "symbol": symbol,
496
+ "leverage": str(leverage),
497
+ "open_type": open_type_str,
498
+ }
499
+ res_lev = await self.client.post(
500
+ f"{self.api_url}/contract/private/submit-leverage",
501
+ json=lev_payload,
502
+ )
503
+ txt_lev = await res_lev.text()
504
+ try:
505
+ resp_lev = json.loads(txt_lev)
506
+ if resp_lev.get("code") != 1000:
507
+ # ignore and proceed; order may still pass
508
+ pass
509
+ except json.JSONDecodeError:
510
+ pass
511
+ await asyncio.sleep(0.05)
512
+ except Exception:
513
+ pass
514
+
515
+ res = await self.client.post(
516
+ f"{self.api_url}/contract/private/submit-order",
517
+ json=api_payload,
518
+ )
519
+ # Parse response (some errors may return text/plain containing JSON)
520
+ text = await res.text()
521
+ try:
522
+ resp = json.loads(text)
523
+ except json.JSONDecodeError:
524
+ raise ValueError(f"Bitmart API submit-order non-json response: {text[:200]}")
525
+ if resp.get("code") != 1000:
526
+ # Auto-sync leverage once if required, then retry once
527
+ if resp.get("code") in (40012,):
528
+ try:
529
+ # Retry leverage sync via official API then retry the order
530
+ lev_payload = {
531
+ "symbol": symbol,
532
+ "leverage": str(leverage),
533
+ "open_type": open_type_str,
534
+ }
535
+ await self.client.post(
536
+ f"{self.api_url}/contract/private/submit-leverage",
537
+ json=lev_payload,
538
+ )
539
+ await asyncio.sleep(0.05)
540
+ res2 = await self.client.post(
541
+ f"{self.api_url}/contract/private/submit-order",
542
+ json=api_payload,
543
+ )
544
+ text2 = await res2.text()
545
+ try:
546
+ resp2 = json.loads(text2)
547
+ except json.JSONDecodeError:
548
+ raise ValueError(
549
+ f"Bitmart API submit-order non-json response: {text2[:200]}"
550
+ )
551
+ if resp2.get("code") == 1000:
552
+ return resp2.get("data", {}).get("order_id")
553
+ else:
554
+ raise ValueError(f"Bitmart API submit-order error: {resp2}")
555
+ except Exception:
556
+ # Fall through to raise original error if sync failed
557
+ pass
558
+ raise ValueError(f"Bitmart API submit-order error: {resp}")
559
+ return resp.get("data", {}).get("order_id")
560
+ else:
561
+ payload: dict[str, Any] = {
562
+ "place_all_order": False,
563
+ "contract_id": contract_id_int,
564
+ "category": category,
565
+ "price": price_fmt,
566
+ "vol": contracts_int,
567
+ "way": way_value,
568
+ "mode": mode_value,
569
+ "open_type": open_type_value,
570
+ "leverage": leverage,
571
+ "reverse_vol": reverse_vol,
572
+ }
477
573
 
478
- if trigger_price is not None:
479
- payload["trigger_price"] = trigger_price
574
+ if trigger_price is not None:
575
+ payload["trigger_price"] = trigger_price
480
576
 
481
- payload["custom_id"] = custom_id or self.gen_order_id()
577
+ payload["custom_id"] = custom_id or self.gen_order_id()
482
578
 
483
- if extra_params:
484
- payload.update(extra_params)
485
-
486
- # print(payload)
487
- # exit()
488
-
489
- res = await self.client.post(
490
- f"{self.forward_api}/v1/ifcontract/submitOrder",
491
- json=payload,
492
- )
493
- resp = await res.json()
494
-
495
- if resp.get("success") is False or resp.get("errno") not in (None, "OK"):
496
- raise ValueError(f"Bitmart submitOrder error: {resp}")
497
-
498
- # {"errno":"OK","message":"Success","data":{"order_id":3000236525013551},"success":true}
499
- # 直接取order_id返回
500
- return resp.get("data", {}).get("order_id")
579
+ if extra_params:
580
+ payload.update(extra_params)
581
+
582
+ res = await self.client.post(
583
+ f"{self.forward_api}/v1/ifcontract/submitOrder",
584
+ json=payload,
585
+ )
586
+ resp = await res.json()
587
+
588
+ if resp.get("success") is False or resp.get("errno") not in (None, "OK"):
589
+ raise ValueError(f"Bitmart submitOrder error: {resp}")
590
+ return resp.get("data", {}).get("order_id")
501
591
 
502
592
  async def cancel_order(
503
593
  self,
@@ -9,6 +9,7 @@ import pybotters
9
9
 
10
10
  from lighter.api.account_api import AccountApi
11
11
  from lighter.api.order_api import OrderApi
12
+ from lighter.api.candlestick_api import CandlestickApi
12
13
  from lighter.api_client import ApiClient
13
14
  from lighter.configuration import Configuration
14
15
  from lighter.signer_client import SignerClient
@@ -31,6 +32,7 @@ class Lighter:
31
32
  api_key_index: int = 3,
32
33
  api_client: ApiClient | None = None,
33
34
  order_api: OrderApi | None = None,
35
+ candlestick_api: CandlestickApi | None = None,
34
36
  account_api: AccountApi | None = None,
35
37
  ws_url: str | None = None,
36
38
  ) -> None:
@@ -46,6 +48,7 @@ class Lighter:
46
48
  self._owns_api_client = api_client is None
47
49
 
48
50
  self.order_api = order_api or OrderApi(self._api_client)
51
+ self.candlestick_api = candlestick_api or CandlestickApi(self._api_client)
49
52
  self.account_api = account_api or AccountApi(self._api_client)
50
53
  self.signer: SignerClient = None
51
54
 
@@ -150,7 +153,9 @@ class Lighter:
150
153
 
151
154
 
152
155
  if include_detail:
153
- tasks.append(("detail", self.order_api.order_books()))
156
+ # Use raw HTTP to avoid strict SDK model validation issues (e.g., status 'inactive').
157
+ url = f"{self.configuration.host.rstrip('/')}/api/v1/orderBooks"
158
+ tasks.append(("detail", self.client.get(url)))
154
159
 
155
160
  if include_orders:
156
161
  if account_index is None or symbol is None:
@@ -206,7 +211,12 @@ class Lighter:
206
211
  results: dict[str, Any] = {}
207
212
  for key, coroutine in tasks:
208
213
  try:
209
- results[key] = await coroutine
214
+ resp = await coroutine
215
+ if key == "detail":
216
+ # Parse JSON body for detail endpoint
217
+ results[key] = await resp.json()
218
+ else:
219
+ results[key] = resp
210
220
  except Exception:
211
221
  logger.exception("Lighter REST request %s failed", key)
212
222
  raise
@@ -300,6 +310,71 @@ class Lighter:
300
310
 
301
311
  return await self.sub_orderbook(market_ids=[], account_ids=account_ids)
302
312
 
313
+ async def sub_kline(
314
+ self,
315
+ symbols: Sequence[str] | str,
316
+ *,
317
+ resolutions: Sequence[str] | str,
318
+ ) -> pybotters.ws.WebSocketApp:
319
+ """Subscribe to trade streams and aggregate into klines in the store.
320
+
321
+ - symbols: list of symbols (e.g., ["BTC-USD"]) or a single symbol; may also be numeric market_ids.
322
+ - resolutions: list like ["1m", "5m"] or a single resolution; added to kline store for aggregation.
323
+ """
324
+
325
+ # Normalize inputs
326
+ symbol_list = [symbols] if isinstance(symbols, str) else list(symbols)
327
+ res_list = [resolutions] if isinstance(resolutions, str) else list(resolutions)
328
+
329
+ if not symbol_list:
330
+ raise ValueError("At least one symbol must be provided")
331
+ if not res_list:
332
+ raise ValueError("At least one resolution must be provided")
333
+
334
+ # Ensure market metadata for symbol->market_id resolution
335
+ needs_detail = any(self.get_contract_id(sym) is None and not str(sym).isdigit() for sym in symbol_list)
336
+ if needs_detail:
337
+ try:
338
+ await self.update("detail")
339
+ except Exception:
340
+ logger.exception("Failed to refresh Lighter market metadata for kline subscription")
341
+ raise
342
+
343
+ # Resolve market ids and populate id->symbol mapping for klines store
344
+ trade_market_ids: list[str] = []
345
+ for sym in symbol_list:
346
+ market_id = self.get_contract_id(sym)
347
+ if market_id is None:
348
+ if str(sym).isdigit():
349
+ market_id = str(sym)
350
+ symbol_for_map = str(sym)
351
+ else:
352
+ raise ValueError(f"Unknown symbol: {sym}")
353
+ else:
354
+ symbol_for_map = sym
355
+ market_id_str = str(market_id)
356
+ trade_market_ids.append(market_id_str)
357
+ # ensure klines store can resolve symbol from market id
358
+ self.store.klines.id_to_symbol[market_id_str] = symbol_for_map
359
+
360
+ # Register resolutions into kline store aggregation list
361
+ for r in res_list:
362
+ if r not in self.store.klines._res_list:
363
+ self.store.klines._res_list.append(r)
364
+
365
+ # Build subscribe payload for trade channels
366
+ channels = [f"trade/{mid}" for mid in trade_market_ids]
367
+ send_payload = [{"type": "subscribe", "channel": ch} for ch in channels]
368
+
369
+ ws_app = self.client.ws_connect(
370
+ self.ws_url,
371
+ send_json=send_payload,
372
+ hdlr_json=self.store.onmessage,
373
+ )
374
+
375
+ await ws_app._event.wait()
376
+ return ws_app
377
+
303
378
  async def place_order(
304
379
  self,
305
380
  symbol: str,
@@ -468,3 +543,60 @@ class Lighter:
468
543
  "request": request_payload,
469
544
  "response": response_payload,
470
545
  }
546
+
547
+ async def update_kline(
548
+ self,
549
+ symbol: str,
550
+ *,
551
+ resolution: str,
552
+ start_timestamp: int,
553
+ end_timestamp: int,
554
+ count_back: int,
555
+ set_timestamp_to_end: bool | None = None,
556
+ ) -> list[dict[str, Any]]:
557
+ """Fetch candlesticks and update the Kline store.
558
+
559
+ Parameters
560
+ - symbol: market symbol, e.g. "BTC-USD".
561
+ - resolution: e.g. "1m", "5m", "1h".
562
+ - start_timestamp: epoch milliseconds.
563
+ - end_timestamp: epoch milliseconds.
564
+ - count_back: number of bars to fetch.
565
+ - set_timestamp_to_end: if True, API sets last bar timestamp to the end.
566
+ """
567
+
568
+ market_id = self.get_contract_id(symbol)
569
+ if market_id is None:
570
+ # try to refresh metadata once
571
+ await self.update("detail")
572
+ market_id = self.get_contract_id(symbol)
573
+ if market_id is None:
574
+ raise ValueError(f"Unknown symbol: {symbol}")
575
+
576
+ resp = await self.candlestick_api.candlesticks(
577
+ market_id=int(market_id),
578
+ resolution=resolution,
579
+ start_timestamp=int(start_timestamp),
580
+ end_timestamp=int(end_timestamp),
581
+ count_back=int(count_back),
582
+ set_timestamp_to_end=bool(set_timestamp_to_end) if set_timestamp_to_end is not None else None,
583
+ )
584
+
585
+ # Update store
586
+ self.store.klines._onresponse(resp, symbol=symbol, resolution=resolution)
587
+
588
+ payload = _maybe_to_dict(resp) or {}
589
+ items = payload.get("candlesticks") or []
590
+ # attach symbol/resolution to return
591
+ out: list[dict[str, Any]] = []
592
+ for it in items:
593
+ if hasattr(it, "to_dict"):
594
+ d = it.to_dict()
595
+ elif hasattr(it, "model_dump"):
596
+ d = it.model_dump()
597
+ else:
598
+ d = dict(it) if isinstance(it, dict) else {"value": it}
599
+ d["symbol"] = symbol
600
+ d["resolution"] = resolution
601
+ out.append(d)
602
+ return out