hyperquant 1.47__tar.gz → 1.49__tar.gz

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.
Files changed (46) hide show
  1. {hyperquant-1.47 → hyperquant-1.49}/PKG-INFO +1 -1
  2. {hyperquant-1.47 → hyperquant-1.49}/pyproject.toml +2 -2
  3. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/auth.py +11 -6
  4. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/polymarket.py +142 -15
  5. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/polymarket.py +166 -40
  6. {hyperquant-1.47 → hyperquant-1.49}/uv.lock +2 -593
  7. {hyperquant-1.47 → hyperquant-1.49}/.gitignore +0 -0
  8. {hyperquant-1.47 → hyperquant-1.49}/README.md +0 -0
  9. {hyperquant-1.47 → hyperquant-1.49}/requirements-dev.lock +0 -0
  10. {hyperquant-1.47 → hyperquant-1.49}/requirements.lock +0 -0
  11. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/__init__.py +0 -0
  12. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/bitget.py +0 -0
  13. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/bitmart.py +0 -0
  14. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/coinw.py +0 -0
  15. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/deepcoin.py +0 -0
  16. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/edgex.py +0 -0
  17. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/hyperliquid.py +0 -0
  18. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/lbank.py +0 -0
  19. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/lib/edgex_sign.py +0 -0
  20. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/lib/hpstore.py +0 -0
  21. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/lib/hyper_types.py +0 -0
  22. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/lib/polymarket/ctfAbi.py +0 -0
  23. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/lib/polymarket/safeAbi.py +0 -0
  24. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/lib/util.py +0 -0
  25. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/lighter.py +0 -0
  26. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/apexpro.py +0 -0
  27. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/bitget.py +0 -0
  28. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/bitmart.py +0 -0
  29. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/coinw.py +0 -0
  30. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/deepcoin.py +0 -0
  31. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/edgex.py +0 -0
  32. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/hyperliquid.py +0 -0
  33. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/lbank.py +0 -0
  34. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/lighter.py +0 -0
  35. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/ourbit.py +0 -0
  36. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/ourbit.py +0 -0
  37. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/ws.py +0 -0
  38. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/core.py +0 -0
  39. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/datavison/_util.py +0 -0
  40. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/datavison/binance.py +0 -0
  41. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/datavison/coinglass.py +0 -0
  42. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/datavison/okx.py +0 -0
  43. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/db.py +0 -0
  44. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/draw.py +0 -0
  45. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/logkit.py +0 -0
  46. {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/notikit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperquant
3
- Version: 1.47
3
+ Version: 1.49
4
4
  Summary: A minimal yet hyper-efficient backtesting framework for quantitative trading
5
5
  Project-URL: Homepage, https://github.com/yourusername/hyperquant
6
6
  Project-URL: Issues, https://github.com/yourusername/hyperquant/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hyperquant"
3
- version = "1.47"
3
+ version = "1.49"
4
4
  description = "A minimal yet hyper-efficient backtesting framework for quantitative trading"
5
5
  authors = [
6
6
  { name = "MissinA", email = "1421329142@qq.com" }
@@ -17,7 +17,7 @@ dependencies = [
17
17
  "eth-account>=0.10.0",
18
18
  "web3>=7.14.0",
19
19
  "python-dotenv>=1.2.1",
20
- "coincurve>=21.0.0",
20
+ "coincurve>=21.0.0"
21
21
  ]
22
22
  readme = "README.md"
23
23
  requires-python = ">=3.13"
@@ -13,11 +13,18 @@ import json as pyjson
13
13
  from urllib.parse import urlencode
14
14
  from datetime import datetime, timezone
15
15
  from eth_account import Account
16
- from eth_account.messages import encode_typed_data
16
+ try:
17
+ from eth_account.messages import encode_typed_data
18
+ except ImportError:
19
+ from eth_account.messages import encode_structured_data as encode_typed_data
17
20
  from eth_utils import keccak, to_checksum_address
18
21
  import secrets
19
22
  from random import random
20
23
 
24
+ try:
25
+ from coincurve import PrivateKey as _CoincurvePrivateKey
26
+ except Exception: # pragma: no cover - optional dependency
27
+ _CoincurvePrivateKey = None
21
28
 
22
29
  POLY_ADDRESS = "POLY_ADDRESS"
23
30
  POLY_SIGNATURE = "POLY_SIGNATURE"
@@ -859,10 +866,8 @@ class Auth:
859
866
  Avoids heavy typed-data helpers by caching type/domain hashes and
860
867
  signing the final digest directly.
861
868
  """
862
- try:
863
- from coincurve import PrivateKey
864
- except Exception as e: # pragma: no cover - optional dependency
865
- raise RuntimeError("coincurve is required for sign_polymarket_order2") from e
869
+ if _CoincurvePrivateKey is None:
870
+ raise RuntimeError("coincurve is required for sign_polymarket_order2")
866
871
 
867
872
  try:
868
873
  now_ts = datetime.now().replace(tzinfo=timezone.utc).timestamp()
@@ -901,7 +906,7 @@ class Auth:
901
906
  typed_hash = keccak(b"\x19\x01" + domain_sep + msg_hash)
902
907
 
903
908
  pk_bytes = bytes.fromhex(private_key[2:] if private_key.startswith("0x") else private_key)
904
- sig65 = PrivateKey(pk_bytes).sign_recoverable(typed_hash, hasher=None)
909
+ sig65 = _CoincurvePrivateKey(pk_bytes).sign_recoverable(typed_hash, hasher=None)
905
910
  r, s, rec_id = sig65[:32], sig65[32:64], sig65[64]
906
911
  v = rec_id + 27 # align with eth_account v
907
912
  signature = "0x" + (r + s + bytes([v])).hex()
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import json
4
4
  from dataclasses import dataclass, field
5
5
  from heapq import heappop, heappush
6
+ import time
6
7
  from typing import TYPE_CHECKING, Any, Iterable
7
8
 
8
9
  from pybotters.store import DataStore, DataStoreCollection
@@ -15,11 +16,37 @@ if TYPE_CHECKING:
15
16
  class Position(DataStore):
16
17
  """Position DataStore keyed by Polymarket token id."""
17
18
 
18
- _KEYS = ["asset", "outcome"]
19
+ _KEYS = ["asset"]
19
20
 
20
- def _on_response(self, msg: Item) -> None:
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:
21
46
  if msg:
22
47
  self._clear()
48
+ for rec in msg:
49
+ rec["ts"] = 0
23
50
  self._update(msg)
24
51
 
25
52
  def on_trade(self, trade: Item) -> None:
@@ -33,6 +60,7 @@ class Position(DataStore):
33
60
  size_raw = trade.get("size")
34
61
  price_raw = trade.get("price")
35
62
 
63
+
36
64
  if not asset_id or not outcome or side not in {"BUY", "SELL"}:
37
65
  return
38
66
 
@@ -45,6 +73,8 @@ class Position(DataStore):
45
73
  except (TypeError, ValueError):
46
74
  price = None
47
75
 
76
+
77
+
48
78
  key = {"asset": asset_id, "outcome": outcome}
49
79
  existing = self.get(key) or {}
50
80
 
@@ -86,6 +116,85 @@ class Position(DataStore):
86
116
  else:
87
117
  self._insert([rec])
88
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
+
89
198
 
90
199
  class Fill(DataStore):
91
200
  """Fill records keyed by maker order id."""
@@ -169,6 +278,7 @@ class Order(DataStore):
169
278
  return normalized
170
279
 
171
280
  def _on_response(self, items: list[dict[str, Any]] | dict[str, Any]) -> None:
281
+ """增量同步:insert新增、update变更、delete消失的订单"""
172
282
  rows: list[dict[str, Any]] = []
173
283
  if isinstance(items, dict):
174
284
  items = [items]
@@ -176,20 +286,34 @@ class Order(DataStore):
176
286
  norm = self._normalize(it)
177
287
  if norm:
178
288
  rows.append(norm)
179
- self._clear()
180
- if rows:
181
- self._insert(rows)
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])
182
307
 
183
308
  def _on_message(self, msg: dict[str, Any]) -> None:
184
- norm = self._normalize(msg)
185
- if not norm:
186
- return
187
- key = {"id": norm["id"]}
188
- if self.get(key):
189
- self._update([norm])
190
- else:
191
- self._insert([norm])
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])
192
314
 
315
+ if status in {"CANCELED", "MATCHED"}:
316
+ self._delete([msg])
193
317
 
194
318
  class MyTrade(DataStore):
195
319
  """User trades keyed by trade id."""
@@ -795,6 +919,7 @@ class PolymarketDataStore(DataStoreCollection):
795
919
  "order_type": "GTC",
796
920
  "created_at": 1762912331
797
921
  }
922
+
798
923
  """
799
924
 
800
925
  return self._get("order")
@@ -908,10 +1033,12 @@ class PolymarketDataStore(DataStoreCollection):
908
1033
  self.book._on_message(m)
909
1034
  elif msg_type == "order":
910
1035
  self.orders._on_message(m)
1036
+ self.position._on_order(m)
1037
+
911
1038
  elif msg_type == "trade":
912
1039
  self.mytrade._on_message(m)
913
- self.fill._on_trade(m)
914
- self.position.on_trade(m)
1040
+ # self.fill._on_trade(m)
1041
+ # self.position.on_trade(m)
915
1042
  elif msg_type == 'orders_matched':
916
1043
  payload = m.get("payload") or {}
917
1044
  if not payload:
@@ -1,10 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import logging
4
5
  from contextlib import suppress
5
6
  from datetime import UTC, datetime, timedelta
6
7
  from functools import lru_cache
7
8
  import os
9
+ import time
8
10
  from typing import Any, Iterable, Iterator, Literal, Mapping, Sequence
9
11
 
10
12
  import json
@@ -27,6 +29,7 @@ DEFAULT_BASE_SLUG = "btc-updown-15m"
27
29
  HOURLY_BITCOIN_BASE_SLUG = "bitcoin-up-or-down"
28
30
  DEFAULT_INTERVAL = 15 * 60
29
31
  DEFAULT_WINDOW = 8
32
+ TICK_SIZE_CACHE_TTL_SECS = 300
30
33
  API_NAME = "polymarket"
31
34
  END_CURSOR = "LTE="
32
35
  USDC_CONTRACT = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
@@ -227,6 +230,16 @@ class Polymarket:
227
230
  signature_type: int | None = None,
228
231
  funder: str | None = None
229
232
  ) -> None:
233
+ # Logger (per-class, safe default)
234
+ self.logger = logging.getLogger(f"{API_NAME}.{self.__class__.__name__}")
235
+ if not self.logger.handlers:
236
+ handler = logging.StreamHandler()
237
+ formatter = logging.Formatter(
238
+ "[%(asctime)s][%(levelname)s][%(name)s] %(message)s"
239
+ )
240
+ handler.setFormatter(formatter)
241
+ self.logger.addHandler(handler)
242
+ self.logger.setLevel(logging.INFO)
230
243
  self.client = client
231
244
  self.rest_api = (rest_api or DEFAULT_REST_ENDPOINT).rstrip("/")
232
245
  self.ws_public = ws_public or DEFAULT_WS_ENDPOINT
@@ -242,6 +255,7 @@ class Polymarket:
242
255
  self._ws_public_ready = asyncio.Event()
243
256
  self._ws_personal: pybotters.ws.WebSocketApp | None = None
244
257
  self.auth = False
258
+ self._tick_size_cache: dict[str, tuple[str, float]] = {}
245
259
 
246
260
  self._ensure_session_entry(private_key=private_key, funder=funder, chain_id=chain_id)
247
261
 
@@ -260,6 +274,25 @@ class Polymarket:
260
274
  self._ws_public = None
261
275
  self._ws_public_ready.clear()
262
276
 
277
+ async def prewarm_connections(
278
+ self,
279
+ endpoints: Sequence[str] | None = None,
280
+ timeout: float = 1.0,
281
+ ) -> None:
282
+ """Pre-warm HTTP connections to reduce first-request latency."""
283
+ if endpoints is None:
284
+ endpoints = ("/ok", "/time")
285
+ session = getattr(self.client, "_session", None)
286
+ if session is None:
287
+ raise RuntimeError("pybotters client session missing")
288
+ for endpoint in endpoints:
289
+ url = f"{self.rest_api}{endpoint}"
290
+ try:
291
+ async with session.get(url, timeout=timeout) as resp:
292
+ await resp.read()
293
+ except Exception:
294
+ continue
295
+
263
296
  # ------------------------------------------------------------------
264
297
  # Store helpers
265
298
 
@@ -271,22 +304,29 @@ class Polymarket:
271
304
  "book",
272
305
  "books",
273
306
  "position",
274
- "history_position",
275
307
  "orders",
276
- ] = "all",
308
+ ] | Sequence[str] = "all",
277
309
  *,
