hyperquant 1.48__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.

Files changed (42) hide show
  1. hyperquant/__init__.py +8 -0
  2. hyperquant/broker/auth.py +972 -0
  3. hyperquant/broker/bitget.py +311 -0
  4. hyperquant/broker/bitmart.py +720 -0
  5. hyperquant/broker/coinw.py +487 -0
  6. hyperquant/broker/deepcoin.py +651 -0
  7. hyperquant/broker/edgex.py +500 -0
  8. hyperquant/broker/hyperliquid.py +570 -0
  9. hyperquant/broker/lbank.py +661 -0
  10. hyperquant/broker/lib/edgex_sign.py +455 -0
  11. hyperquant/broker/lib/hpstore.py +252 -0
  12. hyperquant/broker/lib/hyper_types.py +48 -0
  13. hyperquant/broker/lib/polymarket/ctfAbi.py +721 -0
  14. hyperquant/broker/lib/polymarket/safeAbi.py +1138 -0
  15. hyperquant/broker/lib/util.py +22 -0
  16. hyperquant/broker/lighter.py +679 -0
  17. hyperquant/broker/models/apexpro.py +150 -0
  18. hyperquant/broker/models/bitget.py +359 -0
  19. hyperquant/broker/models/bitmart.py +635 -0
  20. hyperquant/broker/models/coinw.py +724 -0
  21. hyperquant/broker/models/deepcoin.py +809 -0
  22. hyperquant/broker/models/edgex.py +1053 -0
  23. hyperquant/broker/models/hyperliquid.py +284 -0
  24. hyperquant/broker/models/lbank.py +557 -0
  25. hyperquant/broker/models/lighter.py +868 -0
  26. hyperquant/broker/models/ourbit.py +1155 -0
  27. hyperquant/broker/models/polymarket.py +1071 -0
  28. hyperquant/broker/ourbit.py +550 -0
  29. hyperquant/broker/polymarket.py +2399 -0
  30. hyperquant/broker/ws.py +132 -0
  31. hyperquant/core.py +513 -0
  32. hyperquant/datavison/_util.py +18 -0
  33. hyperquant/datavison/binance.py +111 -0
  34. hyperquant/datavison/coinglass.py +237 -0
  35. hyperquant/datavison/okx.py +177 -0
  36. hyperquant/db.py +191 -0
  37. hyperquant/draw.py +1200 -0
  38. hyperquant/logkit.py +205 -0
  39. hyperquant/notikit.py +124 -0
  40. hyperquant-1.48.dist-info/METADATA +32 -0
  41. hyperquant-1.48.dist-info/RECORD +42 -0
  42. hyperquant-1.48.dist-info/WHEEL +4 -0
