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 +101 -1
- hyperquant/broker/bitmart.py +170 -80
- hyperquant/broker/lighter.py +134 -2
- hyperquant/broker/models/bitmart.py +14 -25
- hyperquant/broker/models/lighter.py +301 -0
- hyperquant/broker/ws.py +7 -0
- {hyperquant-1.21.dist-info → hyperquant-1.24.dist-info}/METADATA +2 -4
- {hyperquant-1.21.dist-info → hyperquant-1.24.dist-info}/RECORD +9 -11
- hyperquant/broker/coinup.py +0 -591
- hyperquant/broker/models/coinup.py +0 -334
- {hyperquant-1.21.dist-info → hyperquant-1.24.dist-info}/WHEEL +0 -0
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
|
+
)
|
hyperquant/broker/bitmart.py
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
331
|
-
|
|
337
|
+
if not channels:
|
|
338
|
+
raise ValueError("No channels resolved for subscription")
|
|
332
339
|
|
|
333
|
-
|
|
334
|
-
# print(payload)
|
|
340
|
+
payload = {"action": "subscribe", "args": channels}
|
|
335
341
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
"
|
|
468
|
-
"
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
479
|
-
|
|
574
|
+
if trigger_price is not None:
|
|
575
|
+
payload["trigger_price"] = trigger_price
|
|
480
576
|
|
|
481
|
-
|
|
577
|
+
payload["custom_id"] = custom_id or self.gen_order_id()
|
|
482
578
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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,
|
hyperquant/broker/lighter.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|