278
310
  token_ids: Sequence[str] | str | None = None,
279
311
  limit: int | None = None,
280
- funder: str | None = None,
281
- event_id: str | None = None
282
312
  ) -> None:
283
- """Refresh cached data using Polymarket REST endpoints."""
313
+ """Refresh cached data using Polymarket REST endpoints.
314
+
315
+ update_type 可以是单个字符串或列表,例如:
316
+ update_type='position'
317
+ update_type=['position', 'orders']
318
+ """
319
+ # 统一转为 set
320
+ if isinstance(update_type, str):
321
+ types = {update_type}
322
+ else:
323
+ types = set(update_type)
284
324
 
285
- include_detail = update_type in {"all", "detail", "markets"}
286
- include_books = update_type in {"all", "book", "books"}
287
- include_position = update_type in {"all", "position"}
288
- include_history_position = update_type in {"all", "history_position"}
289
- include_orders = update_type in {"all", "orders"}
325
+ include_detail = "all" in types or "detail" in types or "markets" in types
326
+ include_books = "all" in types or "book" in types or "books" in types
327
+ include_position = "all" in types or "position" in types
328
+ include_history_position = "history_position" in types
329
+ include_orders = "all" in types or "orders" in types
290
330
 
291
331
  if include_books and token_ids is None:
