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,1071 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from heapq import heappop, heappush
6
+ import time
7
+ from typing import TYPE_CHECKING, Any, Iterable
8
+
9
+ from pybotters.store import DataStore, DataStoreCollection
10
+ from pybotters.ws import ClientWebSocketResponse
11
+
12
+ if TYPE_CHECKING:
13
+ from pybotters.typedefs import Item
14
+
15
+
16
+ class Position(DataStore):
17
+ """Position DataStore keyed by Polymarket token id."""
18
+
19
+ _KEYS = ["asset"]
20
+
21
+ def _init(self) -> None:
22
+ # 缓存LIVE订单已计入的size_matched: {order_id: size_matched}
23
+ self._live_cache: dict[str, float] = {}
24
+
25
+
26
+ def sorted(
27
+ self, query: Item | None = None, limit: int | None = None
28
+ ) -> dict[str, list[Item]]:
29
+ """按ts降序排列,按outcome分组"""
30
+ if query is None:
31
+ query = {}
32
+ result: dict[str, list[Item]] = {}
33
+ for item in self:
34
+ if all(k in item and query[k] == item[k] for k in query):
35
+ outcome = item.get("outcome") or "unknown"
36
+ if outcome not in result:
37
+ result[outcome] = []
38
+ result[outcome].append(item)
39
+ for outcome in result:
40
+ result[outcome].sort(key=lambda x: (x.get("eventSlug") or '0'), reverse=True)
41
+ if limit:
42
+ result[outcome] = result[outcome][:limit]
43
+ return result
44
+
45
+ def _on_response(self, msg: list[Item]) -> None:
46
+ if msg:
47
+ self._clear()
48
+ for rec in msg:
49
+ rec["ts"] = 0
50
+ self._update(msg)
51
+
52
+ def on_trade(self, trade: Item) -> None:
53
+ status = str(trade.get("status") or "").upper()
54
+ if status not in {"MATCHED"}:
55
+ return
56
+
57
+ asset_id = trade.get("asset_id")
58
+ outcome = trade.get("outcome")
59
+ side = str(trade.get("side") or "").upper()
60
+ size_raw = trade.get("size")
61
+ price_raw = trade.get("price")
62
+
63
+
64
+ if not asset_id or not outcome or side not in {"BUY", "SELL"}:
65
+ return
66
+
67
+ try:
68
+ size = float(size_raw)
69
+ except (TypeError, ValueError):
70
+ return
71
+ try:
72
+ price = float(price_raw)
73
+ except (TypeError, ValueError):
74
+ price = None
75
+
76
+
77
+
78
+ key = {"asset": asset_id, "outcome": outcome}
79
+ existing = self.get(key) or {}
80
+
81
+ cur_size = float(existing.get("size") or 0.0)
82
+ cur_total_bought = float(existing.get("totalBought") or 0.0)
83
+ cur_avg_price = float(existing.get("avgPrice") or 0.0)
84
+ cur_cost = cur_size * cur_avg_price
85
+
86
+ if side == "BUY":
87
+ new_size = cur_size + size
88
+ total_bought = cur_total_bought + size
89
+ # 未拿到成交价时使用当前均价兜底,避免均价被拉低
90
+ effective_price = price if price is not None else cur_avg_price
91
+ new_cost = cur_cost + size * effective_price
92
+ else: # SELL
93
+ new_size = cur_size - size
94
+ total_bought = cur_total_bought
95
+ # 卖出按照当前均价释放成本
96
+ new_cost = cur_cost - min(size, cur_size) * cur_avg_price
97
+
98
+ if new_size <= 0:
99
+ new_size = 0.0
100
+ avg_price = 0.0
101
+ new_cost = 0.0
102
+ else:
103
+ avg_price = max(new_cost, 0.0) / new_size
104
+
105
+ rec: dict[str, Any] = {
106
+ "asset": asset_id,
107
+ "outcome": outcome,
108
+ "side": side,
109
+ "size": new_size,
110
+ "totalBought": total_bought,
111
+ "avgPrice": avg_price,
112
+ }
113
+
114
+ if existing:
115
+ self._update([rec])
116
+ else:
117
+ self._insert([rec])
118
+
119
+ def _on_order(self, order: dict[str, Any]) -> None:
120
+ """通过order更新持仓,处理LIVE时部分成交的增量统计"""
121
+ # print(order)
122
+ # order写入本地尝试后续分析
123
+ with open("polymarket_orders.log", "a") as f:
124
+ f.write(json.dumps(order) + "\n")
125
+ order_id = order.get("id")
126
+ asset_id = order.get("asset_id")
127
+ outcome = order.get("outcome")
128
+ side = str(order.get("side") or "").upper()
129
+ size_matched = float(order.get("size_matched") or 0)
130
+ price = float(order.get("price") or 0)
131
+ status = str(order.get("status") or "").upper()
132
+
133
+ if not order_id or not asset_id or not outcome or side not in {"BUY", "SELL"}:
134
+ return
135
+
136
+ cached = self._live_cache.get(order_id, 0.0)
137
+
138
+ if status == "LIVE":
139
+ # LIVE时计算增量
140
+ delta = size_matched - cached
141
+ if delta > 0:
142
+ self._live_cache[order_id] = size_matched
143
+ self._apply_trade(asset_id, outcome, side, delta, price)
144
+ elif status in {"CANCELED", "MATCHED"}:
145
+ # 订单完结:计算最终增量 = 最终size_matched - 已计入的cached
146
+ delta = size_matched - cached
147
+ if delta > 0:
148
+ self._apply_trade(asset_id, outcome, side, delta, price)
149
+ # 清理缓存
150
+ self._live_cache.pop(order_id, None)
151
+
152
+ def _apply_trade(self, asset_id: str, outcome: str, side: str, size: float, price: float) -> None:
153
+ """应用成交到持仓"""
154
+ if size <= 0:
155
+ return
156
+
157
+ key = {"asset": asset_id, "outcome": outcome}
158
+ existing = self.get(key) or {}
159
+
160
+ cur_size = float(existing.get("size") or 0.0)
161
+ cur_total_bought = float(existing.get("totalBought") or 0.0)
162
+ cur_avg_price = float(existing.get("avgPrice") or 0.0)
163
+ cur_cost = cur_size * cur_avg_price
164
+
165
+ if side == "BUY":
166
+ new_size = cur_size + size
167
+ total_bought = cur_total_bought + size
168
+ effective_price = price if price else cur_avg_price
169
+ new_cost = cur_cost + size * effective_price
170
+ else: # SELL
171
+ new_size = cur_size - size
172
+ total_bought = cur_total_bought
173
+ new_cost = cur_cost - min(size, cur_size) * cur_avg_price
174
+
175
+ if new_size <= 0:
176
+ new_size = 0.0
177
+ avg_price = 0.0
178
+ new_cost = 0.0
179
+ else:
180
+ avg_price = max(new_cost, 0.0) / new_size
181
+
182
+ rec: dict[str, Any] = {
183
+ "asset": asset_id,
184
+ "outcome": outcome,
185
+ "side": side,
186
+ "size": new_size,
187
+ "totalBought": total_bought,
188
+ "avgPrice": avg_price,
189
+ "ts": int(time.time() * 1000),
190
+ }
191
+
192
+ if existing:
193
+ self._update([rec])
194
+ else:
195
+ self._insert([rec])
196
+
197
+
198
+
199
+ class Fill(DataStore):
200
+ """Fill records keyed by maker order id."""
201
+
202
+ _KEYS = ["order_id"]
203
+
204
+ @staticmethod
205
+ def _from_trade(trade: dict[str, Any], maker: dict[str, Any]) -> dict[str, Any] | None:
206
+ order_id = maker.get("order_id")
207
+ if not order_id:
208
+ return None
209
+
210
+ record = {
211
+ "order_id": order_id,
212
+ "trade_id": trade.get("id"),
213
+ "asset_id": maker.get("asset_id") or trade.get("asset_id"),
214
+ "market": trade.get("market"),
215
+ "outcome": maker.get("outcome") or trade.get("outcome"),
216
+ "matched_amount": maker.get("matched_amount") or trade.get("size"),
217
+ "price": maker.get("price") or trade.get("price"),
218
+ "status": trade.get("status"),
219
+ "match_time": trade.get("match_time") or trade.get("timestamp"),
220
+ "maker_owner": maker.get("owner"),
221
+ "taker_order_id": trade.get("taker_order_id"),
222
+ "side": maker.get("side") or trade.get("side"),
223
+ }
224
+
225
+ for key in ("matched_amount", "price"):
226
+ value = record.get(key)
227
+ if value is None:
228
+ continue
229
+ try:
230
+ record[key] = float(value)
231
+ except (TypeError, ValueError):
232
+ pass
233
+
234
+ return record
235
+
236
+ def _on_trade(self, trade: dict[str, Any]) -> None:
237
+ status = str(trade.get("status") or "").upper()
238
+ if status != "MATCHED":
239
+ return
240
+ maker_orders = trade.get("maker_orders") or []
241
+ upserts: list[dict[str, Any]] = []
242
+ for maker in maker_orders:
243
+ record = self._from_trade(trade, maker)
244
+ if not record:
245
+ continue
246
+ upserts.append(record)
247
+
248
+ if not upserts:
249
+ return
250
+
251
+ for record in upserts:
252
+ key = {"order_id": record["order_id"]}
253
+ if self.get(key):
254
+ self._update([record])
255
+ else:
256
+ self._insert([record])
257
+
258
+
259
+ class Order(DataStore):
260
+ """User orders keyed by order id (REST + WS)."""
261
+
262
+ _KEYS = ["id"]
263
+
264
+ @staticmethod
265
+ def _normalize(entry: dict[str, Any]) -> dict[str, Any] | None:
266
+ oid = entry.get("id")
267
+ if not oid:
268
+ return None
269
+ normalized = dict(entry)
270
+ # numeric fields
271
+ for field in ("price", "original_size", "size_matched"):
272
+ val = normalized.get(field)
273
+ try:
274
+ if val is not None:
275
+ normalized[field] = float(val)
276
+ except (TypeError, ValueError):
277
+ pass
278
+ return normalized
279
+
280
+ def _on_response(self, items: list[dict[str, Any]] | dict[str, Any]) -> None:
281
+ """增量同步:insert新增、update变更、delete消失的订单"""
282
+ rows: list[dict[str, Any]] = []
283
+ if isinstance(items, dict):
284
+ items = [items]
285
+ for it in items or []:
286
+ norm = self._normalize(it)
287
+ if norm:
288
+ rows.append(norm)
289
+
290
+ # 构建新订单id集合
291
+ new_ids = {r["id"] for r in rows}
292
+
293
+ # 删除不再存在的订单(传入完整状态)
294
+ to_delete = [dict(item) for item in self if item["id"] not in new_ids]
295
+ if to_delete:
296
+ self._delete(to_delete)
297
+
298
+ # 插入或更新
299
+ for row in rows:
300
+ existing = self.get({"id": row["id"]})
301
+ if existing:
302
+ # 有变化才update
303
+ if any(existing.get(k) != row.get(k) for k in row):
304
+ self._update([row])
305
+ else:
306
+ self._insert([row])
307
+
308
+ def _on_message(self, msg: dict[str, Any]) -> None:
309
+ status = str(msg.get("status") or "").upper()
310
+ # CANCELED MATCHED 删除
311
+ order = self.get({"id": msg.get("id")})
312
+ if not order:
313
+ self._insert([msg])
314
+
315
+ if status in {"CANCELED", "MATCHED"}:
316
+ self._delete([msg])
317
+
318
+ class MyTrade(DataStore):
319
+ """User trades keyed by trade id."""
320
+
321
+ _KEYS = ["id"]
322
+
323
+ @staticmethod
324
+ def _normalize(entry: dict[str, Any]) -> dict[str, Any] | None:
325
+ trade_id = entry.get("id")
326
+ if not trade_id:
327
+ return None
328
+ normalized = dict(entry)
329
+ for field in ("price", "size", "fee_rate_bps"):
330
+ value = normalized.get(field)
331
+ if value is None:
332
+ continue
333
+ try:
334
+ normalized[field] = float(value)
335
+ except (TypeError, ValueError):
336
+ pass
337
+ return normalized
338
+
339
+ def _on_message(self, msg: dict[str, Any]) -> None:
340
+ normalized = self._normalize(msg) or {}
341
+ trade_id = normalized.get("id")
342
+ if not trade_id:
343
+ return
344
+ if self.get({"id": trade_id}):
345
+ self._update([normalized])
346
+ else:
347
+ self._insert([normalized])
348
+
349
+ class Trade(DataStore):
350
+ """User trades keyed by trade id."""
351
+
352
+ _KEYS = ["hash"]
353
+ _MAXLEN = 500
354
+
355
+ def _on_message(self, msg: dict[str, Any]) -> None:
356
+ payload = msg or {}
357
+ if payload:
358
+ if payload.get("event_type") == "last_trade_price":
359
+ transaction_hash = payload.get("transaction_hash")
360
+ if transaction_hash:
361
+ payload.update({"hash": transaction_hash})
362
+ payload.pop("transaction_hash", None)
363
+ else:
364
+ if payload.get("transactionHash"):
365
+ payload.update({"hash": payload.get("transactionHash")})
366
+ payload.pop("transactionHash", None)
367
+
368
+ self._insert([payload])
369
+
370
+ class Book(DataStore):
371
+ """Full depth order book keyed by Polymarket token id."""
372
+
373
+ _KEYS = ["s", "S", "p"]
374
+
375
+ def _init(self) -> None:
376
+ self.id_to_alias: dict[str, str] = {}
377
+
378
+ def update_aliases(self, mapping: dict[str, str]) -> None:
379
+ if not mapping:
380
+ return
381
+ self.id_to_alias.update(mapping)
382
+
383
+ def _alias(self, asset_id: str | None) -> tuple[str, str | None] | tuple[None, None]:
384
+ if asset_id is None:
385
+ return None, None
386
+ alias = self.id_to_alias.get(asset_id)
387
+ return asset_id, alias
388
+
389
+ def _normalize_levels(
390
+ self,
391
+ entries: Iterable[dict[str, Any]] | None,
392
+ *,
393
+ side: str,
394
+ symbol: str,
395
+ alias: str | None,
396
+ ) -> list[dict[str, Any]]:
397
+ if not entries:
398
+ return []
399
+ normalized: list[dict[str, Any]] = []
400
+ for entry in entries:
401
+ try:
402
+ price = float(entry["price"])
403
+ size = float(entry["size"])
404
+ except (KeyError, TypeError, ValueError):
405
+ continue
406
+ record = {"s": symbol, "S": side, "p": price, "q": size}
407
+ if alias is not None:
408
+ record["alias"] = alias
409
+ normalized.append(record)
410
+ return normalized
411
+
412
+ def _purge_missing_levels(
413
+ self, *, symbol: str, side: str, new_levels: list[dict[str, Any]]
414
+ ) -> None:
415
+ """Remove levels no longer present in the latest snapshot."""
416
+ existing = self.find({"s": symbol, "S": side})
417
+ if not existing:
418
+ return
419
+ new_prices = {lvl["p"] for lvl in new_levels}
420
+ stale = [
421
+ {"s": symbol, "S": side, "p": level["p"]}
422
+ for level in existing
423
+ if level.get("p") not in new_prices
424
+ ]
425
+ if stale:
426
+ self._delete(stale)
427
+
428
+ def _on_message(self, msg: dict[str, Any]) -> None:
429
+ msg_type = msg.get("event_type")
430
+ if msg_type not in {"book", "price_change"}:
431
+ return
432
+
433
+ if msg_type == "book":
434
+ asset_id = msg.get("asset_id") or msg.get("token_id")
435
+ symbol, alias = self._alias(asset_id)
436
+ if symbol is None:
437
+ return
438
+ bids = self._normalize_levels(msg.get("bids"), side="b", symbol=symbol, alias=alias)
439
+ asks = self._normalize_levels(msg.get("asks"), side="a", symbol=symbol, alias=alias)
440
+ self._purge_missing_levels(symbol=symbol, side="b", new_levels=bids)
441
+ self._purge_missing_levels(symbol=symbol, side="a", new_levels=asks)
442
+ if bids:
443
+ self._insert(bids)
444
+ if asks:
445
+ self._insert(asks)
446
+ return
447
+
448
+ price_changes = msg.get("price_changes") or []
449
+ updates: list[dict[str, Any]] = []
450
+ removals: list[dict[str, Any]] = []
451
+ for change in price_changes:
452
+ asset_id = change.get("asset_id") or change.get("token_id")
453
+ symbol, alias = self._alias(asset_id)
454
+ if symbol is None:
455
+ continue
456
+ side = "b" if change.get("side") == "BUY" else "a"
457
+ try:
458
+ price = float(change["price"])
459
+ size = float(change["size"])
460
+ except (KeyError, TypeError, ValueError):
461
+ continue
462
+ record = {"s": symbol, "S": side, "p": price}
463
+ if alias is not None:
464
+ record["alias"] = alias
465
+ if size == 0:
466
+ removals.append({"s": symbol, "S": side, "p": price})
467
+ else:
468
+ record["q"] = size
469
+ updates.append(record)
470
+
471
+ if removals:
472
+ self._delete(removals)
473
+ if updates:
474
+ self._update(updates)
475
+
476
+ def sorted(
477
+ self, query: Item | None = None, limit: int | None = None
478
+ ) -> dict[str, list[Item]]:
479
+ return self._sorted(
480
+ item_key="S",
481
+ item_asc_key="a",
482
+ item_desc_key="b",
483
+ sort_key="p",
484
+ query=query,
485
+ limit=limit,
486
+ )
487
+
488
+ @dataclass
489
+ class _SideBook:
490
+ is_ask: bool
491
+ levels: dict[float, tuple[str, str]] = field(default_factory=dict)
492
+ heap: list[tuple[float, float]] = field(default_factory=list)
493
+
494
+ def clear(self) -> None:
495
+ self.levels.clear()
496
+ self.heap.clear()
497
+
498
+ def update_levels(
499
+ self, updates: Iterable[dict[str, Any]] | None, *, snapshot: bool
500
+ ) -> None:
501
+ if updates is None:
502
+ return
503
+
504
+ if snapshot:
505
+ self.clear()
506
+
507
+ for entry in updates:
508
+ price, size = self._extract(entry)
509
+ price_val = self._to_float(price)
510
+ size_val = self._to_float(size)
511
+ if price_val is None or size_val is None:
512
+ continue
513
+
514
+ if size_val <= 0:
515
+ self.levels.pop(price_val, None)
516
+ continue
517
+
518
+ self.levels[price_val] = (str(price), str(size))
519
+ priority = price_val if self.is_ask else -price_val
520
+ heappush(self.heap, (priority, price_val))
521
+
522
+ def best(self) -> tuple[str, str] | None:
523
+ while self.heap:
524
+ _, price = self.heap[0]
525
+ level = self.levels.get(price)
526
+ if level is not None:
527
+ return level
528
+ heappop(self.heap)
529
+ return None
530
+
531
+ @staticmethod
532
+ def _extract(entry: Any) -> tuple[Any, Any]:
533
+ if isinstance(entry, dict):
534
+ price = entry.get("price", entry.get("p"))
535
+ size = entry.get("size", entry.get("q"))
536
+ return price, size
537
+ if isinstance(entry, (list, tuple)) and len(entry) >= 2:
538
+ return entry[0], entry[1]
539
+ return None, None
540
+
541
+ @staticmethod
542
+ def _to_float(value: Any) -> float | None:
543
+ try:
544
+ return float(value)
545
+ except (TypeError, ValueError):
546
+ return None
547
+
548
+ class Price(DataStore):
549
+ _KEYS = ["s"]
550
+
551
+ def _on_message(self, msg: dict[str, Any]) -> None:
552
+ payload = msg.get('payload') or {}
553
+ data = payload.get('data') or {}
554
+ symbol = payload.get('symbol')
555
+
556
+ if not symbol:
557
+ return
558
+
559
+ _next = self.get({'s': symbol}) or {}
560
+ _next_price = _next.get('p')
561
+ last_price = None
562
+
563
+ if data and isinstance(data, list):
564
+ last_price = data[-1].get('value')
565
+ if 'value' in payload:
566
+ last_price = payload.get('value')
567
+
568
+ if last_price is None:
569
+ return
570
+
571
+ record = {'s': symbol, 'p': last_price}
572
+ key = {'s': symbol}
573
+ if self.get(key):
574
+ self._update([record])
575
+ else:
576
+ self._insert([record])
577
+
578
+
579
+ class BBO(DataStore):
580
+ _KEYS = ["s", "S"]
581
+
582
+ def _init(self) -> None:
583
+ self._book: dict[str, dict[str, _SideBook]] = {}
584
+ self.id_to_alias: dict[str, str] = {}
585
+
586
+ def update_aliases(self, mapping: dict[str, str]) -> None:
587
+ if not mapping:
588
+ return
589
+ self.id_to_alias.update(mapping)
590
+
591
+ def _alias(self, asset_id: str | None) -> tuple[str, str | None] | tuple[None, None]:
592
+ if asset_id is None:
593
+ return None, None
594
+ alias = self.id_to_alias.get(asset_id)
595
+ return asset_id, alias
596
+
597
+ def _side(self, symbol: str, side: str) -> _SideBook:
598
+ symbol_book = self._book.setdefault(symbol, {})
599
+ side_book = symbol_book.get(side)
600
+ if side_book is None:
601
+ side_book = _SideBook(is_ask=(side == "a"))
602
+ symbol_book[side] = side_book
603
+ return side_book
604
+
605
+ def _sync_side(
606
+ self, symbol: str, side: str, best: tuple[str, str] | None, alias: str | None
607
+ ) -> None:
608
+ key = {"s": symbol, "S": side}
609
+ current = self.get(key)
610
+
611
+ if best is None:
612
+ if current:
613
+ self._delete([key])
614
+ return
615
+
616
+ price, size = best
617
+ payload = {"s": symbol, "S": side, "p": price, "q": size}
618
+ if alias is not None:
619
+ payload["alias"] = alias
620
+
621
+ if current:
622
+ cur_price = current.get("p")
623
+ cur_size = current.get("q")
624
+ cur_alias = current.get("alias")
625
+
626
+ if cur_price == price:
627
+ # price unchanged -> only update quantities / alias changes
628
+ if cur_size != size or (alias is not None and cur_alias != alias):
629
+ self._update([payload])
630
+ return
631
+
632
+ # price changed -> delete old then insert new level to trigger change watchers
633
+ self._delete([key])
634
+
635
+ self._insert([payload])
636
+
637
+ def _from_price_changes(self, msg: dict[str, Any]) -> None:
638
+ price_changes = msg.get("price_changes") or []
639
+ touched: dict[str, str | None] = {}
640
+ for change in price_changes:
641
+ asset_id = change.get("asset_id") or change.get("token_id")
642
+ symbol, alias = self._alias(asset_id)
643
+ if symbol is None:
644
+ continue
645
+ side = "b" if str(change.get("side") or "").upper() == "BUY" else "a"
646
+ side_book = self._side(symbol, side)
647
+ side_book.update_levels([change], snapshot=False)
648
+ touched[symbol] = alias
649
+
650
+ for symbol, alias in touched.items():
651
+ asks = self._side(symbol, "a")
652
+ bids = self._side(symbol, "b")
653
+ self._sync_side(symbol, "a", asks.best(), alias)
654
+ self._sync_side(symbol, "b", bids.best(), alias)
655
+
656
+ def _from_snapshot(self, msg: dict[str, Any]) -> None:
657
+ asset_id = msg.get("asset_id") or msg.get("token_id")
658
+ symbol, alias = self._alias(asset_id)
659
+ if symbol is None:
660
+ return
661
+ asks = self._side(symbol, "a")
662
+ bids = self._side(symbol, "b")
663
+ asks.update_levels(msg.get("asks"), snapshot=True)
664
+ bids.update_levels(msg.get("bids"), snapshot=True)
665
+ self._sync_side(symbol, "a", asks.best(), alias)
666
+ self._sync_side(symbol, "b", bids.best(), alias)
667
+
668
+ def _on_message(self, msg: dict[str, Any]) -> None:
669
+ msg_type = (msg.get("event_type") or msg.get("type") or "").lower()
670
+ if msg_type == "book":
671
+ self._from_snapshot(msg)
672
+ elif msg_type == "price_change":
673
+ self._from_price_changes(msg)
674
+
675
+
676
+ class Detail(DataStore):
677
+ """Market metadata keyed by Polymarket token id."""
678
+
679
+ _KEYS = ["token_id"]
680
+
681
+ @staticmethod
682
+ def _normalize_entry(market: dict[str, Any], token: dict[str, Any]) -> dict[str, Any]:
683
+ slug = market.get("slug")
684
+ outcome = token.get("outcome")
685
+ alias = slug if outcome is None else f"{slug}:{outcome}"
686
+
687
+ tick_size = (
688
+ market.get("minimum_tick_size")
689
+ or market.get("orderPriceMinTickSize")
690
+ or market.get("order_price_min_tick_size")
691
+ )
692
+ step_size = (
693
+ market.get("minimum_order_size")
694
+ or market.get("orderMinSize")
695
+ or market.get("order_min_size")
696
+ )
697
+
698
+ try:
699
+ tick_size = float(tick_size) if tick_size is not None else None
700
+ except (TypeError, ValueError):
701
+ tick_size = None
702
+ try:
703
+ step_size = float(step_size) if step_size is not None else None
704
+ except (TypeError, ValueError):
705
+ step_size = None
706
+
707
+ return {
708
+ "token_id": token.get("token_id") or token.get("id"),
709
+ "asset_id": token.get("token_id") or token.get("id"),
710
+ "alias": alias,
711
+ "question": market.get("question"),
712
+ "outcome": outcome,
713
+ "active": market.get("active"),
714
+ "closed": market.get("closed"),
715
+ "neg_risk": market.get("neg_risk"),
716
+ "tick_size": tick_size if tick_size is not None else 0.01,
717
+ "step_size": step_size if step_size is not None else 1.0,
718
+ "minimum_order_size": step_size if step_size is not None else 1.0,
719
+ "minimum_tick_size": tick_size if tick_size is not None else 0.01,
720
+ }
721
+
722
+ def on_response(self, markets: Iterable[dict[str, Any]]) -> dict[str, str]:
723
+ mapping: dict[str, str] = {}
724
+ records: list[dict[str, Any]] = []
725
+ for market in markets or []:
726
+ tokens = market.get("tokens") or []
727
+ if not tokens:
728
+ token_ids = market.get("clobTokenIds") or []
729
+ outcomes = market.get("outcomes") or []
730
+
731
+ if isinstance(token_ids, str):
732
+ try:
733
+ token_ids = json.loads(token_ids)
734
+ except json.JSONDecodeError:
735
+ token_ids = [token_ids]
736
+ if isinstance(outcomes, str):
737
+ try:
738
+ outcomes = json.loads(outcomes)
739
+ except json.JSONDecodeError:
740
+ outcomes = [outcomes]
741
+
742
+ if not isinstance(token_ids, list):
743
+ token_ids = [token_ids]
744
+ if not isinstance(outcomes, list):
745
+ outcomes = [outcomes]
746
+
747
+ tokens = [
748
+ {"token_id": tid, "outcome": outcomes[idx] if idx < len(outcomes) else None}
749
+ for idx, tid in enumerate(token_ids)
750
+ if tid
751
+ ]
752
+
753
+ for token in tokens:
754
+ normalized = self._normalize_entry(market, token)
755
+ slug: str = market.get("slug")
756
+ # 取最后一个'-'之前部分
757
+ base_slug = slug.rsplit("-", 1)[0] if slug else slug
758
+ # Add or update additional fields from market
759
+ normalized.update({
760
+ "condition_id": market.get("conditionId"),
761
+ "slug": market.get("slug"),
762
+ "base_slug": base_slug,
763
+ "end_date": market.get("endDate"),
764
+ "start_date": market.get("startDate"),
765
+ "icon": market.get("icon"),
766
+ "image": market.get("image"),
767
+ "liquidity": market.get("liquidityNum") or market.get("liquidity"),
768
+ "volume": market.get("volumeNum") or market.get("volume"),
769
+ "accepting_orders": market.get("acceptingOrders"),
770
+ "spread": market.get("spread"),
771
+ "best_bid": market.get("bestBid"),
772
+ "best_ask": market.get("bestAsk"),
773
+ })
774
+ token_id = normalized.get("token_id")
775
+ if not token_id:
776
+ continue
777
+ records.append(normalized)
778
+ mapping[token_id] = normalized.get("alias") or token_id
779
+
780
+ self._update(records)
781
+ return mapping
782
+
783
+
784
+ class PolymarketDataStore(DataStoreCollection):
785
+ """Polymarket-specific DataStore aggregate."""
786
+
787
+ def _init(self) -> None:
788
+ self._create("book", datastore_class=Book)
789
+ self._create("bbo", datastore_class=BBO)
790
+ self._create("detail", datastore_class=Detail)
791
+ self._create("position", datastore_class=Position)
792
+ self._create("order", datastore_class=Order)
793
+ self._create("mytrade", datastore_class=MyTrade)
794
+ self._create("fill", datastore_class=Fill)
795
+ self._create("trade", datastore_class=Trade)
796
+ self._create("price", datastore_class=Price)
797
+
798
+ @property
799
+ def book(self) -> Book:
800
+ """Order Book DataStore
801
+ _key: k (asset_id), S (side), p (price)
802
+
803
+ .. code:: json
804
+ [{
805
+ "k": "asset_id",
806
+ "S": "b" | "a",
807
+ "p": "price",
808
+ "q": "size"
809
+ }]
810
+ """
811
+ return self._get("book")
812
+
813
+ @property
814
+ def detail(self) -> Detail:
815
+ """
816
+ Market metadata keyed by token id.
817
+
818
+ .. code:: json
819
+
820
+ [
821
+ {
822
+ "token_id": "14992165475527298486519422865149275159537493330633013685269145597531945526992",
823
+ "asset_id": "14992165475527298486519422865149275159537493330633013685269145597531945526992",
824
+ "alias": "Bitcoin Up or Down - November 12, 12:30AM-12:45AM ET:Down",
825
+ "question": "Bitcoin Up or Down - November 12, 12:30AM-12:45AM ET",
826
+ "outcome": "Down",
827
+ "active": true,
828
+ "closed": false,
829
+ "neg_risk": null,
830
+ "tick_size": 0.01,
831
+ "step_size": 5.0,
832
+ "minimum_order_size": 5.0,
833
+ "minimum_tick_size": 0.01,
834
+ "condition_id": "0xb64133e5ae9710fab2533cfd3c48cba142347e4bab36822964ca4cca4b7660d2",
835
+ "slug": "btc-updown-15m-1762925400",
836
+ "end_date": "2025-11-12T05:45:00Z",
837
+ "start_date": "2025-11-11T05:32:59.491174Z",
838
+ "icon": "https://polymarket-upload.s3.us-east-2.amazonaws.com/BTC+fullsize.png",
839
+ "image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/BTC+fullsize.png",
840
+ "liquidity": 59948.1793,
841
+ "volume": 12214.600385,
842
+ "accepting_orders": true,
843
+ "spread": 0.01,
844
+ "best_bid": 0.5,
845
+ "best_ask": 0.51
846
+ }
847
+ ]
848
+ """
849
+
850
+ return self._get("detail")
851
+
852
+ @property
853
+ def position(self) -> Position:
854
+ """
855
+
856
+ .. code:: python
857
+
858
+ [{
859
+ # 🔑 基础信息
860
+ "proxyWallet": "0x56687bf447db6ffa42ffe2204a05edaa20f55839", # 代理钱包地址(用于代表用户在链上的交易地址)
861
+ "asset": "<string>", # outcome token 资产地址或 symbol
862
+ "conditionId": "0xdd22472e552920b8438158ea7238bfadfa4f736aa4cee91a6b86c39ead110917", # 市场条件 ID(event 的唯一标识)
863
+
864
+ # 💰 交易与价格信息
865
+ "size": 123, # 当前持仓数量(仅在未平仓时存在)
866
+ "avgPrice": 123, # 平均买入价(每个 outcome token 的均价)
867
+ "curPrice": 123, # 当前市场价格
868
+ "initialValue": 123, # 初始建仓总价值(avgPrice × size)
869
+ "currentValue": 123, # 当前持仓市值(curPrice × size)
870
+
871
+ # 📊 盈亏指标
872
+ "cashPnl": 123, # 未实现盈亏(当前浮动盈亏)
873
+ "percentPnl": 123, # 未实现盈亏百分比
874
+ "realizedPnl": 123, # 已实现盈亏(平仓后的实际收益)
875
+ "percentRealizedPnl": 123, # 已实现盈亏百分比(相对成本的收益率)
876
+
877
+ # 🧮 累计交易信息
878
+ "totalBought": 123, # 累计买入数量(含历史)
879
+
880
+ # ⚙️ 状态标志
881
+ "redeemable": True, # 是否可赎回(True 表示市场已结算且你是赢家,可提取 USDC)
882
+ "mergeable": True, # 是否可合并(多笔相同 outcome 可合并为一笔)
883
+ "negativeRisk": True, # 是否为负风险组合(风险对冲导致净敞口为负)
884
+
885
+ # 🧠 市场元数据
886
+ "title": "<string>", # 市场标题(如 “Bitcoin up or down 15m”)
887
+ "slug": "<string>", # outcome 唯一 slug(对应前端页面路径的一部分)
888
+ "eventSlug": "<string>", # event slug(整个预测事件的唯一路径标识)
889
+ "icon": "<string>", # 图标 URL(一般为事件关联资产)
890
+ "outcome": "<string>", # 当前持有的 outcome 名称(例如 “Yes” 或 “No”)
891
+ "outcomeIndex": 123, # outcome 在该市场中的索引(0 或 1)
892
+ "oppositeOutcome": "<string>",# 对立 outcome 名称
893
+ "oppositeAsset": "<string>", # 对立 outcome token 地址
894
+ "endDate": "<string>", # 市场结束时间(UTC ISO 格式字符串)
895
+ }]
896
+ """
897
+
898
+ return self._get("position")
899
+
900
+ @property
901
+ def orders(self) -> Order:
902
+ """User orders keyed by order id.
903
+
904
+ Example row (from REST get_orders):
905
+
906
+ .. code:: json
907
+ {
908
+ "id": "0xd4359d…",
909
+ "status": "LIVE",
910
+ "owner": "<api-key>",
911
+ "maker_address": "0x…",
912
+ "market": "0x…",
913
+ "asset_id": "317234…",
914
+ "side": "BUY",
915
+ "original_size": 5.0,
916
+ "size_matched": 0.0,
917
+ "price": 0.02,
918
+ "outcome": "Up",
919
+ "order_type": "GTC",
920
+ "created_at": 1762912331
921
+ }
922
+
923
+ """
924
+
925
+ return self._get("order")
926
+
927
+ @property
928
+ def mytrade(self) -> MyTrade:
929
+ """User trade stream keyed by trade id.
930
+
931
+ Columns include Polymarket websocket ``trade`` payloads, e.g.
932
+
933
+ .. code:: json
934
+ {
935
+ "event_type": "trade",
936
+ "id": "28c4d2eb-bbea-40e7-a9f0-b2fdb56b2c2e",
937
+ "market": "0xbd31…",
938
+ "asset_id": "521143…",
939
+ "side": "BUY",
940
+ "price": 0.57,
941
+ "size": 10,
942
+ "status": "MATCHED",
943
+ "maker_orders": [ ... ]
944
+ }
945
+ """
946
+
947
+ return self._get("trade")
948
+
949
+ @property
950
+ def price(self) -> Price:
951
+ """Price DataStore
952
+ _key: s
953
+ """
954
+ return self._get("price")
955
+
956
+ @property
957
+ def fill(self) -> Fill:
958
+ """Maker-order fills keyed by ``order_id``.
959
+
960
+ A row is created whenever a trade arrives with ``status == 'MATCHED'``.
961
+ ``matched_amount`` and ``price`` are stored as floats for quick PnL math.
962
+
963
+ .. code:: json
964
+ {
965
+ "order_id": "0xb46574626be7eb57a8fa643eac5623bdb2ec42104e2dc3441576a6ed8d0cc0ed",
966
+ "owner": "1aa9c6be-02d2-c021-c5fc-0c5b64ba8fd6",
967
+ "maker_address": "0x64A46A989363eb21DAB87CD53d57A4567Ccbc103",
968
+ "matched_amount": "1.35",
969
+ "price": "0.73",
970
+ "fee_rate_bps": "0",
971
+ "asset_id": "60833383978754019365794467018212448484210363665632025956221025028271757152271",
972
+ "outcome": "Up",
973
+ "outcome_index": 0,
974
+ "side": "BUY"
975
+ }
976
+ """
977
+
978
+ return self._get("fill")
979
+
980
+ @property
981
+ def bbo(self) -> BBO:
982
+ """Best Bid and Offer DataStore
983
+ _key: s (asset_id), S (side)
984
+
985
+ """
986
+ return self._get("bbo")
987
+
988
+ @property
989
+ def trade(self) -> Trade:
990
+ """
991
+ _key asset
992
+ MATCHED进行快速捕捉
993
+ .. code:: json
994
+ {
995
+ "asset": "12819879685513143002408869746992985182419696851931617234615358342350852997413",
996
+ "bio": "",
997
+ "conditionId": "0xea609d2c6bc2cb20e328be7c89f258b84b35bbe119b44e0a2cfc5f15e6642b3b",
998
+ "eventSlug": "btc-updown-15m-1763865000",
999
+ "icon": "https://polymarket-upload.s3.us-east-2.amazonaws.com/BTC+fullsize.png",
1000
+ "name": "infusion",
1001
+ "outcome": "Up",
1002
+ "outcomeIndex": 0,
1003
+ "price": 0.7,
1004
+ "profileImage": "",
1005
+ "proxyWallet": "0x2C060830B6F6B43174b1Cf8B4475db07703c1543",
1006
+ "pseudonym": "Frizzy-Graduate",
1007
+ "side": "BUY",
1008
+ "size": 5,
1009
+ "slug": "btc-updown-15m-1763865000",
1010
+ "timestamp": 1763865085,
1011
+ "title": "Bitcoin Up or Down - November 22, 9:30PM-9:45PM ET",
1012
+ "hash": "0xddea11d695e811686f83379d9269accf1be581fbcb542809c6c67a3cc3002488"
1013
+ }
1014
+ """
1015
+ return self._get("trade")
1016
+
1017
+
1018
+ def onmessage(self, msg: Any, ws: ClientWebSocketResponse | None = None) -> None:
1019
+ # 判定msg是否为list
1020
+ lst_msg = msg if isinstance(msg, list) else [msg]
1021
+ for m in lst_msg:
1022
+ if m == '':
1023
+ continue
1024
+ topic = m.get("topic") or ""
1025
+ if topic in {'crypto_prices_chainlink', 'crypto_prices'}:
1026
+ self.price._on_message(m)
1027
+ continue
1028
+ raw_type = m.get("event_type") or m.get("type")
1029
+ if not raw_type:
1030
+ continue
1031
+ msg_type = str(raw_type).lower()
1032
+ if msg_type in {"book", "price_change"}:
1033
+ self.book._on_message(m)
1034
+ elif msg_type == "order":
1035
+ self.orders._on_message(m)
1036
+ self.position._on_order(m)
1037
+
1038
+ elif msg_type == "trade":
1039
+ self.mytrade._on_message(m)
1040
+ # self.fill._on_trade(m)
1041
+ # self.position.on_trade(m)
1042
+ elif msg_type == 'orders_matched':
1043
+ payload = m.get("payload") or {}
1044
+ if not payload:
1045
+ continue
1046
+ trade_msg = dict(payload)
1047
+ if "asset_id" not in trade_msg and "asset" in trade_msg:
1048
+ trade_msg["asset_id"] = trade_msg["asset"]
1049
+ self.trade._on_message(trade_msg)
1050
+
1051
+ def onmessage_for_bbo(self, msg: Any, ws: ClientWebSocketResponse | None = None) -> None:
1052
+ # 判定msg是否为list
1053
+ lst_msg = msg if isinstance(msg, list) else [msg]
1054
+ for m in lst_msg:
1055
+ raw_type = m.get("event_type") or m.get("type")
1056
+ if not raw_type:
1057
+ continue
1058
+ msg_type = str(raw_type).lower()
1059
+ if msg_type in {"book", "price_change"}:
1060
+ self.bbo._on_message(m)
1061
+
1062
+ def onmessage_for_last_trade(self, msg, ws = None):
1063
+ # 判定msg是否为list
1064
+ lst_msg = msg if isinstance(msg, list) else [msg]
1065
+ for m in lst_msg:
1066
+ raw_type = m.get("event_type") or m.get("type")
1067
+ if not raw_type:
1068
+ continue
1069
+ msg_type = str(raw_type).lower()
1070
+ if msg_type == "last_trade_price":
1071
+ self.trade._on_message(m)