@@ -0,0 +1,868 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from typing import TYPE_CHECKING, Any, Iterable, Literal, Sequence
6
+
7
+ from pybotters.store import DataStore, DataStoreCollection
8
+
9
+ if TYPE_CHECKING:
10
+ from pybotters.typedefs import Item
11
+ from pybotters.ws import ClientWebSocketResponse
12
+
13
+
14
+ def _maybe_to_dict(payload: Any) -> Any:
15
+ """Convert pydantic models to dict, keeping plain dict/list untouched."""
16
+ if payload is None:
17
+ return None
18
+ if hasattr(payload, "to_dict"):
19
+ return payload.to_dict()
20
+ if hasattr(payload, "model_dump"):
21
+ return payload.model_dump()
22
+ return payload
23
+
24
+
25
+ class Book(DataStore):
26
+ """Order book snapshots sourced from Lighter websocket feeds."""
27
+
28
+ _KEYS = ["s", "S", "p"]
29
+
30
+ def _init(self) -> None:
31
+ self.limit: int | None = None
32
+ self.id_to_symbol: dict[str, str] = {} # broker设置
33
+ self._last_update: float = 0.0
34
+ self._state: dict[str, dict[str, dict[float, float]]] = {}
35
+ self._visible: dict[str, dict[str, dict[float, dict[str, Any]]]] = {}
36
+
37
+ @staticmethod
38
+ def _market_id_from_channel(channel: str | None) -> str | None:
39
+ if not channel:
40
+ return None
41
+ if ":" in channel:
42
+ return channel.split(":", 1)[1]
43
+ if "/" in channel:
44
+ return channel.split("/", 1)[1]
45
+ return channel
46
+
47
+
48
+ @staticmethod
49
+ def _make_entry(symbol: str, side: Literal["a", "b"], price: float, size: float) -> dict[str, Any]:
50
+ return {
51
+ "s": symbol,
52
+ "S": side,
53
+ "p": f"{price}",
54
+ "q": f"{abs(size)}",
55
+ }
56
+
57
+ def _on_message(self, msg: dict[str, Any]) -> None:
58
+ msg_type = msg.get("type")
59
+ if msg_type not in {"subscribed/order_book", "update/order_book"}:
60
+ return
61
+
62
+ market_id = self._market_id_from_channel(msg.get("channel"))
63
+ if market_id is None:
64
+ return
65
+
66
+ order_book = msg.get("order_book")
67
+ if not isinstance(order_book, dict):
68
+ return
69
+
70
+ state = self._state.setdefault(market_id, {"ask": {}, "bid": {}})
71
+ visible = self._visible.setdefault(market_id, {"a": {}, "b": {}})
72
+
73
+ symbol = self.id_to_symbol.get(market_id)
74
+ if symbol is None:
75
+ symbol = market_id
76
+
77
+ for side_name, updates_data in (("ask", order_book.get("asks")), ("bid", order_book.get("bids"))):
78
+ side_state = state[side_name]
79
+ if not updates_data:
80
+ continue
81
+ for level in updates_data:
82
+ price = level.get("price")
83
+ size = level.get("size") or level.get("remaining_base_amount") or level.get("base_amount")
84
+ if price is None or size is None:
85
+ continue
86
+ try:
87
+ price_val = float(price)
88
+ size_val = float(size)
89
+ except (TypeError, ValueError):
90
+ continue
91
+ if size_val <= 0:
92
+ side_state.pop(price_val, None)
93
+ else:
94
+ side_state[price_val] = size_val
95
+
96
+ def build_visible(side_name: Literal["ask", "bid"]) -> dict[float, dict[str, Any]]:
97
+ side_state = state[side_name]
98
+ reverse = side_name == "bid"
99
+ sorted_levels = sorted(side_state.items(), reverse=reverse)
100
+ if self.limit is not None:
101
+ sorted_levels = sorted_levels[: self.limit]
102
+ entry_side = "a" if side_name == "ask" else "b"
103
+ return {
104
+ price: self._make_entry(symbol, entry_side, price, size)
105
+ for price, size in sorted_levels
106
+ }
107
+
108
+ new_visible = {
109
+ "a": build_visible("ask"),
110
+ "b": build_visible("bid"),
111
+ }
112
+
113
+ removals: list[dict[str, Any]] = []
114
+ updates: list[dict[str, Any]] = []
115
+
116
+ for side_key in ("a", "b"):
117
+ prev_side = visible[side_key]
118
+ next_side = new_visible[side_key]
119
+
120
+ for price, entry in prev_side.items():
121
+ if price not in next_side:
122
+ removals.append({k: entry[k] for k in self._KEYS})
123
+
124
+ for price, entry in next_side.items():
125
+ prev_entry = prev_side.get(price)
126
+ if prev_entry is None or prev_entry.get("q") != entry.get("q"):
127
+ updates.append(entry)
128
+
129
+ if removals:
130
+ self._delete(removals)
131
+ if updates:
132
+ self._update(updates)
133
+
134
+ self._visible[market_id] = new_visible
135
+ self._last_update = time.time()
136
+
137
+ def sorted(self, query: Item | None = None, limit: int | None = None) -> dict[str, list[Item]]:
138
+ return self._sorted(
139
+ item_key="S",
140
+ item_asc_key="a",
141
+ item_desc_key="b",
142
+ sort_key="p",
143
+ query=query,
144
+ limit=limit,
145
+ )
146
+
147
+ @property
148
+ def last_update(self) -> float:
149
+ return self._last_update
150
+
151
+
152
+
153
+
154
+ class Detail(DataStore):
155
+ """Market metadata."""
156
+
157
+ _KEYS = ["symbol"]
158
+
159
+ def _onresponse(self, data: Any) -> None:
160
+ payload = _maybe_to_dict(data) or {}
161
+ order_books = payload.get("order_books") or payload.get("order_book_details") or []
162
+
163
+ if isinstance(order_books, dict):
164
+ order_books = list(order_books.values())
165
+
166
+ normalized: list[dict[str, Any]] = []
167
+ for entry in order_books or []:
168
+ if not isinstance(entry, dict):
169
+ continue
170
+ market_id = entry.get("market_id") or entry.get("id")
171
+ symbol = entry.get("symbol")
172
+ if market_id is None and symbol is None:
173
+ continue
174
+ record = dict(entry)
175
+ if market_id is None and symbol is not None:
176
+ record["market_id"] = symbol
177
+ normalized.append(record)
178
+
179
+ self._clear()
180
+ if normalized:
181
+ self._insert(normalized)
182
+
183
+
184
+ class Orders(DataStore):
185
+ """Active orders."""
186
+
187
+ _KEYS = ["order_id"]
188
+
189
+ def _init(self) -> None:
190
+ self.id_to_symbol: dict[str, str] = {} # broker设置
191
+
192
+ @staticmethod
193
+ def _normalize(entry: dict[str, Any]) -> dict[str, Any] | None:
194
+ order_id = entry.get("order_id") or entry.get("orderId")
195
+ if order_id is None:
196
+ return None
197
+ normalized = dict(entry)
198
+ normalized["order_id"] = str(order_id)
199
+ return normalized
200
+
201
+ def _onresponse(self, data: Any) -> None:
202
+ payload = _maybe_to_dict(data) or {}
203
+ orders = payload.get("orders") or []
204
+ items: list[dict[str, Any]] = []
205
+ for entry in orders:
206
+ if not isinstance(entry, dict):
207
+ continue
208
+ normalized = self._normalize(entry)
209
+ if self.id_to_symbol:
210
+ market_id = entry.get("market_index")
211
+ if market_id is not None:
212
+ symbol = self.id_to_symbol.get(str(market_id))
213
+ if symbol is not None and normalized is not None:
214
+ normalized["symbol"] = symbol
215
+
216
+ if normalized:
217
+ items.append(normalized)
218
+
219
+ self._clear()
220
+ if items:
221
+ self._insert(items)
222
+
223
+ def _on_message(self, msg: dict[str, Any]) -> None:
224
+ """Handle websocket incremental updates for orders.
225
+
226
+ For WS updates we should not clear-and-reinsert. Instead:
227
+ - For fully filled or cancelled orders => delete
228
+ - Otherwise => update/insert
229
+ """
230
+ if not isinstance(msg, dict):
231
+ return
232
+
233
+ orders_obj = msg.get("orders")
234
+ if orders_obj is None:
235
+ account = msg.get("account")
236
+ if isinstance(account, dict):
237
+ orders_obj = account.get("orders")
238
+ if not orders_obj:
239
+ return
240
+
241
+ # Normalize orders to a flat list of dicts
242
+ if isinstance(orders_obj, dict):
243
+ raw_list: list[dict[str, Any]] = []
244
+ for _, lst in orders_obj.items():
245
+ if isinstance(lst, list):
246
+ raw_list.extend([o for o in lst if isinstance(o, dict)])
247
+ elif isinstance(orders_obj, list):
248
+ raw_list = [o for o in orders_obj if isinstance(o, dict)]
249
+ else:
250
+ return
251
+
252
+ def _is_terminal(order: dict[str, Any]) -> bool:
253
+ status = str(order.get("status", "")).lower()
254
+ if status in {"cancelled", "canceled", "executed", "filled", "closed", "done"}:
255
+ return True
256
+ rem = order.get("remaining_base_amount")
257
+ try:
258
+ return float(rem) <= 0 if rem is not None else False
259
+ except Exception:
260
+ return False
261
+
262
+
263
+ for entry in raw_list:
264
+ normalized = self._normalize(entry)
265
+ if normalized is None:
266
+ continue
267
+ # enrich with symbol if mapping is available
268
+ if self.id_to_symbol:
269
+ market_id = entry.get("market_index")
270
+ if market_id is not None:
271
+ symbol = self.id_to_symbol.get(str(market_id))
272
+ if symbol is not None:
273
+ normalized["symbol"] = symbol
274
+
275
+ self._update([normalized])
276
+ if _is_terminal(entry):
277
+ self._delete([normalized])
278
+
279
+
280
+
281
+
282
+
283
+ class Accounts(DataStore):
284
+ """Account level balances and metadata."""
285
+
286
+ _KEYS = ["account_index"]
287
+
288
+ def _normalize_account(self, entry: dict[str, Any]) -> dict[str, Any] | None:
289
+ account_index = entry.get("account_index") or entry.get("index")
290
+ if account_index is None:
291
+ return None
292
+ normalized = dict(entry)
293
+ normalized["account_index"] = account_index
294
+ normalized.pop("positions", None)
295
+ return normalized
296
+
297
+ def _on_accounts(self, accounts: Iterable[dict[str, Any]]) -> None:
298
+ normalized: list[dict[str, Any]] = []
299
+ for account in accounts:
300
+ if not isinstance(account, dict):
301
+ continue
302
+ record = self._normalize_account(account)
303
+ if record:
304
+ normalized.append(record)
305
+ if not normalized:
306
+ return
307
+ keys = [{"account_index": record["account_index"]} for record in normalized]
308
+ self._delete(keys)
309
+ self._insert(normalized)
310
+
311
+ def _onresponse(self, data: Any) -> None:
312
+ payload = _maybe_to_dict(data) or {}
313
+ accounts = payload.get("accounts") or []
314
+ self._on_accounts(accounts)
315
+
316
+ def _on_message(self, msg: dict[str, Any]) -> None:
317
+ account = msg.get("account")
318
+ if not isinstance(account, dict):
319
+ return
320
+ self._on_accounts([account])
321
+
322
+
323
+ class Positions(DataStore):
324
+ """Per-market positions grouped by account."""
325
+
326
+ _KEYS = ["account_index", "market_id"]
327
+
328
+ def _init(self) -> None:
329
+ self.id_to_symbol: dict[str, str] = {} # broker设置
330
+
331
+ @staticmethod
332
+ def _normalize(
333
+ account_index: Any,
334
+ position: dict[str, Any],
335
+ ) -> dict[str, Any] | None:
336
+ market_id = position.get("market_id")
337
+ if account_index is None or market_id is None:
338
+ return None
339
+ normalized = dict(position)
340
+ normalized["account_index"] = account_index
341
+ normalized["market_id"] = market_id
342
+ return normalized
343
+
344
+ def _update_positions(
345
+ self,
346
+ account_index: Any,
347
+ positions: Sequence[dict[str, Any]] | None,
348
+ ) -> None:
349
+ if positions is None:
350
+ return
351
+ items: list[dict[str, Any]] = []
352
+ for position in positions:
353
+ if not isinstance(position, dict):
354
+ continue
355
+ record = self._normalize(account_index, position)
356
+ if record:
357
+ items.append(record)
358
+ if not items:
359
+ return
360
+ keys = [{"account_index": item["account_index"], "market_id": item["market_id"]} for item in items]
361
+ self._delete(keys)
362
+ self._insert(items)
363
+
364
+ def _onresponse(self, data: Any) -> None:
365
+ payload = _maybe_to_dict(data) or {}
366
+ accounts = payload.get("accounts") or []
367
+ for account in accounts:
368
+ if not isinstance(account, dict):
369
+ continue
370
+ account_index = account.get("account_index") or account.get("index")
371
+ positions = account.get("positions")
372
+ self._update_positions(account_index, positions)
373
+
374
+ def _on_message(self, msg: dict[str, Any]) -> None:
375
+ account = msg.get("account")
376
+ if not isinstance(account, dict):
377
+ return
378
+ account_index = account.get("account_index") or account.get("index")
379
+ positions = account.get("positions")
380
+ self._update_positions(account_index, positions)
381
+
382
+
383
+ class Klines(DataStore):
384
+ """Candlestick/Kline store keyed by (symbol, resolution, timestamp).
385
+
386
+ - Maintains a list of active resolutions in ``_res_list`` (populated by REST updates).
387
+ - Updates candles in real-time by aggregating trade websocket messages.
388
+ """
389
+
390
+ _KEYS = ["symbol", "resolution", "timestamp"]
391
+
392
+ def _init(self) -> None:
393
+ self.id_to_symbol: dict[str, str] = {}
394
+ self._current_symbol: str | None = None
395
+ self._res_list: list[str] = []
396
+ # Track last processed trade_id to deduplicate snapshot trades after reconnect
397
+ self._last_trade_id_by_market: dict[str, int] = {}
398
+ self._last_trade_id_by_symbol: dict[str, int] = {}
399
+
400
+ @staticmethod
401
+ def _resolution_to_ms(resolution: str) -> int | None:
402
+ try:
403
+ res = resolution.strip().lower()
404
+ except Exception:
405
+ return None
406
+ # Common forms: 1m, 5m, 1h, 1d; also allow pure digits => seconds
407
+ unit = res[-1]
408
+ num_part = res[:-1] if unit in {"s", "m", "h", "d", "w"} else res
409
+ try:
410
+ n = int(num_part)
411
+ except Exception:
412
+ return None
413
+ if unit == "s":
414
+ return n * 1000
415
+ if unit == "m" or unit not in {"s", "h", "d", "w"}: # default minutes if no unit
416
+ return n * 60 * 1000
417
+ if unit == "h":
418
+ return n * 60 * 60 * 1000
419
+ if unit == "d":
420
+ return n * 24 * 60 * 60 * 1000
421
+ if unit == "w":
422
+ return n * 7 * 24 * 60 * 60 * 1000
423
+ return None
424
+
425
+ @staticmethod
426
+ def _market_id_from_channel(channel: str | None) -> str | None:
427
+ if not channel:
428
+ return None
429
+ if ":" in channel:
430
+ return channel.split(":", 1)[1]
431
+ if "/" in channel:
432
+ return channel.split("/", 1)[1]
433
+ return channel
434
+
435
+ def _compose_item(
436
+ self,
437
+ *,
438
+ symbol: str,
439
+ resolution: str,
440
+ ts: int,
441
+ price: float,
442
+ size: float,
443
+ last_trade_id: int | None,
444
+ open_price: float | None = None,
445
+ ) -> dict[str, Any]:
446
+ return {
447
+ "symbol": symbol,
448
+ "resolution": resolution,
449
+ "timestamp": ts,
450
+ "open": price if open_price is None else float(open_price),
451
+ "high": price,
452
+ "low": price,
453
+ "close": price,
454
+ "volume0": abs(size),
455
+ "volume1": abs(size) * price,
456
+ "last_trade_id": last_trade_id or 0,
457
+ }
458
+
459
+ def _ensure_backfill(self, *, symbol: str, resolution: str, new_bucket_ts: int) -> None:
460
+ """Backfill missing empty bars up to (but not including) new_bucket_ts.
461
+
462
+ Uses the last known close as O/H/L/C for synthetic bars and zero volume.
463
+ """
464
+ step = self._resolution_to_ms(resolution)
465
+ if not step:
466
+ return
467
+ # find the last existing bar before new_bucket_ts
468
+ rows = self.find({"symbol": symbol, "resolution": resolution})
469
+ prev = None
470
+ prev_ts = None
471
+ for r in rows:
472
+ try:
473
+ ts = int(r.get("timestamp"))
474
+ except Exception:
475
+ continue
476
+ if ts < new_bucket_ts and (prev_ts is None or ts > prev_ts):
477
+ prev = r
478
+ prev_ts = ts
479
+ if prev is None or prev_ts is None:
480
+ return
481
+ expected = prev_ts + step
482
+ while expected < new_bucket_ts:
483
+ prev_close = float(prev.get("close"))
484
+ fill_item = {
485
+ "symbol": symbol,
486
+ "resolution": resolution,
487
+ "timestamp": expected,
488
+ "open": prev_close,
489
+ "high": prev_close,
490
+ "low": prev_close,
491
+ "close": prev_close,
492
+ "volume0": 0.0,
493
+ "volume1": 0.0,
494
+ "last_trade_id": int(prev.get("last_trade_id", 0)) if prev.get("last_trade_id") is not None else 0,
495
+ }
496
+ self._insert([fill_item])
497
+ prev = fill_item
498
+ expected += step
499
+
500
+ def _merge_trade(self, *, symbol: str, trade_ts_ms: int, price: float, size: float, last_trade_id: int | None) -> None:
501
+ # Iterate active resolutions
502
+ for res in list(self._res_list):
503
+ interval_ms = self._resolution_to_ms(res)
504
+ if not interval_ms:
505
+ continue
506
+ bucket_ts = (trade_ts_ms // interval_ms) * interval_ms
507
+ # Upsert logic
508
+ existing = self.get({"symbol": symbol, "resolution": res, "timestamp": bucket_ts})
509
+ if existing is None:
510
+ # backfill any missing empty bars before creating a new bucket
511
+ self._ensure_backfill(symbol=symbol, resolution=res, new_bucket_ts=bucket_ts)
512
+ # open should be previous bar's close if exists; if none, fall back to current price
513
+ prev = None
514
+ rows = self.find({"symbol": symbol, "resolution": res})
515
+ prev_ts = None
516
+ for r in rows:
517
+ try:
518
+ ts = int(r.get("timestamp"))
519
+ except Exception:
520
+ continue
521
+ if ts < bucket_ts and (prev_ts is None or ts > prev_ts):
522
+ prev = r
523
+ prev_ts = ts
524
+ open_px = float(prev.get("close")) if prev is not None else price
525
+ self._insert([
526
+ self._compose_item(
527
+ symbol=symbol,
528
+ resolution=res,
529
+ ts=bucket_ts,
530
+ price=price,
531
+ size=size,
532
+ last_trade_id=last_trade_id,
533
+ open_price=open_px,
534
+ )
535
+ ])
536
+ continue
537
+ # merge into existing
538
+ updated = dict(existing)
539
+ o = float(updated.get("open", price))
540
+ h = float(updated.get("high", price))
541
+ l = float(updated.get("low", price))
542
+ c = float(updated.get("close", price))
543
+ v0 = float(updated.get("volume0", 0.0))
544
+ v1 = float(updated.get("volume1", 0.0))
545
+ p = float(price)
546
+ s = abs(float(size))
547
+ updated["open"] = o
548
+ updated["high"] = max(h, p)
549
+ updated["low"] = min(l, p)
550
+ updated["close"] = p
551
+ updated["volume0"] = v0 + s
552
+ updated["volume1"] = v1 + s * p
553
+ if last_trade_id is not None:
554
+ try:
555
+ updated["last_trade_id"] = max(int(last_trade_id), int(updated.get("last_trade_id", 0)))
556
+ except Exception:
557
+ updated["last_trade_id"] = int(last_trade_id)
558
+ self._update([updated])
559
+
560
+ def _onresponse(self, data: Any, *, symbol: str | None = None, resolution: str | None = None) -> None:
561
+ payload = _maybe_to_dict(data) or {}
562
+ candlesticks = payload.get("candlesticks") or []
563
+ res = payload.get("resolution") or resolution
564
+ if res not in self._res_list and res is not None:
565
+ self._res_list.append(res)
566
+
567
+ sym = symbol or self._current_symbol
568
+
569
+ # Sort incoming bars by timestamp to backfill in order
570
+ items: list[dict[str, Any]] = []
571
+ for c in sorted((candlesticks or []), key=lambda x: x.get("timestamp", 0)):
572
+ if not isinstance(c, dict):
573
+ continue
574
+ entry = dict(c)
575
+ if sym is not None:
576
+ entry["symbol"] = sym
577
+ if res is not None:
578
+ entry["resolution"] = res
579
+ items.append(entry)
580
+
581
+ # Insert or update per bar; backfill gaps before inserting new bars
582
+ for entry in items:
583
+ sym_i = entry.get("symbol")
584
+ res_i = entry.get("resolution")
585
+ ts_i = entry.get("timestamp")
586
+ if sym_i is None or res_i is None or ts_i is None:
587
+ continue
588
+ if self.get({"symbol": sym_i, "resolution": res_i, "timestamp": ts_i}) is None:
589
+ self._ensure_backfill(symbol=sym_i, resolution=res_i, new_bucket_ts=int(ts_i))
590
+ self._insert([entry])
591
+ else:
592
+ self._update([entry])
593
+
594
+ # Update last_trade_id baseline (by symbol) from REST bars if available
595
+ if sym is not None:
596
+ max_tid = 0
597
+ for e in items:
598
+ try:
599
+ tid = int(e.get("last_trade_id", 0))
600
+ except Exception:
601
+ tid = 0
602
+ if tid > max_tid:
603
+ max_tid = tid
604
+ if max_tid:
605
+ prev = self._last_trade_id_by_symbol.get(sym, 0)
606
+ if max_tid > prev:
607
+ self._last_trade_id_by_symbol[sym] = max_tid
608
+
609
+ def _on_message(self, msg: dict[str, Any]) -> None:
610
+ msg_type = msg.get("type")
611
+ if msg_type not in {"subscribed/trade", "update/trade"}:
612
+ return
613
+ market_id = self._market_id_from_channel(msg.get("channel"))
614
+ if market_id is None:
615
+ return
616
+ market_id_str = str(market_id)
617
+ symbol = self.id_to_symbol.get(market_id_str) or market_id_str
618
+ trades = msg.get("trades") or []
619
+ # Baseline last trade_id from market and symbol
620
+ base_last_tid = max(
621
+ self._last_trade_id_by_market.get(market_id_str, 0),
622
+ self._last_trade_id_by_symbol.get(symbol, 0),
623
+ )
624
+ # Process in ascending trade_id order for stability
625
+ try:
626
+ trades_sorted = sorted(trades, key=lambda x: int(x.get("trade_id", 0)))
627
+ except Exception:
628
+ trades_sorted = trades
629
+
630
+ last_tid = base_last_tid
631
+ for t in trades_sorted:
632
+ if not isinstance(t, dict):
633
+ continue
634
+ ts = t.get("timestamp")
635
+ price = t.get("price")
636
+ size = t.get("size")
637
+ trade_id = t.get("trade_id")
638
+ try:
639
+ ts = int(ts)
640
+ p = float(price)
641
+ s = float(size)
642
+ tid = int(trade_id) if trade_id is not None else 0
643
+ except Exception:
644
+ continue
645
+ # Skip stale or duplicate snapshot trades
646
+ if tid and last_tid and tid <= last_tid:
647
+ continue
648
+ self._merge_trade(symbol=symbol, trade_ts_ms=ts, price=p, size=s, last_trade_id=tid)
649
+ if tid > last_tid:
650
+ last_tid = tid
651
+
652
+ # Persist last processed trade_id for this market
653
+ if last_tid and last_tid > base_last_tid:
654
+ self._last_trade_id_by_market[market_id_str] = last_tid
655
+
656
+
657
+
658
+
659
+ class LighterDataStore(DataStoreCollection):
660
+ """Data store collection for the Lighter exchange."""
661
+
662
+ def _init(self) -> None:
663
+ self._create("book", datastore_class=Book)
664
+ self._create("detail", datastore_class=Detail)
665
+ self._create("orders", datastore_class=Orders)
666
+ self._create("accounts", datastore_class=Accounts)
667
+ self._create("positions", datastore_class=Positions)
668
+ self._create("klines", datastore_class=Klines)
669
+
670
+ def set_id_to_symbol(self, id_to_symbol: dict[str, str]) -> None:
671
+ self.id_to_symbol = id_to_symbol
672
+ self.book.id_to_symbol = self.id_to_symbol
673
+ self.orders.id_to_symbol = self.id_to_symbol
674
+ self.positions.id_to_symbol = self.id_to_symbol
675
+ self.klines.id_to_symbol = self.id_to_symbol
676
+
677
+ def onmessage(self, msg: Item, ws: ClientWebSocketResponse | None = None) -> None:
678
+
679
+ msg_type = msg.get("type")
680
+ if msg_type == "ping":
681
+ asyncio.create_task(ws.send_json({"type": "pong"}))
682
+ return
683
+
684
+ if isinstance(msg, dict):
685
+ msg_type = msg.get("type")
686
+ if msg_type in {"subscribed/order_book", "update/order_book"}:
687
+ self.book._on_message(msg)
688
+ elif msg_type in {"subscribed/account_all", "update/account_all"}:
689
+ self.accounts._on_message(msg)
690
+ self.positions._on_message(msg)
691
+ self.orders._on_message(msg)
692
+ elif msg_type in {"subscribed/account_all_orders", "update/account_all_orders"}:
693
+ self.orders._on_message(msg)
694
+ elif msg_type in {"subscribed/trade", "update/trade"}:
695
+ self.klines._on_message(msg)
696
+
697
+
698
+ @property
699
+ def book(self) -> Book:
700
+ """
701
+ Lighter 深度快照。
702
+
703
+ .. code:: json
704
+
705
+ {
706
+ "s": "BTC-USD",
707
+ "S": "a", # \"a\"=ask / \"b\"=bid
708
+ "p": "50250.5",
709
+ "q": "0.37"
710
+ }
711
+ """
712
+ return self._get("book")
713
+
714
+ @property
715
+ def detail(self) -> Detail:
716
+ """
717
+ `lighter.models.OrderBookDetail` 元数据。
718
+
719
+ .. code:: json
720
+
721
+ [
722
+ {
723
+ "symbol": "DOLO",
724
+ "market_id": 75,
725
+ "status": "active",
726
+ "taker_fee": "0.0000",
727
+ "maker_fee": "0.0000",
728
+ "liquidation_fee": "1.0000",
729
+ "min_base_amount": "30.0",
730
+ "min_quote_amount": "10.000000",
731
+ "supported_size_decimals": 1,
732
+ "supported_price_decimals": 5,
733
+ "supported_quote_decimals": 6,
734
+ "order_quote_limit": ""
735
+ }
736
+ ]
737
+ """
738
+ return self._get("detail")
739
+
740
+ @property
741
+ def orders(self) -> Orders:
742
+ """
743
+ 活动订单(`lighter.models.Order`)。
744
+
745
+ .. code:: json
746
+
747
+ {
748
+ "order_index": 21673573193817727,
749
+ "client_order_index": 0,
750
+ "order_id": "21673573193817727",
751
+ "client_order_id": "0",
752
+ "market_index": 75,
753
+ "symbol": "DOLO",
754
+ "owner_account_index": 311464,
755
+ "initial_base_amount": "146.7",
756
+ "price": "0.07500",
757
+ "nonce": 281474963807871,
758
+ "remaining_base_amount": "146.7",
759
+ "is_ask": false,
760
+ "base_size": 1467,
761
+ "base_price": 7500,
762
+ "filled_base_amount": "0.0",
763
+ "filled_quote_amount": "0.000000",
764
+ "side": "",
765
+ "type": "limit",
766
+ "time_in_force": "good-till-time",
767
+ "reduce_only": false,
768
+ "trigger_price": "0.00000",
769
+ "order_expiry": 1764082202799,
770
+ "status": "open",
771
+ "trigger_status": "na",
772
+ "trigger_time": 0,
773
+ "parent_order_index": 0,
774
+ "parent_order_id": "0",
775
+ "to_trigger_order_id_0": "0",
776
+ "to_trigger_order_id_1": "0",
777
+ "to_cancel_order_id_0": "0",
778
+ "block_height": 75734444,
779
+ "timestamp": 1761663003,
780
+ "created_at": 1761663003,
781
+ "updated_at": 1761663003
782
+ }
783
+ """
784
+ return self._get("orders")
785
+
786
+
787
+ @property
788
+ def accounts(self) -> Accounts:
789
+ """
790
+ 账户概览(`lighter.models.DetailedAccount`)。
791
+
792
+ .. code:: json
793
+ [
794
+ {
795
+ "code": 0,
796
+ "account_type": 0,
797
+ "index": 311464,
798
+ "l1_address": "0x5B3f0AdDfaf4c1d8729e266b22093545EFaE6c0e",
799
+ "cancel_all_time": 0,
800
+ "total_order_count": 1,
801
+ "total_isolated_order_count": 0,
802
+ "pending_order_count": 0,
803
+ "available_balance": "30.000000",
804
+ "status": 0,
805
+ "collateral": "30.000000",
806
+ "account_index": 311464,
807
+ "name": "",
808
+ "description": "",
809
+ "can_invite": false,
810
+ "referral_points_percentage": "",
811
+ "total_asset_value": "30",
812
+ "cross_asset_value": "30",
813
+ "shares": []
814
+ }
815
+ ]
816
+ """
817
+ return self._get("accounts")
818
+
819
+ @property
820
+ def positions(self) -> Positions:
821
+ """
822
+ 账户持仓(`lighter.models.AccountPosition`)。
823
+
824
+ .. code:: json
825
+
826
+ [
827
+ {
828
+ "market_id": 75,
829
+ "symbol": "DOLO",
830
+ "initial_margin_fraction": "33.33",
831
+ "open_order_count": 1,
832
+ "pending_order_count": 0,
833
+ "position_tied_order_count": 0,
834
+ "sign": 1,
835
+ "position": "129.8",
836
+ "avg_entry_price": "0.08476",
837
+ "position_value": "10.969398",
838
+ "unrealized_pnl": "-0.032450",
839
+ "realized_pnl": "0.000000",
840
+ "liquidation_price": "0",
841
+ "margin_mode": 0,
842
+ "allocated_margin": "0.000000",
843
+ "account_index": 311464
844
+ }
845
+ ]
846
+ """
847
+ return self._get("positions")
848
+
849
+ @property
850
+ def klines(self) -> "Klines":
851
+ """
852
+ K线/蜡烛图数据(`lighter.models.Candlesticks` -> `lighter.models.Candlestick`)。
853
+
854
+ .. code:: json
855
+
856
+ {
857
+ "symbol": "BTC",
858
+ "timestamp": 1730612700000,
859
+ "open": 68970.5,
860
+ "high": 69012.3,
861
+ "low": 68890.0,
862
+ "close": 68995.1,
863
+ "volume0": 12.34,
864
+ "volume1": 850000.0,
865
+ "resolution": "1m"
866
+ }
867
+ """
868
+ return self._get("klines")