292
332
  raise ValueError("token_ids are required when updating books")
@@ -315,21 +355,11 @@ class Polymarket:
315
355
  )
316
356
 
317
357
  if include_position or include_history_position:
318
- funder = funder or self.funder
319
- path = '/positions' if include_position else '/closed-positions'
320
- params = {"user": funder, 'sizeThreshold': 0.1}
321
- if event_id:
322
- params.update({'eventId': event_id})
323
358
  tasks.append(
324
359
  (
325
360
  "position",
326
361
  asyncio.create_task(
327
- self._rest(
328
- "GET",
329
- path,
330
- params=params,
331
- host=DEFAULT_DATA_ENDPOINT
332
- )
362
+ self.get_mergeable_positions()
333
363
  ),
334
364
  )
335
365
  )
@@ -342,8 +372,25 @@ class Polymarket:
342
372
  raise ValueError(f"Unsupported update_type={update_type}")
343
373
 
344
374
  results: dict[str, Any] = {}
345
- for key, fut in tasks:
346
- results[key] = await fut
375
+
376
+ keys = [k for k, _ in tasks]
377
+ futs = [f for _, f in tasks]
378
+
379
+ done = await asyncio.gather(*futs, return_exceptions=True)
380
+
381
+ for key, res in zip(keys, done):
382
+ if isinstance(res, Exception):
383
+ # REST 更新为 best-effort:记录错误但不中断整体流程
384
+ try:
385
+ logger = getattr(self, "logger", None)
386
+ if logger:
387
+ logger.warning(f"[update] {key} failed: {res}", exc_info=True)
388
+ else:
389
+ print(f"[update] {key} failed: {res}")
390
+ except Exception:
391
+ pass
392
+ continue
393
+ results[key] = res
347
394
 
