hyperquant 1.22__py3-none-any.whl → 1.25__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/lighter.py +222 -12
- hyperquant/broker/models/lighter.py +360 -0
- hyperquant/broker/ws.py +7 -0
- {hyperquant-1.22.dist-info → hyperquant-1.25.dist-info}/METADATA +1 -4
- {hyperquant-1.22.dist-info → hyperquant-1.25.dist-info}/RECORD +6 -8
- hyperquant/broker/coinup.py +0 -591
- hyperquant/broker/models/coinup.py +0 -334
- {hyperquant-1.22.dist-info → hyperquant-1.25.dist-info}/WHEEL +0 -0
hyperquant/broker/lighter.py
CHANGED
|
@@ -3,12 +3,14 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
5
|
import json
|
|
6
|
+
from decimal import Decimal, ROUND_DOWN, ROUND_HALF_UP
|
|
6
7
|
from typing import Any, Literal, Sequence
|
|
7
8
|
|
|
8
9
|
import pybotters
|
|
9
10
|
|
|
10
11
|
from lighter.api.account_api import AccountApi
|
|
11
12
|
from lighter.api.order_api import OrderApi
|
|
13
|
+
from lighter.api.candlestick_api import CandlestickApi
|
|
12
14
|
from lighter.api_client import ApiClient
|
|
13
15
|
from lighter.configuration import Configuration
|
|
14
16
|
from lighter.signer_client import SignerClient
|
|
@@ -31,6 +33,7 @@ class Lighter:
|
|
|
31
33
|
api_key_index: int = 3,
|
|
32
34
|
api_client: ApiClient | None = None,
|
|
33
35
|
order_api: OrderApi | None = None,
|
|
36
|
+
candlestick_api: CandlestickApi | None = None,
|
|
34
37
|
account_api: AccountApi | None = None,
|
|
35
38
|
ws_url: str | None = None,
|
|
36
39
|
) -> None:
|
|
@@ -46,6 +49,7 @@ class Lighter:
|
|
|
46
49
|
self._owns_api_client = api_client is None
|
|
47
50
|
|
|
48
51
|
self.order_api = order_api or OrderApi(self._api_client)
|
|
52
|
+
self.candlestick_api = candlestick_api or CandlestickApi(self._api_client)
|
|
49
53
|
self.account_api = account_api or AccountApi(self._api_client)
|
|
50
54
|
self.signer: SignerClient = None
|
|
51
55
|
|
|
@@ -150,7 +154,9 @@ class Lighter:
|
|
|
150
154
|
|
|
151
155
|
|
|
152
156
|
if include_detail:
|
|
153
|
-
|
|
157
|
+
# Use raw HTTP to avoid strict SDK model validation issues (e.g., status 'inactive').
|
|
158
|
+
url = f"{self.configuration.host.rstrip('/')}/api/v1/orderBooks"
|
|
159
|
+
tasks.append(("detail", self.client.get(url)))
|
|
154
160
|
|
|
155
161
|
if include_orders:
|
|
156
162
|
if account_index is None or symbol is None:
|
|
@@ -206,7 +212,12 @@ class Lighter:
|
|
|
206
212
|
results: dict[str, Any] = {}
|
|
207
213
|
for key, coroutine in tasks:
|
|
208
214
|
try:
|
|
209
|
-
|
|
215
|
+
resp = await coroutine
|
|
216
|
+
if key == "detail":
|
|
217
|
+
# Parse JSON body for detail endpoint
|
|
218
|
+
results[key] = await resp.json()
|
|
219
|
+
else:
|
|
220
|
+
results[key] = resp
|
|
210
221
|
except Exception:
|
|
211
222
|
logger.exception("Lighter REST request %s failed", key)
|
|
212
223
|
raise
|
|
@@ -292,13 +303,104 @@ class Lighter:
|
|
|
292
303
|
await ws_app._event.wait()
|
|
293
304
|
return ws_app
|
|
294
305
|
|
|
295
|
-
|
|
306
|
+
|
|
307
|
+
async def sub_orders(
|
|
308
|
+
self,
|
|
309
|
+
account_ids: Sequence[int] | int = None,
|
|
310
|
+
) -> pybotters.ws.WebSocketApp:
|
|
311
|
+
"""Subscribe to order updates via Account All Orders stream.
|
|
312
|
+
|
|
313
|
+
Channel per docs: "account_all_orders/{ACCOUNT_ID}" (requires auth).
|
|
314
|
+
Response carries an "orders" mapping of market_id -> [Order].
|
|
315
|
+
"""
|
|
316
|
+
if account_ids:
|
|
317
|
+
if isinstance(account_ids, int):
|
|
318
|
+
account_id_list = [str(account_ids)]
|
|
319
|
+
else:
|
|
320
|
+
account_id_list = [str(aid) for aid in account_ids]
|
|
321
|
+
else:
|
|
322
|
+
account_id_list = [self.account_index]
|
|
323
|
+
|
|
324
|
+
channels = [f"account_all_orders/{aid}" for aid in account_id_list]
|
|
325
|
+
send_payload = [
|
|
326
|
+
{"type": "subscribe", "channel": channel, "auth": self.auth}
|
|
327
|
+
for channel in channels
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
ws_app = self.client.ws_connect(
|
|
331
|
+
self.ws_url,
|
|
332
|
+
send_json=send_payload,
|
|
333
|
+
hdlr_json=self.store.onmessage,
|
|
334
|
+
)
|
|
335
|
+
await ws_app._event.wait()
|
|
336
|
+
return ws_app
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
async def sub_kline(
|
|
296
341
|
self,
|
|
297
|
-
|
|
342
|
+
symbols: Sequence[str] | str,
|
|
343
|
+
*,
|
|
344
|
+
resolutions: Sequence[str] | str,
|
|
298
345
|
) -> pybotters.ws.WebSocketApp:
|
|
299
|
-
"""Subscribe to
|
|
346
|
+
"""Subscribe to trade streams and aggregate into klines in the store.
|
|
300
347
|
|
|
301
|
-
|
|
348
|
+
- symbols: list of symbols (e.g., ["BTC-USD"]) or a single symbol; may also be numeric market_ids.
|
|
349
|
+
- resolutions: list like ["1m", "5m"] or a single resolution; added to kline store for aggregation.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
# Normalize inputs
|
|
353
|
+
symbol_list = [symbols] if isinstance(symbols, str) else list(symbols)
|
|
354
|
+
res_list = [resolutions] if isinstance(resolutions, str) else list(resolutions)
|
|
355
|
+
|
|
356
|
+
if not symbol_list:
|
|
357
|
+
raise ValueError("At least one symbol must be provided")
|
|
358
|
+
if not res_list:
|
|
359
|
+
raise ValueError("At least one resolution must be provided")
|
|
360
|
+
|
|
361
|
+
# Ensure market metadata for symbol->market_id resolution
|
|
362
|
+
needs_detail = any(self.get_contract_id(sym) is None and not str(sym).isdigit() for sym in symbol_list)
|
|
363
|
+
if needs_detail:
|
|
364
|
+
try:
|
|
365
|
+
await self.update("detail")
|
|
366
|
+
except Exception:
|
|
367
|
+
logger.exception("Failed to refresh Lighter market metadata for kline subscription")
|
|
368
|
+
raise
|
|
369
|
+
|
|
370
|
+
# Resolve market ids and populate id->symbol mapping for klines store
|
|
371
|
+
trade_market_ids: list[str] = []
|
|
372
|
+
for sym in symbol_list:
|
|
373
|
+
market_id = self.get_contract_id(sym)
|
|
374
|
+
if market_id is None:
|
|
375
|
+
if str(sym).isdigit():
|
|
376
|
+
market_id = str(sym)
|
|
377
|
+
symbol_for_map = str(sym)
|
|
378
|
+
else:
|
|
379
|
+
raise ValueError(f"Unknown symbol: {sym}")
|
|
380
|
+
else:
|
|
381
|
+
symbol_for_map = sym
|
|
382
|
+
market_id_str = str(market_id)
|
|
383
|
+
trade_market_ids.append(market_id_str)
|
|
384
|
+
# ensure klines store can resolve symbol from market id
|
|
385
|
+
self.store.klines.id_to_symbol[market_id_str] = symbol_for_map
|
|
386
|
+
|
|
387
|
+
# Register resolutions into kline store aggregation list
|
|
388
|
+
for r in res_list:
|
|
389
|
+
if r not in self.store.klines._res_list:
|
|
390
|
+
self.store.klines._res_list.append(r)
|
|
391
|
+
|
|
392
|
+
# Build subscribe payload for trade channels
|
|
393
|
+
channels = [f"trade/{mid}" for mid in trade_market_ids]
|
|
394
|
+
send_payload = [{"type": "subscribe", "channel": ch} for ch in channels]
|
|
395
|
+
|
|
396
|
+
ws_app = self.client.ws_connect(
|
|
397
|
+
self.ws_url,
|
|
398
|
+
send_json=send_payload,
|
|
399
|
+
hdlr_json=self.store.onmessage,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
await ws_app._event.wait()
|
|
403
|
+
return ws_app
|
|
302
404
|
|
|
303
405
|
async def place_order(
|
|
304
406
|
self,
|
|
@@ -366,30 +468,81 @@ class Lighter:
|
|
|
366
468
|
except KeyError as exc:
|
|
367
469
|
raise ValueError(f"Unsupported time_in_force: {time_in_force}") from exc
|
|
368
470
|
|
|
369
|
-
|
|
471
|
+
# Per WS/API docs, OrderExpiry can be 0 with ExpiredAt computed by signer.
|
|
472
|
+
# Use caller-provided value if given; otherwise default to 0 to avoid
|
|
473
|
+
# "OrderExpiry is invalid" errors on some markets.
|
|
474
|
+
expiry = order_expiry if order_expiry is not None else 0
|
|
370
475
|
nonce_value = nonce if nonce is not None else -1
|
|
371
476
|
api_key_idx = api_key_index if api_key_index is not None else self.api_key_index
|
|
372
477
|
|
|
478
|
+
# ----- Precision and min constraints handling -----
|
|
479
|
+
# Prefer explicitly supported decimals. Avoid using quote decimals to infer size.
|
|
373
480
|
price_decimals = (
|
|
374
481
|
detail.get("supported_price_decimals")
|
|
375
482
|
or detail.get("price_decimals")
|
|
376
|
-
or detail.get("quote_decimals")
|
|
377
483
|
or 0
|
|
378
484
|
)
|
|
379
485
|
size_decimals = (
|
|
380
486
|
detail.get("supported_size_decimals")
|
|
381
487
|
or detail.get("size_decimals")
|
|
382
|
-
or detail.get("supported_quote_decimals")
|
|
383
488
|
or 0
|
|
384
489
|
)
|
|
385
490
|
|
|
491
|
+
# Optional constraints provided by the API
|
|
492
|
+
# Strings like "10.000000" may be returned – normalize via Decimal for accuracy
|
|
493
|
+
def _to_decimal(v, default: str | int = 0):
|
|
494
|
+
try:
|
|
495
|
+
if v is None or v == "":
|
|
496
|
+
return Decimal(str(default))
|
|
497
|
+
return Decimal(str(v))
|
|
498
|
+
except Exception:
|
|
499
|
+
return Decimal(str(default))
|
|
500
|
+
|
|
501
|
+
min_base_amount = _to_decimal(detail.get("min_base_amount"), 0)
|
|
502
|
+
min_quote_amount = _to_decimal(detail.get("min_quote_amount"), 0)
|
|
503
|
+
order_quote_limit = _to_decimal(detail.get("order_quote_limit"), 0)
|
|
504
|
+
|
|
505
|
+
# Use Decimal for precise arithmetic and quantization
|
|
506
|
+
d_price = Decimal(str(price))
|
|
507
|
+
d_size = Decimal(str(base_amount))
|
|
508
|
+
quant_price = Decimal(1) / (Decimal(10) ** int(price_decimals)) if int(price_decimals) > 0 else Decimal(1)
|
|
509
|
+
quant_size = Decimal(1) / (Decimal(10) ** int(size_decimals)) if int(size_decimals) > 0 else Decimal(1)
|
|
510
|
+
|
|
511
|
+
# Round price/size to allowed decimals (half up to the nearest tick)
|
|
512
|
+
d_price = d_price.quantize(quant_price, rounding=ROUND_HALF_UP)
|
|
513
|
+
d_size = d_size.quantize(quant_size, rounding=ROUND_HALF_UP)
|
|
514
|
+
|
|
515
|
+
# Ensure minimum notional and minimum base constraints
|
|
516
|
+
# If violating, adjust size upward to the smallest valid amount respecting size tick
|
|
517
|
+
if min_quote_amount > 0:
|
|
518
|
+
notional = d_price * d_size
|
|
519
|
+
if notional < min_quote_amount:
|
|
520
|
+
# required size to reach min notional
|
|
521
|
+
required = (min_quote_amount / d_price).quantize(quant_size, rounding=ROUND_HALF_UP)
|
|
522
|
+
if required > d_size:
|
|
523
|
+
d_size = required
|
|
524
|
+
if min_base_amount > 0 and d_size < min_base_amount:
|
|
525
|
+
d_size = min_base_amount.quantize(quant_size, rounding=ROUND_HALF_UP)
|
|
526
|
+
|
|
527
|
+
# Respect optional maximum notional limit if provided (>0)
|
|
528
|
+
if order_quote_limit and order_quote_limit > 0:
|
|
529
|
+
notional = d_price * d_size
|
|
530
|
+
if notional > order_quote_limit:
|
|
531
|
+
# Reduce size down to the maximum allowed notional (floor to tick)
|
|
532
|
+
max_size = (order_quote_limit / d_price).quantize(quant_size, rounding=ROUND_DOWN)
|
|
533
|
+
if max_size <= 0:
|
|
534
|
+
raise ValueError("order would exceed order_quote_limit and cannot be reduced to a positive size")
|
|
535
|
+
d_size = max_size
|
|
536
|
+
|
|
537
|
+
# Convert to integer representation expected by signer
|
|
386
538
|
price_scale = 10 ** int(price_decimals)
|
|
387
539
|
size_scale = 10 ** int(size_decimals)
|
|
388
540
|
|
|
389
|
-
price_int = int(
|
|
390
|
-
base_amount_int = int(
|
|
541
|
+
price_int = int((d_price * price_scale).to_integral_value(rounding=ROUND_HALF_UP))
|
|
542
|
+
base_amount_int = int((d_size * size_scale).to_integral_value(rounding=ROUND_HALF_UP))
|
|
543
|
+
|
|
391
544
|
trigger_price_int = (
|
|
392
|
-
int(
|
|
545
|
+
int((Decimal(str(trigger_price)) * price_scale).to_integral_value(rounding=ROUND_HALF_UP))
|
|
393
546
|
if trigger_price is not None
|
|
394
547
|
else self.signer.NIL_TRIGGER_PRICE
|
|
395
548
|
)
|
|
@@ -468,3 +621,60 @@ class Lighter:
|
|
|
468
621
|
"request": request_payload,
|
|
469
622
|
"response": response_payload,
|
|
470
623
|
}
|
|
624
|
+
|
|
625
|
+
async def update_kline(
|
|
626
|
+
self,
|
|
627
|
+
symbol: str,
|
|
628
|
+
*,
|
|
629
|
+
resolution: str,
|
|
630
|
+
start_timestamp: int,
|
|
631
|
+
end_timestamp: int,
|
|
632
|
+
count_back: int,
|
|
633
|
+
set_timestamp_to_end: bool | None = None,
|
|
634
|
+
) -> list[dict[str, Any]]:
|
|
635
|
+
"""Fetch candlesticks and update the Kline store.
|
|
636
|
+
|
|
637
|
+
Parameters
|
|
638
|
+
- symbol: market symbol, e.g. "BTC-USD".
|
|
639
|
+
- resolution: e.g. "1m", "5m", "1h".
|
|
640
|
+
- start_timestamp: epoch milliseconds.
|
|
641
|
+
- end_timestamp: epoch milliseconds.
|
|
642
|
+
- count_back: number of bars to fetch.
|
|
643
|
+
- set_timestamp_to_end: if True, API sets last bar timestamp to the end.
|
|
644
|
+
"""
|
|
645
|
+
|
|
646
|
+
market_id = self.get_contract_id(symbol)
|
|
647
|
+
if market_id is None:
|
|
648
|
+
# try to refresh metadata once
|
|
649
|
+
await self.update("detail")
|
|
650
|
+
market_id = self.get_contract_id(symbol)
|
|
651
|
+
if market_id is None:
|
|
652
|
+
raise ValueError(f"Unknown symbol: {symbol}")
|
|
653
|
+
|
|
654
|
+
resp = await self.candlestick_api.candlesticks(
|
|
655
|
+
market_id=int(market_id),
|
|
656
|
+
resolution=resolution,
|
|
657
|
+
start_timestamp=int(start_timestamp),
|
|
658
|
+
end_timestamp=int(end_timestamp),
|
|
659
|
+
count_back=int(count_back),
|
|
660
|
+
set_timestamp_to_end=bool(set_timestamp_to_end) if set_timestamp_to_end is not None else None,
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
# Update store
|
|
664
|
+
self.store.klines._onresponse(resp, symbol=symbol, resolution=resolution)
|
|
665
|
+
|
|
666
|
+
payload = _maybe_to_dict(resp) or {}
|
|
667
|
+
items = payload.get("candlesticks") or []
|
|
668
|
+
# attach symbol/resolution to return
|
|
669
|
+
out: list[dict[str, Any]] = []
|
|
670
|
+
for it in items:
|
|
671
|
+
if hasattr(it, "to_dict"):
|
|
672
|
+
d = it.to_dict()
|
|
673
|
+
elif hasattr(it, "model_dump"):
|
|
674
|
+
d = it.model_dump()
|
|
675
|
+
else:
|
|
676
|
+
d = dict(it) if isinstance(it, dict) else {"value": it}
|
|
677
|
+
d["symbol"] = symbol
|
|
678
|
+
d["resolution"] = resolution
|
|
679
|
+
out.append(d)
|
|
680
|
+
return out
|