hyperquant 0.93__py3-none-any.whl → 0.95__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.
@@ -0,0 +1,470 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import json
6
+ from typing import Any, Literal, Sequence
7
+
8
+ import pybotters
9
+
10
+ from lighter.api.account_api import AccountApi
11
+ from lighter.api.order_api import OrderApi
12
+ from lighter.api_client import ApiClient
13
+ from lighter.configuration import Configuration
14
+ from lighter.signer_client import SignerClient
15
+
16
+ from .models.lighter import LighterDataStore, _maybe_to_dict
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class Lighter:
22
+ """Lighter exchange client (REST + WebSocket) built on top of the official SDK."""
23
+
24
+ def __init__(
25
+ self,
26
+ client: pybotters.Client,
27
+ *,
28
+ configuration: Configuration | None = None,
29
+ l1_address: str | None = None,
30
+ secret: str | None = None,
31
+ api_key_index: int = 3,
32
+ api_client: ApiClient | None = None,
33
+ order_api: OrderApi | None = None,
34
+ account_api: AccountApi | None = None,
35
+ ws_url: str | None = None,
36
+ ) -> None:
37
+ self.client = client
38
+ self.store = LighterDataStore()
39
+ self.l1_address = l1_address
40
+ self.account_index: int | None = None
41
+ self.secret:str = secret
42
+ self.api_key_index = api_key_index
43
+
44
+ self.configuration = configuration or Configuration.get_default()
45
+ self._api_client = api_client or ApiClient(configuration=self.configuration)
46
+ self._owns_api_client = api_client is None
47
+
48
+ self.order_api = order_api or OrderApi(self._api_client)
49
+ self.account_api = account_api or AccountApi(self._api_client)
50
+ self.signer: SignerClient = None
51
+
52
+ base_host = self.configuration.host.rstrip("/")
53
+ default_ws_url = f"{base_host.replace('https://', 'wss://')}/stream"
54
+ self.ws_url = ws_url or default_ws_url
55
+ self.id_to_symbol: dict[str, str] = {}
56
+
57
+ async def __aenter__(self) -> "Lighter":
58
+ await self.update("detail")
59
+
60
+ # 设置id_to_symbol映射
61
+ for detail in self.store.detail.find():
62
+ market_id = detail.get("market_id")
63
+ symbol = detail.get("symbol")
64
+ if market_id is not None and symbol is not None:
65
+ self.id_to_symbol[str(market_id)] = symbol
66
+
67
+ self.store.set_id_to_symbol(self.id_to_symbol)
68
+
69
+ # 尝试自动设置account_index
70
+ if self.l1_address is not None:
71
+ subact = await self.account_api.accounts_by_l1_address(
72
+ l1_address=self.l1_address
73
+ )
74
+ self.account_index = subact.sub_accounts[0].index
75
+
76
+ if self.secret:
77
+
78
+ self.signer = SignerClient(
79
+ url=self.configuration.host,
80
+ private_key=self.secret,
81
+ account_index=self.account_index if self.account_index is not None else -1,
82
+ api_key_index=self.api_key_index,
83
+ )
84
+
85
+ return self
86
+
87
+ async def __aexit__(self, exc_type, exc, tb) -> None:
88
+ await self.aclose()
89
+
90
+ async def aclose(self) -> None:
91
+ if self._owns_api_client:
92
+ await self._api_client.close()
93
+
94
+ @property
95
+ def auth(self):
96
+ if not self.signer:
97
+ raise RuntimeError("SignerClient is required for auth token generation")
98
+ auth, err = self.signer.create_auth_token_with_expiry(SignerClient.DEFAULT_10_MIN_AUTH_EXPIRY)
99
+ if err is not None:
100
+ raise Exception(err)
101
+ return auth
102
+
103
+ def get_contract_id(self, symbol: str) -> str | None:
104
+ """Helper that resolves a symbol to its `market_id`."""
105
+ detail = self.store.detail.get({"symbol": symbol}) or self.store.detail.get({"market_id": symbol})
106
+ if not detail:
107
+ return None
108
+ market_id = detail.get("market_id")
109
+ if market_id is None and detail.get("order_book_index") is not None:
110
+ market_id = detail["order_book_index"]
111
+ return str(market_id) if market_id is not None else None
112
+
113
+ def _get_detail_entry(self, symbol: str | None = None, market_index: int | None = None) -> dict[str, Any] | None:
114
+ if symbol:
115
+ entry = self.store.detail.get({"symbol": symbol})
116
+ if entry:
117
+ return entry
118
+
119
+ if market_index is not None:
120
+ entries = self.store.detail.find({"market_id": market_index})
121
+ if entries:
122
+ return entries[0]
123
+
124
+ return None
125
+
126
+ async def update(
127
+ self,
128
+ update_type: Literal[
129
+ "detail",
130
+ "orders",
131
+ "history_order",
132
+ "history_orders",
133
+ "account",
134
+ "positions",
135
+ "all",
136
+ ] = "all",
137
+ *,
138
+ symbol: str | None = None,
139
+ limit: int = 50,
140
+ ) -> None:
141
+ """Refresh cached data via Lighter REST endpoints."""
142
+
143
+ tasks: list[tuple[str, Any]] = []
144
+
145
+ include_detail = update_type in {"detail", "all"}
146
+ include_orders = update_type in {"orders", "all"}
147
+ include_history = update_type in {"history_order", "history_orders", "all"}
148
+ include_account = update_type in {"account", "positions", "all"}
149
+ account_index = self.account_index
150
+
151
+
152
+ if include_detail:
153
+ tasks.append(("detail", self.order_api.order_books()))
154
+
155
+ if include_orders:
156
+ if account_index is None or symbol is None:
157
+ if update_type == "orders":
158
+ raise ValueError("account_index and symbol are required to update orders")
159
+ else:
160
+ cid = self.get_contract_id(symbol)
161
+ tasks.append(
162
+ (
163
+ "orders",
164
+ self.order_api.account_active_orders(
165
+ account_index=account_index,
166
+ market_id=int(cid),
167
+ auth=self.auth
168
+ ),
169
+ )
170
+ )
171
+
172
+ if include_history:
173
+ if account_index is None:
174
+ raise ValueError("account_index is required to update history orders")
175
+ else:
176
+ tasks.append(
177
+ (
178
+ "history_orders",
179
+ self.order_api.account_inactive_orders(
180
+ account_index=account_index,
181
+ limit=limit,
182
+ auth=self.auth
183
+ ),
184
+ )
185
+ )
186
+
187
+ if include_account:
188
+ if account_index is None:
189
+ if update_type in {"account", "positions"}:
190
+ raise ValueError("account_index is required to update account data")
191
+ else:
192
+ tasks.append(
193
+ (
194
+ "account",
195
+ self.account_api.account(
196
+ by="index",
197
+ value=str(account_index),
198
+ ),
199
+ )
200
+ )
201
+
202
+ if not tasks:
203
+ logger.debug("No REST requests enqueued for Lighter update_type=%s", update_type)
204
+ return
205
+
206
+ results: dict[str, Any] = {}
207
+ for key, coroutine in tasks:
208
+ try:
209
+ results[key] = await coroutine
210
+ except Exception:
211
+ logger.exception("Lighter REST request %s failed", key)
212
+ raise
213
+
214
+ if "detail" in results:
215
+ self.store.detail._onresponse(results["detail"])
216
+
217
+ if "orders" in results:
218
+ self.store.orders._onresponse(results["orders"])
219
+
220
+ if "history_orders" in results:
221
+ self.store.orders._onresponse(results["history_orders"])
222
+
223
+ if "account" in results:
224
+ account_payload = results["account"]
225
+ self.store.accounts._onresponse(account_payload)
226
+ self.store.positions._onresponse(account_payload)
227
+
228
+ async def sub_orderbook(
229
+ self,
230
+ symbols: Sequence[str] | str,
231
+ *,
232
+ account_ids: Sequence[int] | int | None = None,
233
+ depth_limit: int | None = None,
234
+ ) -> pybotters.ws.WebSocketApp:
235
+ """Subscribe to order book (and optional account) websocket streams by symbol."""
236
+
237
+ if isinstance(symbols, str):
238
+ symbol_list = [symbols]
239
+ else:
240
+ symbol_list = list(symbols)
241
+
242
+ if not symbol_list and not account_ids:
243
+ raise ValueError("At least one symbol or account_id must be provided")
244
+
245
+ needs_detail = any(self.get_contract_id(sym) is None for sym in symbol_list)
246
+ if needs_detail and symbol_list:
247
+ try:
248
+ await self.update("detail")
249
+ except Exception:
250
+ logger.exception("Failed to refresh Lighter market metadata for symbol resolution")
251
+ raise
252
+
253
+ order_book_ids: list[str] = []
254
+ for sym in symbol_list:
255
+ market_id = self.get_contract_id(sym)
256
+ if market_id is None:
257
+ if sym.isdigit():
258
+ market_id = sym
259
+ else:
260
+ raise ValueError(f"Unknown symbol: {sym}")
261
+ market_id_str = str(market_id)
262
+ order_book_ids.append(market_id_str)
263
+ self.store.book.id_to_symbol[market_id_str] = sym
264
+
265
+ account_id_list: list[str] = []
266
+ if account_ids is not None:
267
+ if isinstance(account_ids, int):
268
+ account_id_list = [str(account_ids)]
269
+ else:
270
+ account_id_list = [str(aid) for aid in account_ids]
271
+
272
+ if not order_book_ids and not account_id_list:
273
+ raise ValueError("No valid symbols or account_ids resolved for subscription")
274
+
275
+ if depth_limit is not None:
276
+ self.store.book.limit = depth_limit
277
+
278
+ order_book_channels = [f"order_book/{mid}" for mid in order_book_ids]
279
+ account_channels = [f"account_all/{aid}" for aid in account_id_list]
280
+
281
+ send_payload = [
282
+ {"type": "subscribe", "channel": channel} for channel in order_book_channels + account_channels
283
+ ]
284
+
285
+ ws_app = self.client.ws_connect(
286
+ self.ws_url,
287
+ send_json=send_payload,
288
+ hdlr_json=self.store.onmessage,
289
+ autoping=False,
290
+ )
291
+
292
+ await ws_app._event.wait()
293
+ return ws_app
294
+
295
+ async def sub_accounts(
296
+ self,
297
+ account_ids: Sequence[int] | int,
298
+ ) -> pybotters.ws.WebSocketApp:
299
+ """Subscribe to account-only websocket updates."""
300
+
301
+ return await self.sub_orderbook(market_ids=[], account_ids=account_ids)
302
+
303
+ async def place_order(
304
+ self,
305
+ symbol: str,
306
+ *,
307
+ base_amount: float,
308
+ price: float,
309
+ is_ask: bool,
310
+ order_type: Literal[
311
+ "limit",
312
+ "market",
313
+ "stop-loss",
314
+ "stop-loss-limit",
315
+ "take-profit",
316
+ "take-profit-limit",
317
+ "twap",
318
+ ] = "limit",
319
+ time_in_force: Literal["ioc", "gtc", "post_only"] = "gtc",
320
+ reduce_only: bool = False,
321
+ trigger_price: float | None = None,
322
+ order_expiry: int | None = None,
323
+ nonce: int | None = None,
324
+ api_key_index: int | None = None,
325
+ client_order_index: int = 0,
326
+ ) -> dict[str, Any]:
327
+ """Submit an order through the signer client using human-readable inputs."""
328
+
329
+ if self.signer is None:
330
+ raise RuntimeError("SignerClient is required for placing orders")
331
+
332
+ market_index = self.get_contract_id(symbol)
333
+ if market_index is None:
334
+ raise ValueError(f"Unknown symbol: {symbol}")
335
+ market_index = int(market_index)
336
+
337
+ detail = self._get_detail_entry(symbol=symbol, market_index=market_index)
338
+ if detail is None:
339
+ await self.update("detail")
340
+ detail = self._get_detail_entry(symbol=symbol, market_index=market_index)
341
+ if detail is None:
342
+ raise ValueError(f"Market metadata unavailable for symbol: {symbol}")
343
+
344
+ order_type_map = {
345
+ "limit": self.signer.ORDER_TYPE_LIMIT,
346
+ "market": self.signer.ORDER_TYPE_MARKET,
347
+ "stop-loss": self.signer.ORDER_TYPE_STOP_LOSS,
348
+ "stop-loss-limit": self.signer.ORDER_TYPE_STOP_LOSS_LIMIT,
349
+ "take-profit": self.signer.ORDER_TYPE_TAKE_PROFIT,
350
+ "take-profit-limit": self.signer.ORDER_TYPE_TAKE_PROFIT_LIMIT,
351
+ "twap": self.signer.ORDER_TYPE_TWAP,
352
+ }
353
+ tif_map = {
354
+ "ioc": self.signer.ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL,
355
+ "gtc": self.signer.ORDER_TIME_IN_FORCE_GOOD_TILL_TIME,
356
+ "post_only": self.signer.ORDER_TIME_IN_FORCE_POST_ONLY,
357
+ }
358
+
359
+ try:
360
+ order_type_code = order_type_map[order_type]
361
+ except KeyError as exc:
362
+ raise ValueError(f"Unsupported order_type: {order_type}") from exc
363
+
364
+ try:
365
+ tif_code = tif_map[time_in_force]
366
+ except KeyError as exc:
367
+ raise ValueError(f"Unsupported time_in_force: {time_in_force}") from exc
368
+
369
+ expiry = order_expiry if order_expiry is not None else self.signer.DEFAULT_28_DAY_ORDER_EXPIRY
370
+ nonce_value = nonce if nonce is not None else -1
371
+ api_key_idx = api_key_index if api_key_index is not None else self.api_key_index
372
+
373
+ price_decimals = (
374
+ detail.get("supported_price_decimals")
375
+ or detail.get("price_decimals")
376
+ or detail.get("quote_decimals")
377
+ or 0
378
+ )
379
+ size_decimals = (
380
+ detail.get("supported_size_decimals")
381
+ or detail.get("size_decimals")
382
+ or detail.get("supported_quote_decimals")
383
+ or 0
384
+ )
385
+
386
+ price_scale = 10 ** int(price_decimals)
387
+ size_scale = 10 ** int(size_decimals)
388
+
389
+ price_int = int(round(float(price) * price_scale))
390
+ base_amount_int = int(round(float(base_amount) * size_scale))
391
+ trigger_price_int = (
392
+ int(round(float(trigger_price) * price_scale))
393
+ if trigger_price is not None
394
+ else self.signer.NIL_TRIGGER_PRICE
395
+ )
396
+
397
+ created_tx, response, error = await self.signer.create_order(
398
+ market_index=market_index,
399
+ client_order_index=client_order_index,
400
+ base_amount=base_amount_int,
401
+ price=price_int,
402
+ is_ask=is_ask,
403
+ order_type=order_type_code,
404
+ time_in_force=tif_code,
405
+ reduce_only=reduce_only,
406
+ trigger_price=trigger_price_int,
407
+ order_expiry=expiry,
408
+ nonce=nonce_value,
409
+ api_key_index=api_key_idx,
410
+ )
411
+
412
+ if error:
413
+ raise RuntimeError(f"Lighter create_order failed: {error}")
414
+ if response is None:
415
+ raise RuntimeError("Lighter create_order returned no response")
416
+
417
+ if hasattr(created_tx, "to_json"):
418
+ request_payload = json.loads(created_tx.to_json())
419
+ else:
420
+ request_payload = str(created_tx)
421
+ response_payload = response.to_dict() if hasattr(response, "to_dict") else _maybe_to_dict(response)
422
+
423
+ # return {
424
+ # "request": request_payload,
425
+ # "response": response_payload,
426
+ # }
427
+ return response_payload
428
+
429
+ async def cancel_order(
430
+ self,
431
+ symbol: str,
432
+ order_index: int,
433
+ *,
434
+ nonce: int | None = None,
435
+ api_key_index: int | None = None,
436
+ ) -> dict[str, Any]:
437
+ """Cancel a single order using the signer client."""
438
+
439
+ market_index = self.get_contract_id(symbol)
440
+ if market_index is None:
441
+ raise ValueError(f"Unknown symbol: {symbol}")
442
+ market_index = int(market_index)
443
+
444
+ if self.signer is None:
445
+ raise RuntimeError("SignerClient is required for cancelling orders")
446
+
447
+ nonce_value = nonce if nonce is not None else -1
448
+ api_key_idx = api_key_index or self.api_key_index
449
+
450
+ cancel_tx, response, error = await self.signer.cancel_order(
451
+ market_index=market_index,
452
+ order_index=order_index,
453
+ nonce=nonce_value,
454
+ api_key_index=api_key_idx,
455
+ )
456
+
457
+ if error:
458
+ raise RuntimeError(f"Lighter cancel_order failed: {error}")
459
+ if response is None:
460
+ raise RuntimeError("Lighter cancel_order returned no response")
461
+
462
+ if hasattr(cancel_tx, "to_json"):
463
+ request_payload = json.loads(cancel_tx.to_json())
464
+ else:
465
+ request_payload = str(cancel_tx)
466
+ response_payload = response.to_dict() if hasattr(response, "to_dict") else _maybe_to_dict(response)
467
+ return {
468
+ "request": request_payload,
469
+ "response": response_payload,
470
+ }