348
395
 
349
396
  if "books" in results:
@@ -441,12 +488,12 @@ class Polymarket:
441
488
 
442
489
  wsapp = self.client.ws_connect(
443
490
  RTS_DATA_ENDPOINT,
491
+ send_json=payload,
444
492
  hdlr_str=callback,
445
493
  heartbeat=5,
446
494
  )
447
495
 
448
496
  await wsapp._event.wait()
449
- await wsapp.current_ws.send_json(payload)
450
497
  return wsapp
451
498
 
452
499
 
@@ -480,6 +527,9 @@ class Polymarket:
480
527
  self,
481
528
  callback: Any = None,
482
529
  markets: Sequence[str] | None = None,
530
+ rest_sync: bool = True,
531
+ rest_order_sync_interval: int = 5,
532
+ rest_position_sync_interval: int = 8,
483
533
  ) -> pybotters.ws.WebSocketApp:
484
534
  """Subscribe to personal updates (requires authentication)."""
485
535
 
@@ -487,8 +537,15 @@ class Polymarket:
487
537
  if not creds:
488
538
  raise RuntimeError("Polymarket API credentials are required for personal subscriptions")
489
539
 
540
+ # 记录 position store 最后更新时间
541
+ last_position_update = time.time()
542
+
490
543
  def _handler(message, ws=None):
