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.

@@ -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
- tasks.append(("detail", self.order_api.order_books()))
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
- results[key] = await coroutine
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
- async def sub_accounts(
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
- account_ids: Sequence[int] | int,
342
+ symbols: Sequence[str] | str,
343
+ *,
344
+ resolutions: Sequence[str] | str,
298
345
  ) -> pybotters.ws.WebSocketApp:
299
- """Subscribe to account-only websocket updates."""
346
+ """Subscribe to trade streams and aggregate into klines in the store.
300
347
 
301
- return await self.sub_orderbook(market_ids=[], account_ids=account_ids)
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
- expiry = order_expiry if order_expiry is not None else self.signer.DEFAULT_28_DAY_ORDER_EXPIRY
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(round(float(price) * price_scale))
390
- base_amount_int = int(round(float(base_amount) * size_scale))
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(round(float(trigger_price) * price_scale))
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