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.
- hyperquant/__init__.py +8 -0
- hyperquant/broker/auth.py +972 -0
- hyperquant/broker/bitget.py +311 -0
- hyperquant/broker/bitmart.py +720 -0
- hyperquant/broker/coinw.py +487 -0
- hyperquant/broker/deepcoin.py +651 -0
- hyperquant/broker/edgex.py +500 -0
- hyperquant/broker/hyperliquid.py +570 -0
- hyperquant/broker/lbank.py +661 -0
- hyperquant/broker/lib/edgex_sign.py +455 -0
- hyperquant/broker/lib/hpstore.py +252 -0
- hyperquant/broker/lib/hyper_types.py +48 -0
- hyperquant/broker/lib/polymarket/ctfAbi.py +721 -0
- hyperquant/broker/lib/polymarket/safeAbi.py +1138 -0
- hyperquant/broker/lib/util.py +22 -0
- hyperquant/broker/lighter.py +679 -0
- hyperquant/broker/models/apexpro.py +150 -0
- hyperquant/broker/models/bitget.py +359 -0
- hyperquant/broker/models/bitmart.py +635 -0
- hyperquant/broker/models/coinw.py +724 -0
- hyperquant/broker/models/deepcoin.py +809 -0
- hyperquant/broker/models/edgex.py +1053 -0
- hyperquant/broker/models/hyperliquid.py +284 -0
- hyperquant/broker/models/lbank.py +557 -0
- hyperquant/broker/models/lighter.py +868 -0
- hyperquant/broker/models/ourbit.py +1155 -0
- hyperquant/broker/models/polymarket.py +1071 -0
- hyperquant/broker/ourbit.py +550 -0
- hyperquant/broker/polymarket.py +2399 -0
- hyperquant/broker/ws.py +132 -0
- hyperquant/core.py +513 -0
- hyperquant/datavison/_util.py +18 -0
- hyperquant/datavison/binance.py +111 -0
- hyperquant/datavison/coinglass.py +237 -0
- hyperquant/datavison/okx.py +177 -0
- hyperquant/db.py +191 -0
- hyperquant/draw.py +1200 -0
- hyperquant/logkit.py +205 -0
- hyperquant/notikit.py +124 -0
- hyperquant-1.48.dist-info/METADATA +32 -0
- hyperquant-1.48.dist-info/RECORD +42 -0
- 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")
|