544
+ nonlocal last_position_update
491
545
  self.store.onmessage(message, ws)
546
+ # 检测是否是 position 相关消息
547
+ if isinstance(message, dict) and message.get('event_type') in ('order', 'trade'):
548
+ last_position_update = time.time()
492
549
  if callback:
493
550
  callback(message, ws)
494
551
 
@@ -503,14 +560,45 @@ class Polymarket:
503
560
  auth = {"apiKey": api_key, "secret": api_secret, "passphrase": api_passphrase}
504
561
  payload = {"markets": list(markets or []), "type": "user", "auth": auth}
505
562
 
563
+ # 在开始前用rest_api同步持仓
564
+ await self.update('position')
565
+
566
+ # 后台任务:3秒无更新则同步持仓
567
+ async def _rest_sync_watchdog():
568
+ nonlocal last_position_update
569
+ last_orders_update = time.time()
570
+ while True:
571
+ await asyncio.sleep(1)
572
+ now = time.time()
573
+ # position: 6秒无更新则同步
574
+ if now - last_position_update > rest_position_sync_interval:
575
+ try:
576
+ await self.update('position')
577
+ last_position_update = now
578
+ except Exception:
579
+ pass
580
+ # orders: 每3秒同步一次
581
+ if now - last_orders_update > rest_order_sync_interval:
582
+ try:
583
+ await self.update('orders')
584
+ last_orders_update = now
585
+ except Exception:
586
+ pass
587
+
588
+ if rest_sync:
589
+ asyncio.create_task(_rest_sync_watchdog())
590
+
591
+ # 使用 send_json 参数,这样重连后会自动重新订阅
506
592
  self._ws_personal = self.client.ws_connect(
507
593
  "wss://ws-subscriptions-clob.polymarket.com/ws/user",
594
+ send_json=payload,
508
595
  hdlr_json=effective_cb,
509
596
  heartbeat=30,
510
597
  auth=None,
511
598
  )
512
599
  await self._ws_personal._event.wait()
513
- await self._ws_personal.current_ws.send_json(payload)
600
+
601
+
514
602
  return self._ws_personal
515
603
 
516
604
  async def sub_trades(self, slug: str):
@@ -522,7 +610,6 @@ class Polymarket:
522
610
  "topic": "activity",
523
611
  "type": "orders_matched",
524
612
  "filters": json.dumps({"event_slug": slug}, separators=(',', ':'))
525
- # "filters": "{\"event_slug\":\"btc-updown-15m-1762951500\"}"
526
613
  }
527
614
  ]
528
615
  }
@@ -537,14 +624,14 @@ class Polymarket:
537
624
 
538
625
  self.store.onmessage(data, ws)
539
626
 
540
-
627
+ # 使用 send_json 参数,重连后自动重新订阅
541
628
  wsapp = self.client.ws_connect(
542
629
  RTS_DATA_ENDPOINT,
630
+ send_json=payload,
543
631
  hdlr_str=callback,
544
632
  heartbeat=5
545
633
  )
546
634
  await wsapp._event.wait()
547
- await wsapp.current_ws.send_json(payload)
548
635
  return wsapp
549
636
 
550
637
 
@@ -945,9 +1032,12 @@ class Polymarket:
945
1032
  request_path = path
946
1033
  url = f"{self.rest_api}{request_path}"
947
1034
  payload_obj = dict(body) if isinstance(body, dict) else body
948
- serialized = (
949
- str(payload_obj).replace("'", '"') if payload_obj is not None else ""
950
- )
1035
+ if payload_obj is None:
1036
+ serialized = ""
1037
+ elif isinstance(payload_obj, str):
1038
+ serialized = payload_obj
1039
+ else:
1040
+ serialized = json.dumps(payload_obj, separators=(",", ":"), ensure_ascii=False)
951
1041
  secret_bytes = base64.urlsafe_b64decode(api_secret)
