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.
- {hyperquant-1.47 → hyperquant-1.49}/PKG-INFO +1 -1
- {hyperquant-1.47 → hyperquant-1.49}/pyproject.toml +2 -2
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/auth.py +11 -6
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/polymarket.py +142 -15
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/polymarket.py +166 -40
- {hyperquant-1.47 → hyperquant-1.49}/uv.lock +2 -593
- {hyperquant-1.47 → hyperquant-1.49}/.gitignore +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/README.md +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/requirements-dev.lock +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/requirements.lock +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/__init__.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/bitget.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/bitmart.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/coinw.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/deepcoin.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/edgex.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/hyperliquid.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/lbank.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/lib/edgex_sign.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/lib/hpstore.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/lib/hyper_types.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/lib/polymarket/ctfAbi.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/lib/polymarket/safeAbi.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/lib/util.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/lighter.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/apexpro.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/bitget.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/bitmart.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/coinw.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/deepcoin.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/edgex.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/hyperliquid.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/lbank.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/lighter.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/models/ourbit.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/ourbit.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/broker/ws.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/core.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/datavison/_util.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/datavison/binance.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/datavison/coinglass.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/datavison/okx.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/db.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/draw.py +0 -0
- {hyperquant-1.47 → hyperquant-1.49}/src/hyperquant/logkit.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
863
|
-
|
|
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 =
|
|
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"
|
|
19
|
+
_KEYS = ["asset"]
|
|
19
20
|
|
|
20
|
-
def
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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 =
|
|
286
|
-
include_books =
|
|
287
|
-
include_position =
|
|
288
|
-
include_history_position =
|
|
289
|
-
include_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.
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
949
|
-
|
|
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
|
-
|
|
1187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1235
|
-
|
|
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,
|