952
1042
  msg = f"{ts}{method}{request_path}{serialized}"
953
1043
  sig = hmac.new(secret_bytes, msg.encode("utf-8"), hashlib.sha256).digest()
@@ -1181,18 +1271,24 @@ class Polymarket:
1181
1271
  async def _resolve_tick_size(self, token_id: str, tick_size: str | float | None) -> str:
1182
1272
  if tick_size is not None:
1183
1273
  return str(tick_size)
1274
+ cached = self._tick_size_cache.get(token_id)
1275
+ now = time.time()
1276
+ if cached and (now - cached[1]) < TICK_SIZE_CACHE_TTL_SECS:
1277
+ return cached[0]
1184
1278
  tick_resp = await self.get_tick_size(token_id)
1185
1279
  if isinstance(tick_resp, dict):
1186
- return str(tick_resp.get("minimum_tick_size") or tick_resp.get("tick_size") or "0.01")
1187
- return str(tick_resp)
1280
+ resolved = str(
1281
+ tick_resp.get("minimum_tick_size") or tick_resp.get("tick_size") or "0.01"
1282
+ )
1283
+ else:
1284
+ resolved = str(tick_resp)
1285
+ self._tick_size_cache[token_id] = (resolved, now)
1286
+ return resolved
1188
1287
 
1189
1288
  async def _resolve_fee_rate(self, token_id: str, fee_rate_bps: int | None) -> int:
1190
1289
  if fee_rate_bps is not None:
1191
1290
  return int(fee_rate_bps)
1192
- fee_resp = await self.get_fee_rate(token_id)
1193
- if isinstance(fee_resp, dict):
1194
- return int(fee_resp.get("base_fee", 0))
1195
- return int(fee_resp or 0)
1291
+ return 0
1196
1292
 
1197
1293
 
1198
1294
  async def _signed_request_via_session(
@@ -1231,9 +1327,12 @@ class Polymarket:
1231
1327
  payload_obj = dict(body)
1232
1328
  else:
1233
1329
  payload_obj = body
1234
- serialized = (
1235
- str(payload_obj).replace("'", '"') if payload_obj is not None else ""
1236
- )
1330
+ if payload_obj is None:
1331
+ serialized = ""
1332
+ elif isinstance(payload_obj, str):
1333
+ serialized = payload_obj
1334
+ else:
1335
+ serialized = json.dumps(payload_obj, separators=(",", ":"), ensure_ascii=False)
1237
1336
  msg = f"{ts}{method}{request_path}{serialized}"
1238
1337
  sig = hmac.new(secret_bytes, msg.encode("utf-8"), hashlib.sha256).digest()
1239
1338
  sign_b64 = base64.urlsafe_b64encode(sig).decode("utf-8")
@@ -1433,6 +1532,33 @@ class Polymarket:
1433
1532
  position = position / 1e6
1434
1533
  return position
1435
1534
 
1535
+ async def get_mergeable_positions(
1536
+ self,
1537
+ *,
1538
+ size_threshold: float = 0.1,
1539
+ limit: int = 100,
1540
+ sort_by: str = "TOKENS",
1541
+ sort_direction: str = "DESC",
1542
+ user: str | None = None,
1543
+ neg_risk: bool = False,
1544
+ mergeable: bool = True,
1545
+ ) -> Any:
1546
+ params = {
1547
+ "sizeThreshold": str(size_threshold),
1548
+ "limit": str(limit),
1549
+ "sortBy": sort_by,
1550
+ "sortDirection": sort_direction,
1551
+ "mergeable": str(mergeable).lower(),
1552
+ }
1553
+ if user is not None:
1554
+ params["user"] = user
1555
+ else:
1556
+ params['user'] = self.funder
1557
+
1558
+ if neg_risk:
1559
+ params["negRisk"] = "true"
1560
+ return await self._rest("GET", "/positions", params=params, host=DEFAULT_DATA_ENDPOINT)
1561
+
1436
1562
 
1437
1563
  async def merge_tokens_strict(
1438
1564
  self,