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,2399 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Iterable, Iterator, Literal, Mapping, Sequence
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
import aiohttp
|
|
15
|
+
import pybotters
|
|
16
|
+
import pybotters.ws
|
|
17
|
+
import pytz
|
|
18
|
+
from web3 import Web3
|
|
19
|
+
|
|
20
|
+
from .models.polymarket import PolymarketDataStore
|
|
21
|
+
from .auth import Auth
|
|
22
|
+
|
|
23
|
+
DEFAULT_REST_ENDPOINT = "https://clob.polymarket.com"
|
|
24
|
+
DEFAULT_WS_ENDPOINT = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
|
|
25
|
+
GAMMA_EVENTS_API = "https://gamma-api.polymarket.com/events"
|
|
26
|
+
DEFAULT_DATA_ENDPOINT = "https://data-api.polymarket.com"
|
|
27
|
+
RTS_DATA_ENDPOINT = "wss://ws-live-data.polymarket.com/"
|
|
28
|
+
DEFAULT_BASE_SLUG = "btc-updown-15m"
|
|
29
|
+
HOURLY_BITCOIN_BASE_SLUG = "bitcoin-up-or-down"
|
|
30
|
+
DEFAULT_INTERVAL = 15 * 60
|
|
31
|
+
DEFAULT_WINDOW = 8
|
|
32
|
+
API_NAME = "polymarket"
|
|
33
|
+
END_CURSOR = "LTE="
|
|
34
|
+
USDC_CONTRACT = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
|
35
|
+
ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
|
|
36
|
+
ZERO_BYTES32 = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
|
37
|
+
ZERO_B32 = ZERO_BYTES32
|
|
38
|
+
USDCE_DIGITS = 6
|
|
39
|
+
ERC20_BALANCE_OF_ABI = (
|
|
40
|
+
"[{\"constant\":true,\"inputs\":[{\"name\":\"account\",\"type\":\"address\"}],"
|
|
41
|
+
"\"name\":\"balanceOf\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],"
|
|
42
|
+
"\"payable\":false,\"stateMutability\":\"view\",\"type\":\"function\"}]"
|
|
43
|
+
)
|
|
44
|
+
NEG_RISK_ADAPTER_ADDRESS = '0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296'
|
|
45
|
+
|
|
46
|
+
CONDITIONAL_TOKENS_ABI = [
|
|
47
|
+
{
|
|
48
|
+
"inputs": [
|
|
49
|
+
{
|
|
50
|
+
"internalType": "contract IERC20",
|
|
51
|
+
"name": "collateralToken",
|
|
52
|
+
"type": "address",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"internalType": "bytes32",
|
|
56
|
+
"name": "parentCollectionId",
|
|
57
|
+
"type": "bytes32",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"internalType": "bytes32",
|
|
61
|
+
"name": "conditionId",
|
|
62
|
+
"type": "bytes32",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"internalType": "uint256[]",
|
|
66
|
+
"name": "indexSets",
|
|
67
|
+
"type": "uint256[]",
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
"name": "redeemPositions",
|
|
71
|
+
"outputs": [],
|
|
72
|
+
"stateMutability": "nonpayable",
|
|
73
|
+
"type": "function",
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
CONDITIONAL_TOKENS_SPLIT_ABI = [
|
|
78
|
+
{
|
|
79
|
+
"constant": False,
|
|
80
|
+
"inputs": [
|
|
81
|
+
{"name": "collateralToken", "type": "address"},
|
|
82
|
+
{"name": "parentCollectionId", "type": "bytes32"},
|
|
83
|
+
{"name": "CONDITION_ID", "type": "bytes32"},
|
|
84
|
+
{"name": "partition", "type": "uint256[]"},
|
|
85
|
+
{"name": "amount", "type": "uint256"},
|
|
86
|
+
],
|
|
87
|
+
"name": "splitPosition",
|
|
88
|
+
"outputs": [],
|
|
89
|
+
"payable": False,
|
|
90
|
+
"stateMutability": "nonpayable",
|
|
91
|
+
"type": "function",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"constant": False,
|
|
95
|
+
"inputs": [
|
|
96
|
+
{"name": "collateralToken", "type": "address"},
|
|
97
|
+
{"name": "parentCollectionId", "type": "bytes32"},
|
|
98
|
+
{"name": "CONDITION_ID", "type": "bytes32"},
|
|
99
|
+
{"name": "partition", "type": "uint256[]"},
|
|
100
|
+
{"name": "amount", "type": "uint256"},
|
|
101
|
+
],
|
|
102
|
+
"name": "mergePositions",
|
|
103
|
+
"outputs": [],
|
|
104
|
+
"payable": False,
|
|
105
|
+
"stateMutability": "nonpayable",
|
|
106
|
+
"type": "function",
|
|
107
|
+
},
|
|
108
|
+
]
|
|
109
|
+
DEFAULT_POLYGON_RPCS = (
|
|
110
|
+
# "https://polygon.llamarpc.com",
|
|
111
|
+
"https://polygon-rpc.com",
|
|
112
|
+
"https://rpc.ankr.com/polygon",
|
|
113
|
+
)
|
|
114
|
+
SAFE_ABI = [
|
|
115
|
+
{
|
|
116
|
+
"inputs": [],
|
|
117
|
+
"name": "nonce",
|
|
118
|
+
"outputs": [{"internalType": "uint256", "name": "nonce", "type": "uint256"}],
|
|
119
|
+
"stateMutability": "view",
|
|
120
|
+
"type": "function",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"inputs": [
|
|
124
|
+
{"internalType": "address", "name": "to", "type": "address"},
|
|
125
|
+
{"internalType": "uint256", "name": "value", "type": "uint256"},
|
|
126
|
+
{"internalType": "bytes", "name": "data", "type": "bytes"},
|
|
127
|
+
{"internalType": "uint8", "name": "operation", "type": "uint8"},
|
|
128
|
+
{"internalType": "uint256", "name": "safeTxGas", "type": "uint256"},
|
|
129
|
+
{"internalType": "uint256", "name": "baseGas", "type": "uint256"},
|
|
130
|
+
{"internalType": "uint256", "name": "gasPrice", "type": "uint256"},
|
|
131
|
+
{"internalType": "address", "name": "gasToken", "type": "address"},
|
|
132
|
+
{"internalType": "address", "name": "refundReceiver", "type": "address"},
|
|
133
|
+
{"internalType": "uint256", "name": "_nonce", "type": "uint256"},
|
|
134
|
+
],
|
|
135
|
+
"name": "getTransactionHash",
|
|
136
|
+
"outputs": [{"internalType": "bytes32", "name": "txHash", "type": "bytes32"}],
|
|
137
|
+
"stateMutability": "view",
|
|
138
|
+
"type": "function",
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"inputs": [
|
|
142
|
+
{"internalType": "address", "name": "to", "type": "address"},
|
|
143
|
+
{"internalType": "uint256", "name": "value", "type": "uint256"},
|
|
144
|
+
{"internalType": "bytes", "name": "data", "type": "bytes"},
|
|
145
|
+
{"internalType": "uint8", "name": "operation", "type": "uint8"},
|
|
146
|
+
{"internalType": "uint256", "name": "safeTxGas", "type": "uint256"},
|
|
147
|
+
{"internalType": "uint256", "name": "baseGas", "type": "uint256"},
|
|
148
|
+
{"internalType": "uint256", "name": "gasPrice", "type": "uint256"},
|
|
149
|
+
{"internalType": "address", "name": "gasToken", "type": "address"},
|
|
150
|
+
{"internalType": "address", "name": "refundReceiver", "type": "address"},
|
|
151
|
+
{"internalType": "bytes", "name": "signatures", "type": "bytes"},
|
|
152
|
+
],
|
|
153
|
+
"name": "execTransaction",
|
|
154
|
+
"outputs": [{"internalType": "bool", "name": "success", "type": "bool"}],
|
|
155
|
+
"stateMutability": "payable",
|
|
156
|
+
"type": "function",
|
|
157
|
+
},
|
|
158
|
+
]
|
|
159
|
+
_EASTERN_TZ = pytz.timezone("US/Eastern")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
from .lib.polymarket.ctfAbi import ctf_abi
|
|
164
|
+
from .lib.polymarket.safeAbi import safe_abi
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
USDCE_DIGITS = 6
|
|
168
|
+
|
|
169
|
+
def parse_field(value):
|
|
170
|
+
"""尝试将字符串 JSON 转为对象,否则原样返回"""
|
|
171
|
+
if isinstance(value, str):
|
|
172
|
+
try:
|
|
173
|
+
return json.loads(value)
|
|
174
|
+
except json.JSONDecodeError:
|
|
175
|
+
return value
|
|
176
|
+
return value
|
|
177
|
+
|
|
178
|
+
def _iter_offsets(window: int) -> Iterator[int]:
|
|
179
|
+
yield 0
|
|
180
|
+
for step in range(1, window + 1):
|
|
181
|
+
yield step
|
|
182
|
+
yield -step
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _parse_list(value: Any) -> list[Any]:
|
|
186
|
+
if isinstance(value, str):
|
|
187
|
+
try:
|
|
188
|
+
return json.loads(value)
|
|
189
|
+
except json.JSONDecodeError:
|
|
190
|
+
return []
|
|
191
|
+
if value is None:
|
|
192
|
+
return []
|
|
193
|
+
return list(value)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _accepting_orders(market: Mapping[str, Any]) -> bool:
|
|
197
|
+
accepting = market.get("acceptingOrders") or market.get("accepting_orders")
|
|
198
|
+
if isinstance(accepting, str):
|
|
199
|
+
return accepting.lower() == "true"
|
|
200
|
+
return bool(accepting)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _compose_hourly_slug(base_slug: str, *, now: datetime | None = None) -> str:
|
|
204
|
+
tz_now = now or datetime.now(_EASTERN_TZ)
|
|
205
|
+
if tz_now.tzinfo is None:
|
|
206
|
+
tz_now = _EASTERN_TZ.localize(tz_now)
|
|
207
|
+
else:
|
|
208
|
+
tz_now = tz_now.astimezone(_EASTERN_TZ)
|
|
209
|
+
|
|
210
|
+
tz_now = (tz_now + timedelta(seconds=5)).replace(minute=0, second=0, microsecond=0)
|
|
211
|
+
month_str = tz_now.strftime("%B").lower()
|
|
212
|
+
day = tz_now.day
|
|
213
|
+
hour_12 = tz_now.strftime("%I").lstrip("0") or "0"
|
|
214
|
+
am_pm = tz_now.strftime("%p").lower()
|
|
215
|
+
return f"{base_slug}-{month_str}-{day}-{hour_12}{am_pm}-et"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class Polymarket:
|
|
219
|
+
"""Polymarket CLOB client with REST helpers, stores and WS subscriptions."""
|
|
220
|
+
|
|
221
|
+
def __init__(
|
|
222
|
+
self,
|
|
223
|
+
client: pybotters.Client,
|
|
224
|
+
*,
|
|
225
|
+
rest_api: str | None = None,
|
|
226
|
+
ws_public: str | None = None,
|
|
227
|
+
private_key: str | None = None,
|
|
228
|
+
chain_id: int | None = None,
|
|
229
|
+
signature_type: int | None = None,
|
|
230
|
+
funder: str | None = None
|
|
231
|
+
) -> None:
|
|
232
|
+
# Logger (per-class, safe default)
|
|
233
|
+
self.logger = logging.getLogger(f"{API_NAME}.{self.__class__.__name__}")
|
|
234
|
+
if not self.logger.handlers:
|
|
235
|
+
handler = logging.StreamHandler()
|
|
236
|
+
formatter = logging.Formatter(
|
|
237
|
+
"[%(asctime)s][%(levelname)s][%(name)s] %(message)s"
|
|
238
|
+
)
|
|
239
|
+
handler.setFormatter(formatter)
|
|
240
|
+
self.logger.addHandler(handler)
|
|
241
|
+
self.logger.setLevel(logging.INFO)
|
|
242
|
+
self.client = client
|
|
243
|
+
self.rest_api = (rest_api or DEFAULT_REST_ENDPOINT).rstrip("/")
|
|
244
|
+
self.ws_public = ws_public or DEFAULT_WS_ENDPOINT
|
|
245
|
+
|
|
246
|
+
self.chain_id = chain_id or 137
|
|
247
|
+
# Default to POLY_GNOSIS_SAFE (2) to match common proxy flows used in examples/tests.
|
|
248
|
+
# Users can override via constructor.
|
|
249
|
+
self.signature_type = signature_type if signature_type is not None else 2
|
|
250
|
+
self.funder = funder
|
|
251
|
+
|
|
252
|
+
self.store = PolymarketDataStore()
|
|
253
|
+
self._ws_public: pybotters.ws.WebSocketApp | None = None
|
|
254
|
+
self._ws_public_ready = asyncio.Event()
|
|
255
|
+
self._ws_personal: pybotters.ws.WebSocketApp | None = None
|
|
256
|
+
self.auth = False
|
|
257
|
+
|
|
258
|
+
self._ensure_session_entry(private_key=private_key, funder=funder, chain_id=chain_id)
|
|
259
|
+
|
|
260
|
+
async def __aenter__(self) -> "Polymarket":
|
|
261
|
+
if self.auth:
|
|
262
|
+
await self.create_or_derive_api_creds()
|
|
263
|
+
return self
|
|
264
|
+
|
|
265
|
+
async def __aexit__(self, exc_type, exc, tb) -> None: # pragma: no cover -
|
|
266
|
+
await self.aclose()
|
|
267
|
+
|
|
268
|
+
async def aclose(self) -> None:
|
|
269
|
+
if self._ws_public is not None:
|
|
270
|
+
with suppress(Exception):
|
|
271
|
+
await self._ws_public.current_ws.close()
|
|
272
|
+
self._ws_public = None
|
|
273
|
+
self._ws_public_ready.clear()
|
|
274
|
+
|
|
275
|
+
# ------------------------------------------------------------------
|
|
276
|
+
# Store helpers
|
|
277
|
+
|
|
278
|
+
async def update(
|
|
279
|
+
self,
|
|
280
|
+
update_type: Literal[
|
|
281
|
+
"all",
|
|
282
|
+
"markets",
|
|
283
|
+
"book",
|
|
284
|
+
"books",
|
|
285
|
+
"position",
|
|
286
|
+
"orders",
|
|
287
|
+
] | Sequence[str] = "all",
|
|
288
|
+
*,
|
|
289
|
+
token_ids: Sequence[str] | str | None = None,
|
|
290
|
+
limit: int | None = None,
|
|
291
|
+
) -> None:
|
|
292
|
+
"""Refresh cached data using Polymarket REST endpoints.
|
|
293
|
+
|
|
294
|
+
update_type 可以是单个字符串或列表,例如:
|
|
295
|
+
update_type='position'
|
|
296
|
+
update_type=['position', 'orders']
|
|
297
|
+
"""
|
|
298
|
+
# 统一转为 set
|
|
299
|
+
if isinstance(update_type, str):
|
|
300
|
+
types = {update_type}
|
|
301
|
+
else:
|
|
302
|
+
types = set(update_type)
|
|
303
|
+
|
|
304
|
+
include_detail = "all" in types or "detail" in types or "markets" in types
|
|
305
|
+
include_books = "all" in types or "book" in types or "books" in types
|
|
306
|
+
include_position = "all" in types or "position" in types
|
|
307
|
+
include_history_position = "history_position" in types
|
|
308
|
+
include_orders = "all" in types or "orders" in types
|
|
309
|
+
|
|
310
|
+
if include_books and token_ids is None:
|
|
311
|
+
raise ValueError("token_ids are required when updating books")
|
|
312
|
+
|
|
313
|
+
tasks: list[tuple[str, Any]] = []
|
|
314
|
+
if include_detail:
|
|
315
|
+
params = {"limit": limit} if limit else None
|
|
316
|
+
tasks.append(
|
|
317
|
+
(
|
|
318
|
+
"detail",
|
|
319
|
+
asyncio.create_task(
|
|
320
|
+
self._rest("GET", "/markets", params=params)
|
|
321
|
+
),
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if include_books and token_ids is not None:
|
|
326
|
+
body = [{"token_id": tid} for tid in self._token_list(token_ids)]
|
|
327
|
+
tasks.append(
|
|
328
|
+
(
|
|
329
|
+
"books",
|
|
330
|
+
asyncio.create_task(
|
|
331
|
+
self._rest("POST", "/books", json=body)
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
if include_position or include_history_position:
|
|
337
|
+
tasks.append(
|
|
338
|
+
(
|
|
339
|
+
"position",
|
|
340
|
+
asyncio.create_task(
|
|
341
|
+
self.get_mergeable_positions()
|
|
342
|
+
),
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
if include_orders:
|
|
347
|
+
tasks.append(("orders", asyncio.create_task(self.get_orders())))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
if not tasks:
|
|
351
|
+
raise ValueError(f"Unsupported update_type={update_type}")
|
|
352
|
+
|
|
353
|
+
results: dict[str, Any] = {}
|
|
354
|
+
|
|
355
|
+
keys = [k for k, _ in tasks]
|
|
356
|
+
futs = [f for _, f in tasks]
|
|
357
|
+
|
|
358
|
+
done = await asyncio.gather(*futs, return_exceptions=True)
|
|
359
|
+
|
|
360
|
+
for key, res in zip(keys, done):
|
|
361
|
+
if isinstance(res, Exception):
|
|
362
|
+
# REST 更新为 best-effort:记录错误但不中断整体流程
|
|
363
|
+
try:
|
|
364
|
+
logger = getattr(self, "logger", None)
|
|
365
|
+
if logger:
|
|
366
|
+
logger.warning(f"[update] {key} failed: {res}", exc_info=True)
|
|
367
|
+
else:
|
|
368
|
+
print(f"[update] {key} failed: {res}")
|
|
369
|
+
except Exception:
|
|
370
|
+
pass
|
|
371
|
+
continue
|
|
372
|
+
results[key] = res
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
if "books" in results:
|
|
376
|
+
entries = results["books"].get("data") or results["books"]
|
|
377
|
+
for entry in entries or []:
|
|
378
|
+
message = {
|
|
379
|
+
"event_type": "book",
|
|
380
|
+
"asset_id": entry.get("asset_id") or entry.get("token_id"),
|
|
381
|
+
"bids": entry.get("bids"),
|
|
382
|
+
"asks": entry.get("asks"),
|
|
383
|
+
}
|
|
384
|
+
self.store.book._on_message(message)
|
|
385
|
+
|
|
386
|
+
if "position" in results or "history_position" in results:
|
|
387
|
+
data = results["position"]
|
|
388
|
+
self.store.position._on_response(data)
|
|
389
|
+
|
|
390
|
+
if "orders" in results:
|
|
391
|
+
orders = results["orders"]
|
|
392
|
+
self.store.orders._on_response(orders)
|
|
393
|
+
|
|
394
|
+
async def sub_rts_prices(
|
|
395
|
+
self,
|
|
396
|
+
symbols: Sequence[str] | str | None = None,
|
|
397
|
+
*,
|
|
398
|
+
source: Literal["chainlink", "binance"] = "chainlink",
|
|
399
|
+
server_filter: bool = False,
|
|
400
|
+
) -> pybotters.ws.WebSocketApp:
|
|
401
|
+
"""Subscribe to Polymarket RTDS prices (Chainlink or Binance sources).
|
|
402
|
+
|
|
403
|
+
Parameters
|
|
404
|
+
----------
|
|
405
|
+
symbols
|
|
406
|
+
Requested symbols (Chainlink prefers ``eth/usd`` format, Binance
|
|
407
|
+
uses ``ethusdt``).
|
|
408
|
+
source
|
|
409
|
+
Either ``"chainlink"`` (default) or ``"binance"``.
|
|
410
|
+
server_filter
|
|
411
|
+
When ``True`` the request payload includes the filter exactly as the
|
|
412
|
+
docs specify (e.g. ``{"symbol":"btc/usd"}``). In practice the
|
|
413
|
+
server sometimes stops streaming after returning the first snapshot
|
|
414
|
+
when filters are present, so the default behaviour is to subscribe
|
|
415
|
+
to the full feed and filter locally.
|
|
416
|
+
"""
|
|
417
|
+
|
|
418
|
+
if isinstance(symbols, str):
|
|
419
|
+
requested = [symbols]
|
|
420
|
+
elif symbols:
|
|
421
|
+
requested = list(symbols)
|
|
422
|
+
else:
|
|
423
|
+
requested = []
|
|
424
|
+
|
|
425
|
+
target_symbols = {s.lower() for s in requested if s}
|
|
426
|
+
|
|
427
|
+
if source == "chainlink":
|
|
428
|
+
topic = "crypto_prices_chainlink"
|
|
429
|
+
sub_type = "*"
|
|
430
|
+
if server_filter and target_symbols:
|
|
431
|
+
if len(target_symbols) == 1:
|
|
432
|
+
filters = json.dumps({"symbol": next(iter(target_symbols))})
|
|
433
|
+
else:
|
|
434
|
+
filters = json.dumps({"symbols": sorted(target_symbols)})
|
|
435
|
+
else:
|
|
436
|
+
filters = None
|
|
437
|
+
else:
|
|
438
|
+
topic = "crypto_prices"
|
|
439
|
+
sub_type = "update"
|
|
440
|
+
filters = None
|
|
441
|
+
if server_filter and target_symbols:
|
|
442
|
+
filters = ",".join(sorted(target_symbols))
|
|
443
|
+
|
|
444
|
+
subscription: dict[str, Any] = {"topic": topic, "type": sub_type}
|
|
445
|
+
if filters:
|
|
446
|
+
subscription["filters"] = filters
|
|
447
|
+
|
|
448
|
+
payload = {
|
|
449
|
+
"action": "subscribe",
|
|
450
|
+
"subscriptions": [subscription],
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
def callback(msg, ws):
|
|
454
|
+
if not msg:
|
|
455
|
+
return
|
|
456
|
+
try:
|
|
457
|
+
data = json.loads(msg)
|
|
458
|
+
except json.JSONDecodeError:
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
payload = data.get("payload") or {}
|
|
462
|
+
symbol = str(payload.get("symbol") or "").lower()
|
|
463
|
+
if (not server_filter) and target_symbols and symbol and symbol not in target_symbols:
|
|
464
|
+
return
|
|
465
|
+
|
|
466
|
+
self.store.onmessage(data, ws)
|
|
467
|
+
|
|
468
|
+
wsapp = self.client.ws_connect(
|
|
469
|
+
RTS_DATA_ENDPOINT,
|
|
470
|
+
send_json=payload,
|
|
471
|
+
hdlr_str=callback,
|
|
472
|
+
heartbeat=5,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
await wsapp._event.wait()
|
|
476
|
+
return wsapp
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
async def sub_books(
|
|
480
|
+
self,
|
|
481
|
+
token_ids: Sequence[str] | str,
|
|
482
|
+
wsapp: pybotters.ws.WebSocketApp | None = None,
|
|
483
|
+
only_bbo: bool = False,
|
|
484
|
+
with_trades: bool = False
|
|
485
|
+
) -> pybotters.ws.WebSocketApp:
|
|
486
|
+
"""Subscribe to public order-book updates for the provided token ids."""
|
|
487
|
+
|
|
488
|
+
tokens = self._token_list(token_ids)
|
|
489
|
+
payload = {"type": "market", "assets_ids": tokens}
|
|
490
|
+
if wsapp:
|
|
491
|
+
await wsapp.current_ws.send_json(payload)
|
|
492
|
+
hdrl_json = self.store.onmessage_for_bbo if only_bbo else self.store.onmessage
|
|
493
|
+
hd_lst = [hdrl_json]
|
|
494
|
+
if with_trades:
|
|
495
|
+
hd_lst.append(self.store.onmessage_for_last_trade)
|
|
496
|
+
|
|
497
|
+
self._ws_public = self.client.ws_connect(
|
|
498
|
+
self.ws_public,
|
|
499
|
+
send_json=payload,
|
|
500
|
+
hdlr_json=hd_lst
|
|
501
|
+
)
|
|
502
|
+
await self._ws_public._event.wait()
|
|
503
|
+
return self._ws_public
|
|
504
|
+
|
|
505
|
+
async def sub_personal(
|
|
506
|
+
self,
|
|
507
|
+
callback: Any = None,
|
|
508
|
+
markets: Sequence[str] | None = None,
|
|
509
|
+
rest_sync: bool = True,
|
|
510
|
+
rest_order_sync_interval: int = 5,
|
|
511
|
+
rest_position_sync_interval: int = 8,
|
|
512
|
+
) -> pybotters.ws.WebSocketApp:
|
|
513
|
+
"""Subscribe to personal updates (requires authentication)."""
|
|
514
|
+
|
|
515
|
+
creds = self._api_creds()
|
|
516
|
+
if not creds:
|
|
517
|
+
raise RuntimeError("Polymarket API credentials are required for personal subscriptions")
|
|
518
|
+
|
|
519
|
+
# 记录 position store 最后更新时间
|
|
520
|
+
last_position_update = time.time()
|
|
521
|
+
|
|
522
|
+
def _handler(message, ws=None):
|
|
523
|
+
nonlocal last_position_update
|
|
524
|
+
self.store.onmessage(message, ws)
|
|
525
|
+
# 检测是否是 position 相关消息
|
|
526
|
+
if isinstance(message, dict) and message.get('event_type') in ('order', 'trade'):
|
|
527
|
+
last_position_update = time.time()
|
|
528
|
+
if callback:
|
|
529
|
+
callback(message, ws)
|
|
530
|
+
|
|
531
|
+
effective_cb = _handler if callback else self.store.onmessage
|
|
532
|
+
|
|
533
|
+
api_key = creds.get("api_key")
|
|
534
|
+
api_secret = creds.get("api_secret")
|
|
535
|
+
api_passphrase = creds.get("api_passphrase")
|
|
536
|
+
if not api_key or not api_secret or not api_passphrase:
|
|
537
|
+
raise RuntimeError("Polymarket API key/secret/passphrase missing; call create_or_derive_api_creds")
|
|
538
|
+
|
|
539
|
+
auth = {"apiKey": api_key, "secret": api_secret, "passphrase": api_passphrase}
|
|
540
|
+
payload = {"markets": list(markets or []), "type": "user", "auth": auth}
|
|
541
|
+
|
|
542
|
+
# 在开始前用rest_api同步持仓
|
|
543
|
+
await self.update('position')
|
|
544
|
+
|
|
545
|
+
# 后台任务:3秒无更新则同步持仓
|
|
546
|
+
async def _rest_sync_watchdog():
|
|
547
|
+
nonlocal last_position_update
|
|
548
|
+
last_orders_update = time.time()
|
|
549
|
+
while True:
|
|
550
|
+
await asyncio.sleep(1)
|
|
551
|
+
now = time.time()
|
|
552
|
+
# position: 6秒无更新则同步
|
|
553
|
+
if now - last_position_update > rest_position_sync_interval:
|
|
554
|
+
try:
|
|
555
|
+
await self.update('position')
|
|
556
|
+
last_position_update = now
|
|
557
|
+
except Exception:
|
|
558
|
+
pass
|
|
559
|
+
# orders: 每3秒同步一次
|
|
560
|
+
if now - last_orders_update > rest_order_sync_interval:
|
|
561
|
+
try:
|
|
562
|
+
await self.update('orders')
|
|
563
|
+
last_orders_update = now
|
|
564
|
+
except Exception:
|
|
565
|
+
pass
|
|
566
|
+
|
|
567
|
+
if rest_sync:
|
|
568
|
+
asyncio.create_task(_rest_sync_watchdog())
|
|
569
|
+
|
|
570
|
+
# 使用 send_json 参数,这样重连后会自动重新订阅
|
|
571
|
+
self._ws_personal = self.client.ws_connect(
|
|
572
|
+
"wss://ws-subscriptions-clob.polymarket.com/ws/user",
|
|
573
|
+
send_json=payload,
|
|
574
|
+
hdlr_json=effective_cb,
|
|
575
|
+
heartbeat=30,
|
|
576
|
+
auth=None,
|
|
577
|
+
)
|
|
578
|
+
await self._ws_personal._event.wait()
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
return self._ws_personal
|
|
582
|
+
|
|
583
|
+
async def sub_trades(self, slug: str):
|
|
584
|
+
"""订阅activate trades"""
|
|
585
|
+
payload = {
|
|
586
|
+
"action": "subscribe",
|
|
587
|
+
"subscriptions": [
|
|
588
|
+
{
|
|
589
|
+
"topic": "activity",
|
|
590
|
+
"type": "orders_matched",
|
|
591
|
+
"filters": json.dumps({"event_slug": slug}, separators=(',', ':'))
|
|
592
|
+
}
|
|
593
|
+
]
|
|
594
|
+
}
|
|
595
|
+
print(payload)
|
|
596
|
+
def callback(msg, ws):
|
|
597
|
+
if not msg:
|
|
598
|
+
return
|
|
599
|
+
try:
|
|
600
|
+
data = json.loads(msg)
|
|
601
|
+
except json.JSONDecodeError:
|
|
602
|
+
return
|
|
603
|
+
|
|
604
|
+
self.store.onmessage(data, ws)
|
|
605
|
+
|
|
606
|
+
# 使用 send_json 参数,重连后自动重新订阅
|
|
607
|
+
wsapp = self.client.ws_connect(
|
|
608
|
+
RTS_DATA_ENDPOINT,
|
|
609
|
+
send_json=payload,
|
|
610
|
+
hdlr_str=callback,
|
|
611
|
+
heartbeat=5
|
|
612
|
+
)
|
|
613
|
+
await wsapp._event.wait()
|
|
614
|
+
return wsapp
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
# ------------------------------------------------------------------
|
|
620
|
+
# Public REST endpoints
|
|
621
|
+
|
|
622
|
+
async def get_markets(self, **params: Any) -> Any:
|
|
623
|
+
return await self._rest("GET", "/markets", params=params or None)
|
|
624
|
+
|
|
625
|
+
async def get_market(self, market_id: str) -> Any:
|
|
626
|
+
return await self._rest("GET", f"/markets/{market_id}")
|
|
627
|
+
|
|
628
|
+
async def get_market_by_slug(self, slug: str) -> Any:
|
|
629
|
+
"""Fetch a market using its human-readable slug.
|
|
630
|
+
https://docs.polymarket.com/api-reference/markets/get-market-by-slug
|
|
631
|
+
"""
|
|
632
|
+
market:dict = await self._rest("GET", f"/slug/{slug}", host='https://gamma-api.polymarket.com/markets')
|
|
633
|
+
market = {k: parse_field(v) for k, v in market.items()}
|
|
634
|
+
return market
|
|
635
|
+
|
|
636
|
+
async def get_order_book(self, token_id: str) -> Any:
|
|
637
|
+
return await self._rest("GET", "/book", params={"token_id": token_id})
|
|
638
|
+
|
|
639
|
+
async def get_order_books(self, token_ids: Sequence[str] | str) -> Any:
|
|
640
|
+
body = [{"token_id": tid} for tid in self._token_list(token_ids)]
|
|
641
|
+
return await self._rest("POST", "/books", json=body)
|
|
642
|
+
|
|
643
|
+
async def get_midpoint(self, token_id: str) -> Any:
|
|
644
|
+
return await self._rest("GET", "/midpoint", params={"token_id": token_id})
|
|
645
|
+
|
|
646
|
+
async def get_midpoints(self, token_ids: Sequence[str] | str) -> Any:
|
|
647
|
+
body = [{"token_id": tid} for tid in self._token_list(token_ids)]
|
|
648
|
+
return await self._rest("POST", "/midpoints", json=body)
|
|
649
|
+
|
|
650
|
+
async def get_price(self, token_id: str, side: str) -> Any:
|
|
651
|
+
return await self._rest("GET", "/price", params={"token_id": token_id, "side": side})
|
|
652
|
+
|
|
653
|
+
async def get_prices(self, requests: Iterable[Mapping[str, str]]) -> Any:
|
|
654
|
+
body = [dict(req) for req in requests]
|
|
655
|
+
return await self._rest("POST", "/prices", json=body)
|
|
656
|
+
|
|
657
|
+
async def get_spread(self, token_id: str) -> Any:
|
|
658
|
+
return await self._rest("GET", "/spread", params={"token_id": token_id})
|
|
659
|
+
|
|
660
|
+
async def get_spreads(self, token_ids: Sequence[str] | str) -> Any:
|
|
661
|
+
body = [{"token_id": tid} for tid in self._token_list(token_ids)]
|
|
662
|
+
return await self._rest("POST", "/spreads", json=body)
|
|
663
|
+
|
|
664
|
+
async def get_last_trade_price(self, token_id: str) -> Any:
|
|
665
|
+
return await self._rest("GET", "/last-trade-price", params={"token_id": token_id})
|
|
666
|
+
|
|
667
|
+
async def get_last_trades_prices(self, token_ids: Sequence[str] | str) -> Any:
|
|
668
|
+
body = [{"token_id": tid} for tid in self._token_list(token_ids)]
|
|
669
|
+
return await self._rest("POST", "/last-trades-prices", json=body)
|
|
670
|
+
|
|
671
|
+
async def get_tick_size(self, token_id: str) -> Any:
|
|
672
|
+
return await self._rest("GET", "/tick-size", params={"token_id": token_id})
|
|
673
|
+
|
|
674
|
+
async def get_neg_risk(self, token_id: str) -> Any:
|
|
675
|
+
return await self._rest("GET", "/neg-risk", params={"token_id": token_id})
|
|
676
|
+
|
|
677
|
+
async def get_fee_rate(self, token_id: str) -> Any:
|
|
678
|
+
return await self._rest("GET", "/fee-rate", params={"token_id": token_id})
|
|
679
|
+
|
|
680
|
+
# ------------------------------------------------------------------
|
|
681
|
+
# Credential management (Level 1 / Level 2)
|
|
682
|
+
|
|
683
|
+
async def create_api_key(self, nonce: int | None = None) -> dict[str, Any]:
|
|
684
|
+
params = {"nonce": nonce} if nonce is not None else None
|
|
685
|
+
data = await self._rest("POST", "/auth/api-key", params=params)
|
|
686
|
+
self._store_api_creds(data)
|
|
687
|
+
return data
|
|
688
|
+
|
|
689
|
+
async def derive_api_key(self, nonce: int | None = None) -> dict[str, Any]:
|
|
690
|
+
params = {"nonce": nonce} if nonce is not None else None
|
|
691
|
+
data = await self._rest("GET", "/auth/derive-api-key", params=params)
|
|
692
|
+
self._store_api_creds(data)
|
|
693
|
+
return data
|
|
694
|
+
|
|
695
|
+
async def create_or_derive_api_creds(self, nonce: int | None = None) -> dict[str, Any]:
|
|
696
|
+
try:
|
|
697
|
+
return await self.derive_api_key(nonce)
|
|
698
|
+
except Exception:
|
|
699
|
+
return await self.create_api_key(nonce)
|
|
700
|
+
|
|
701
|
+
async def get_api_keys(self) -> Any:
|
|
702
|
+
return await self._rest("GET", "/auth/api-keys")
|
|
703
|
+
|
|
704
|
+
async def delete_api_key(self) -> Any:
|
|
705
|
+
return await self._rest("DELETE", "/auth/api-key")
|
|
706
|
+
|
|
707
|
+
async def get_closed_only_mode(self) -> Any:
|
|
708
|
+
return await self._rest("GET", "/auth/ban-status/closed-only")
|
|
709
|
+
|
|
710
|
+
# ------------------------------------------------------------------
|
|
711
|
+
# Trading helpers (Level 2)
|
|
712
|
+
|
|
713
|
+
async def post_order(
|
|
714
|
+
self,
|
|
715
|
+
signed_order: Mapping[str, Any],
|
|
716
|
+
*,
|
|
717
|
+
order_type: str = "GTC",
|
|
718
|
+
owner: str | None = None,
|
|
719
|
+
) -> Any:
|
|
720
|
+
"""Low-level publish for an already-signed order.
|
|
721
|
+
|
|
722
|
+
Prefer ``place_order`` for a compact, user-friendly API.
|
|
723
|
+
"""
|
|
724
|
+
payload = {
|
|
725
|
+
"order": dict(signed_order),
|
|
726
|
+
"owner": self._owner_key(owner),
|
|
727
|
+
"orderType": order_type,
|
|
728
|
+
}
|
|
729
|
+
return await self._rest("POST", "/order", json=payload)
|
|
730
|
+
|
|
731
|
+
# ------------------------------------------------------------------
|
|
732
|
+
# Compact order placement (py_clob_client-like)
|
|
733
|
+
|
|
734
|
+
@staticmethod
|
|
735
|
+
def _round_down(x: float, sig_digits: int) -> float:
|
|
736
|
+
from math import floor
|
|
737
|
+
|
|
738
|
+
return floor(x * (10**sig_digits)) / (10**sig_digits)
|
|
739
|
+
|
|
740
|
+
@staticmethod
|
|
741
|
+
def _round_normal(x: float, sig_digits: int) -> float:
|
|
742
|
+
return round(x * (10**sig_digits)) / (10**sig_digits)
|
|
743
|
+
|
|
744
|
+
@staticmethod
|
|
745
|
+
def _round_up(x: float, sig_digits: int) -> float:
|
|
746
|
+
from math import ceil
|
|
747
|
+
|
|
748
|
+
return ceil(x * (10**sig_digits)) / (10**sig_digits)
|
|
749
|
+
|
|
750
|
+
@staticmethod
|
|
751
|
+
def _decimal_places(x: float) -> int:
|
|
752
|
+
from decimal import Decimal
|
|
753
|
+
|
|
754
|
+
return abs(Decimal(x.__str__()).as_tuple().exponent)
|
|
755
|
+
|
|
756
|
+
@classmethod
|
|
757
|
+
def _to_token_decimals(cls, x: float) -> int:
|
|
758
|
+
f = (10**6) * x
|
|
759
|
+
if cls._decimal_places(f) > 0:
|
|
760
|
+
f = cls._round_normal(f, 0)
|
|
761
|
+
return int(f)
|
|
762
|
+
|
|
763
|
+
@staticmethod
|
|
764
|
+
def _contracts(chain_id: int, neg_risk: bool = False) -> dict[str, str]:
|
|
765
|
+
"""Minimal contract config (avoid external deps)."""
|
|
766
|
+
cfg = {
|
|
767
|
+
False: {
|
|
768
|
+
137: {
|
|
769
|
+
"exchange": "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E",
|
|
770
|
+
"collateral": USDC_CONTRACT,
|
|
771
|
+
"conditional_tokens": "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045",
|
|
772
|
+
"neg_risk_adapter": None,
|
|
773
|
+
},
|
|
774
|
+
80002: {
|
|
775
|
+
"exchange": "0xdFE02Eb6733538f8Ea35D585af8DE5958AD99E40",
|
|
776
|
+
"collateral": "0x9c4e1703476e875070ee25b56a58b008cfb8fa78",
|
|
777
|
+
"conditional_tokens": "0x69308FB512518e39F9b16112fA8d994F4e2Bf8bB",
|
|
778
|
+
"neg_risk_adapter": None,
|
|
779
|
+
},
|
|
780
|
+
},
|
|
781
|
+
True: {
|
|
782
|
+
137: {
|
|
783
|
+
"exchange": "0xC5d563A36AE78145C45a50134d48A1215220f80a",
|
|
784
|
+
"collateral": USDC_CONTRACT,
|
|
785
|
+
"conditional_tokens": "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045",
|
|
786
|
+
"neg_risk_adapter": "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296",
|
|
787
|
+
},
|
|
788
|
+
80002: {
|
|
789
|
+
"exchange": "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296",
|
|
790
|
+
"collateral": "0x9c4e1703476e875070ee25b56a58b008cfb8fa78",
|
|
791
|
+
"conditional_tokens": "0x69308FB512518e39F9b16112fA8d994F4e2Bf8bB",
|
|
792
|
+
"neg_risk_adapter": None,
|
|
793
|
+
},
|
|
794
|
+
},
|
|
795
|
+
}
|
|
796
|
+
try:
|
|
797
|
+
return cfg[bool(neg_risk)][int(chain_id)]
|
|
798
|
+
except Exception as e: # pragma: no cover
|
|
799
|
+
raise RuntimeError(f"Unsupported chain_id={chain_id} for Polymarket") from e
|
|
800
|
+
|
|
801
|
+
@staticmethod
|
|
802
|
+
def _rounding_for_tick(tick_size: str | float) -> tuple[int, int, int]:
|
|
803
|
+
"""Return (price_digits, size_digits, amount_digits) for tick_size."""
|
|
804
|
+
ts = str(tick_size)
|
|
805
|
+
mapping = {
|
|
806
|
+
"0.1": (1, 2, 3),
|
|
807
|
+
"0.01": (2, 2, 4),
|
|
808
|
+
"0.001": (3, 2, 5),
|
|
809
|
+
"0.0001": (4, 2, 6),
|
|
810
|
+
}
|
|
811
|
+
return mapping.get(ts, (2, 2, 4))
|
|
812
|
+
|
|
813
|
+
async def place_order(
|
|
814
|
+
self,
|
|
815
|
+
*,
|
|
816
|
+
token_id: str,
|
|
817
|
+
side: str,
|
|
818
|
+
price: float,
|
|
819
|
+
size: float,
|
|
820
|
+
order_type: Literal["GTC", 'FOK'] = "GTC",
|
|
821
|
+
tick_size: str | float | None = None,
|
|
822
|
+
fee_rate_bps: int | None = None,
|
|
823
|
+
expiration: int | None = None,
|
|
824
|
+
taker: str = ZERO_ADDRESS,
|
|
825
|
+
neg_risk: bool = False,
|
|
826
|
+
owner: str | None = None,
|
|
827
|
+
nonce: int | None = None,
|
|
828
|
+
) -> Any:
|
|
829
|
+
"""Create, sign and submit an order with a compact interface.
|
|
830
|
+
|
|
831
|
+
Parameters
|
|
832
|
+
- token_id: outcome token id
|
|
833
|
+
- side: 'BUY' | 'SELL'
|
|
834
|
+
- price: float price
|
|
835
|
+
- size: float size (in outcome tokens)
|
|
836
|
+
- order_type: 'GTC' (default) or other server-accepted types
|
|
837
|
+
- fee_rate_bps: optional fee bps; defaults to market fee
|
|
838
|
+
- expiration: unix seconds; defaults to 0 (no expiry)
|
|
839
|
+
- taker: zero address by default (public order)
|
|
840
|
+
- neg_risk: whether market is negative risk
|
|
841
|
+
- owner: API key owner (defaults to current credentials)
|
|
842
|
+
- nonce: onchain nonce, default 0
|
|
843
|
+
"""
|
|
844
|
+
|
|
845
|
+
if price <= 0:
|
|
846
|
+
raise ValueError("price must be positive; use place_market_order for market orders")
|
|
847
|
+
|
|
848
|
+
# Ensure L2 creds exist
|
|
849
|
+
if not self._api_creds():
|
|
850
|
+
raise RuntimeError("Polymarket API credentials missing; call create_or_derive_api_creds first")
|
|
851
|
+
|
|
852
|
+
private_key, maker_addr, signer_addr = self._get_signing_context()
|
|
853
|
+
signed_dict = await self._build_signed_order(
|
|
854
|
+
private_key=private_key,
|
|
855
|
+
maker_addr=maker_addr,
|
|
856
|
+
signer_addr=signer_addr,
|
|
857
|
+
token_id=token_id,
|
|
858
|
+
side=side,
|
|
859
|
+
price=price,
|
|
860
|
+
size=size,
|
|
861
|
+
tick_size=tick_size,
|
|
862
|
+
fee_rate_bps=fee_rate_bps,
|
|
863
|
+
expiration=expiration,
|
|
864
|
+
taker=taker,
|
|
865
|
+
neg_risk=neg_risk,
|
|
866
|
+
nonce=nonce,
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
# Submit (use aiohttp session directly with HMAC headers for performance)
|
|
870
|
+
payload = {
|
|
871
|
+
"order": signed_dict,
|
|
872
|
+
"owner": self._owner_key(owner),
|
|
873
|
+
"orderType": order_type,
|
|
874
|
+
}
|
|
875
|
+
return await self._signed_request_via_session("POST", "/order", payload)
|
|
876
|
+
|
|
877
|
+
async def place_market_order(
|
|
878
|
+
self,
|
|
879
|
+
*,
|
|
880
|
+
token_id: str,
|
|
881
|
+
side: str,
|
|
882
|
+
amount: float,
|
|
883
|
+
order_type: Literal["FOK", "GTC", "FAK", "GTD"] = "FOK",
|
|
884
|
+
price: float | None = None,
|
|
885
|
+
tick_size: str | float | None = None,
|
|
886
|
+
fee_rate_bps: int | None = None,
|
|
887
|
+
taker: str = ZERO_ADDRESS,
|
|
888
|
+
neg_risk: bool = False,
|
|
889
|
+
owner: str | None = None,
|
|
890
|
+
nonce: int | None = None,
|
|
891
|
+
) -> Any:
|
|
892
|
+
"""Create, sign and submit a market order similar to ``py_clob_client``.
|
|
893
|
+
|
|
894
|
+
BUY orders treat ``amount`` as collateral (USDC); SELL orders treat it as shares.
|
|
895
|
+
"""
|
|
896
|
+
|
|
897
|
+
if amount <= 0:
|
|
898
|
+
raise ValueError("amount must be greater than 0 for market orders")
|
|
899
|
+
|
|
900
|
+
if not self._api_creds():
|
|
901
|
+
raise RuntimeError("Polymarket API credentials missing; call create_or_derive_api_creds first")
|
|
902
|
+
|
|
903
|
+
private_key, maker_addr, signer_addr = self._get_signing_context()
|
|
904
|
+
owner_key = self._owner_key(owner)
|
|
905
|
+
order_type_str = (order_type or "FOK").upper()
|
|
906
|
+
|
|
907
|
+
if price is None or price <= 0:
|
|
908
|
+
price = await self._calculate_market_price(
|
|
909
|
+
token_id=token_id,
|
|
910
|
+
side=side,
|
|
911
|
+
amount=amount,
|
|
912
|
+
order_type=order_type_str,
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
signed_dict = await self._build_signed_market_order(
|
|
916
|
+
private_key=private_key,
|
|
917
|
+
maker_addr=maker_addr,
|
|
918
|
+
signer_addr=signer_addr,
|
|
919
|
+
token_id=token_id,
|
|
920
|
+
side=side,
|
|
921
|
+
amount=amount,
|
|
922
|
+
price=price,
|
|
923
|
+
tick_size=tick_size,
|
|
924
|
+
fee_rate_bps=fee_rate_bps,
|
|
925
|
+
taker=taker,
|
|
926
|
+
neg_risk=neg_risk,
|
|
927
|
+
nonce=nonce,
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
payload = {
|
|
931
|
+
"order": signed_dict,
|
|
932
|
+
"owner": owner_key,
|
|
933
|
+
"orderType": order_type_str,
|
|
934
|
+
}
|
|
935
|
+
return await self._signed_request_via_session("POST", "/order", payload)
|
|
936
|
+
|
|
937
|
+
async def _calculate_market_price(
|
|
938
|
+
self,
|
|
939
|
+
*,
|
|
940
|
+
token_id: str,
|
|
941
|
+
side: str,
|
|
942
|
+
amount: float,
|
|
943
|
+
order_type: str,
|
|
944
|
+
) -> float:
|
|
945
|
+
side_flag = side.upper()
|
|
946
|
+
if side_flag not in {"BUY", "SELL"}:
|
|
947
|
+
raise ValueError("side must be 'BUY' or 'SELL'")
|
|
948
|
+
if amount <= 0:
|
|
949
|
+
raise ValueError("amount must be greater than 0 for market pricing")
|
|
950
|
+
|
|
951
|
+
book = await self.get_order_book(token_id)
|
|
952
|
+
if not isinstance(book, Mapping):
|
|
953
|
+
raise RuntimeError("Polymarket order book unavailable for market order")
|
|
954
|
+
|
|
955
|
+
key = "asks" if side_flag == "BUY" else "bids"
|
|
956
|
+
raw_levels = book.get(key) or []
|
|
957
|
+
levels: list[tuple[float, float]] = []
|
|
958
|
+
for lvl in raw_levels:
|
|
959
|
+
try:
|
|
960
|
+
price = float(lvl.get("price"))
|
|
961
|
+
size = float(lvl.get("size"))
|
|
962
|
+
except (TypeError, ValueError):
|
|
963
|
+
continue
|
|
964
|
+
if price is None or size is None:
|
|
965
|
+
continue
|
|
966
|
+
levels.append((price, size))
|
|
967
|
+
|
|
968
|
+
if not levels:
|
|
969
|
+
raise RuntimeError(f"Polymarket market order has no {key} liquidity")
|
|
970
|
+
|
|
971
|
+
total = 0.0
|
|
972
|
+
if side_flag == "BUY":
|
|
973
|
+
for price, size in reversed(levels):
|
|
974
|
+
total += price * size
|
|
975
|
+
if total >= amount:
|
|
976
|
+
return price
|
|
977
|
+
else:
|
|
978
|
+
for price, size in reversed(levels):
|
|
979
|
+
total += size
|
|
980
|
+
if total >= amount:
|
|
981
|
+
return price
|
|
982
|
+
|
|
983
|
+
if (order_type or "FOK").upper() == "FOK":
|
|
984
|
+
raise RuntimeError("Polymarket market order exceeds available liquidity")
|
|
985
|
+
|
|
986
|
+
return levels[0][0]
|
|
987
|
+
|
|
988
|
+
async def _signed_request_via_session(
|
|
989
|
+
self, method: str, path: str, body: Mapping[str, Any] | list[Any] | None
|
|
990
|
+
) -> Any:
|
|
991
|
+
import time, base64, hmac, hashlib
|
|
992
|
+
from eth_account import Account as _A
|
|
993
|
+
import aiohttp
|
|
994
|
+
|
|
995
|
+
method = method.upper()
|
|
996
|
+
session: aiohttp.ClientSession = getattr(self.client, "_session", None)
|
|
997
|
+
if session is None:
|
|
998
|
+
raise RuntimeError("pybotters client session missing")
|
|
999
|
+
creds = getattr(session, "_polymarket_api_creds", None)
|
|
1000
|
+
if not creds:
|
|
1001
|
+
raise RuntimeError("Polymarket API creds missing; call create_or_derive_api_creds")
|
|
1002
|
+
api_key = creds.get("api_key")
|
|
1003
|
+
api_secret = creds.get("api_secret")
|
|
1004
|
+
api_passphrase = creds.get("api_passphrase")
|
|
1005
|
+
|
|
1006
|
+
entry = getattr(session, "_apis", {}).get(API_NAME, [])
|
|
1007
|
+
private_key = entry[0] if entry else None
|
|
1008
|
+
addr = _A.from_key(private_key).address if private_key else None
|
|
1009
|
+
|
|
1010
|
+
ts = int(time.time())
|
|
1011
|
+
request_path = path
|
|
1012
|
+
url = f"{self.rest_api}{request_path}"
|
|
1013
|
+
payload_obj = dict(body) if isinstance(body, dict) else body
|
|
1014
|
+
serialized = (
|
|
1015
|
+
str(payload_obj).replace("'", '"') if payload_obj is not None else ""
|
|
1016
|
+
)
|
|
1017
|
+
secret_bytes = base64.urlsafe_b64decode(api_secret)
|
|
1018
|
+
msg = f"{ts}{method}{request_path}{serialized}"
|
|
1019
|
+
sig = hmac.new(secret_bytes, msg.encode("utf-8"), hashlib.sha256).digest()
|
|
1020
|
+
sign_b64 = base64.urlsafe_b64encode(sig).decode("utf-8")
|
|
1021
|
+
|
|
1022
|
+
headers = {
|
|
1023
|
+
"POLY_ADDRESS": addr,
|
|
1024
|
+
"POLY_SIGNATURE": sign_b64,
|
|
1025
|
+
"POLY_TIMESTAMP": str(ts),
|
|
1026
|
+
"POLY_API_KEY": api_key,
|
|
1027
|
+
"POLY_PASSPHRASE": api_passphrase,
|
|
1028
|
+
"Content-Type": "application/json",
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
async with session.request(method, url, headers=headers, data=(serialized or None)) as resp:
|
|
1032
|
+
if resp.status >= 400:
|
|
1033
|
+
text = await resp.text()
|
|
1034
|
+
raise RuntimeError(f"Polymarket {method} {path} failed: {resp.status} {text}")
|
|
1035
|
+
try:
|
|
1036
|
+
return await resp.json()
|
|
1037
|
+
except Exception:
|
|
1038
|
+
return await resp.text()
|
|
1039
|
+
|
|
1040
|
+
def _get_signing_context(self) -> tuple[str, str, str]:
|
|
1041
|
+
from eth_account import Account as _A
|
|
1042
|
+
|
|
1043
|
+
session = getattr(self.client, "_session", None)
|
|
1044
|
+
apis = getattr(session, "_apis", {}) if session else {}
|
|
1045
|
+
entry = list(apis.get(API_NAME, [])) if isinstance(apis, dict) else []
|
|
1046
|
+
private_key = entry[0] if entry and entry[0] else None
|
|
1047
|
+
if not private_key:
|
|
1048
|
+
raise RuntimeError("Polymarket private key not configured in apis.json")
|
|
1049
|
+
if not str(private_key).startswith("0x"):
|
|
1050
|
+
private_key = f"0x{private_key}"
|
|
1051
|
+
try:
|
|
1052
|
+
# Normalize cached session key to avoid repeated normalization work
|
|
1053
|
+
if session and isinstance(apis, dict):
|
|
1054
|
+
apis[API_NAME][0] = private_key
|
|
1055
|
+
except Exception:
|
|
1056
|
+
pass
|
|
1057
|
+
|
|
1058
|
+
cache_key = (private_key, self.funder)
|
|
1059
|
+
cached = getattr(self, "_signing_ctx_cache", None)
|
|
1060
|
+
if cached and cached.get("key") == cache_key:
|
|
1061
|
+
return cached["ctx"]
|
|
1062
|
+
|
|
1063
|
+
signer_addr = _A.from_key(private_key).address
|
|
1064
|
+
maker_addr = self.funder or signer_addr
|
|
1065
|
+
ctx = (private_key, maker_addr, signer_addr)
|
|
1066
|
+
self._signing_ctx_cache = {"key": cache_key, "ctx": ctx}
|
|
1067
|
+
return ctx
|
|
1068
|
+
|
|
1069
|
+
async def _build_signed_order(
|
|
1070
|
+
self,
|
|
1071
|
+
*,
|
|
1072
|
+
private_key: str,
|
|
1073
|
+
maker_addr: str,
|
|
1074
|
+
signer_addr: str,
|
|
1075
|
+
token_id: str,
|
|
1076
|
+
side: str,
|
|
1077
|
+
price: float,
|
|
1078
|
+
size: float,
|
|
1079
|
+
tick_size: str | float | None,
|
|
1080
|
+
fee_rate_bps: int | None,
|
|
1081
|
+
expiration: int | None,
|
|
1082
|
+
taker: str,
|
|
1083
|
+
neg_risk: bool,
|
|
1084
|
+
nonce: int | None,
|
|
1085
|
+
) -> dict[str, Any]:
|
|
1086
|
+
side = side.upper()
|
|
1087
|
+
|
|
1088
|
+
tick = await self._resolve_tick_size(token_id, tick_size)
|
|
1089
|
+
fee_bps = await self._resolve_fee_rate(token_id, fee_rate_bps)
|
|
1090
|
+
|
|
1091
|
+
price_d, size_d, amt_d = self._rounding_for_tick(tick)
|
|
1092
|
+
price = float(self._round_normal(price, price_d))
|
|
1093
|
+
|
|
1094
|
+
if side == "BUY":
|
|
1095
|
+
taker_amt_raw = self._round_down(float(size), size_d)
|
|
1096
|
+
maker_amt_raw = taker_amt_raw * price
|
|
1097
|
+
if self._decimal_places(maker_amt_raw) > amt_d:
|
|
1098
|
+
tmp = self._round_up(maker_amt_raw, amt_d + 4)
|
|
1099
|
+
maker_amt_raw = tmp if self._decimal_places(tmp) <= amt_d else self._round_down(tmp, amt_d)
|
|
1100
|
+
elif side == "SELL":
|
|
1101
|
+
maker_amt_raw = self._round_down(float(size), size_d)
|
|
1102
|
+
taker_amt_raw = maker_amt_raw * price
|
|
1103
|
+
if self._decimal_places(taker_amt_raw) > amt_d:
|
|
1104
|
+
tmp = self._round_up(taker_amt_raw, amt_d + 4)
|
|
1105
|
+
taker_amt_raw = tmp if self._decimal_places(tmp) <= amt_d else self._round_down(tmp, amt_d)
|
|
1106
|
+
else:
|
|
1107
|
+
raise ValueError("side must be 'BUY' or 'SELL'")
|
|
1108
|
+
|
|
1109
|
+
maker_amount = self._to_token_decimals(maker_amt_raw)
|
|
1110
|
+
taker_amount = self._to_token_decimals(taker_amt_raw)
|
|
1111
|
+
|
|
1112
|
+
contract = self._contracts(self.chain_id, neg_risk)
|
|
1113
|
+
side_flag = 0 if side == "BUY" else 1
|
|
1114
|
+
sig_type = int(self.signature_type)
|
|
1115
|
+
|
|
1116
|
+
try:
|
|
1117
|
+
return Auth.sign_polymarket_order2(
|
|
1118
|
+
private_key=private_key,
|
|
1119
|
+
chain_id=self.chain_id,
|
|
1120
|
+
exchange_address=contract["exchange"],
|
|
1121
|
+
order={
|
|
1122
|
+
"maker": maker_addr,
|
|
1123
|
+
"signer": signer_addr,
|
|
1124
|
+
"taker": taker or ZERO_ADDRESS,
|
|
1125
|
+
"tokenId": str(token_id),
|
|
1126
|
+
"makerAmount": int(maker_amount),
|
|
1127
|
+
"takerAmount": int(taker_amount),
|
|
1128
|
+
"expiration": int(expiration or 0),
|
|
1129
|
+
"nonce": int(nonce or 0),
|
|
1130
|
+
"feeRateBps": int(fee_bps or 0),
|
|
1131
|
+
"side": side_flag,
|
|
1132
|
+
"signatureType": sig_type,
|
|
1133
|
+
},
|
|
1134
|
+
)
|
|
1135
|
+
except RuntimeError:
|
|
1136
|
+
# Fallback when coincurve is unavailable
|
|
1137
|
+
return Auth.sign_polymarket_order(
|
|
1138
|
+
private_key=private_key,
|
|
1139
|
+
chain_id=self.chain_id,
|
|
1140
|
+
exchange_address=contract["exchange"],
|
|
1141
|
+
order={
|
|
1142
|
+
"maker": maker_addr,
|
|
1143
|
+
"signer": signer_addr,
|
|
1144
|
+
"taker": taker or ZERO_ADDRESS,
|
|
1145
|
+
"tokenId": str(token_id),
|
|
1146
|
+
"makerAmount": int(maker_amount),
|
|
1147
|
+
"takerAmount": int(taker_amount),
|
|
1148
|
+
"expiration": int(expiration or 0),
|
|
1149
|
+
"nonce": int(nonce or 0),
|
|
1150
|
+
"feeRateBps": int(fee_bps or 0),
|
|
1151
|
+
"side": side_flag,
|
|
1152
|
+
"signatureType": sig_type,
|
|
1153
|
+
},
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
async def _build_signed_market_order(
|
|
1157
|
+
self,
|
|
1158
|
+
*,
|
|
1159
|
+
private_key: str,
|
|
1160
|
+
maker_addr: str,
|
|
1161
|
+
signer_addr: str,
|
|
1162
|
+
token_id: str,
|
|
1163
|
+
side: str,
|
|
1164
|
+
amount: float,
|
|
1165
|
+
price: float,
|
|
1166
|
+
tick_size: str | float | None,
|
|
1167
|
+
fee_rate_bps: int | None,
|
|
1168
|
+
taker: str,
|
|
1169
|
+
neg_risk: bool,
|
|
1170
|
+
nonce: int | None,
|
|
1171
|
+
) -> dict[str, Any]:
|
|
1172
|
+
side = side.upper()
|
|
1173
|
+
tick = await self._resolve_tick_size(token_id, tick_size)
|
|
1174
|
+
fee_bps = await self._resolve_fee_rate(token_id, fee_rate_bps)
|
|
1175
|
+
|
|
1176
|
+
price_d, size_d, amt_d = self._rounding_for_tick(tick)
|
|
1177
|
+
price = float(self._round_normal(price, price_d))
|
|
1178
|
+
if price <= 0:
|
|
1179
|
+
raise ValueError("market price must be positive")
|
|
1180
|
+
|
|
1181
|
+
amt = float(amount)
|
|
1182
|
+
if amt <= 0:
|
|
1183
|
+
raise ValueError("amount must be greater than 0")
|
|
1184
|
+
|
|
1185
|
+
maker_amt_raw = self._round_down(amt, size_d)
|
|
1186
|
+
if maker_amt_raw <= 0:
|
|
1187
|
+
raise ValueError("amount too small for current tick size")
|
|
1188
|
+
|
|
1189
|
+
if side == "BUY":
|
|
1190
|
+
taker_amt_raw = maker_amt_raw / price
|
|
1191
|
+
elif side == "SELL":
|
|
1192
|
+
taker_amt_raw = maker_amt_raw * price
|
|
1193
|
+
else:
|
|
1194
|
+
raise ValueError("side must be 'BUY' or 'SELL'")
|
|
1195
|
+
|
|
1196
|
+
if self._decimal_places(taker_amt_raw) > amt_d:
|
|
1197
|
+
tmp = self._round_up(taker_amt_raw, amt_d + 4)
|
|
1198
|
+
taker_amt_raw = tmp if self._decimal_places(tmp) <= amt_d else self._round_down(tmp, amt_d)
|
|
1199
|
+
|
|
1200
|
+
maker_amount = self._to_token_decimals(maker_amt_raw)
|
|
1201
|
+
taker_amount = self._to_token_decimals(taker_amt_raw)
|
|
1202
|
+
|
|
1203
|
+
contract = self._contracts(self.chain_id, neg_risk)
|
|
1204
|
+
side_flag = 0 if side == "BUY" else 1
|
|
1205
|
+
sig_type = int(self.signature_type)
|
|
1206
|
+
|
|
1207
|
+
try:
|
|
1208
|
+
return Auth.sign_polymarket_order2(
|
|
1209
|
+
private_key=private_key,
|
|
1210
|
+
chain_id=self.chain_id,
|
|
1211
|
+
exchange_address=contract["exchange"],
|
|
1212
|
+
order={
|
|
1213
|
+
"maker": maker_addr,
|
|
1214
|
+
"signer": signer_addr,
|
|
1215
|
+
"taker": taker or ZERO_ADDRESS,
|
|
1216
|
+
"tokenId": str(token_id),
|
|
1217
|
+
"makerAmount": int(maker_amount),
|
|
1218
|
+
"takerAmount": int(taker_amount),
|
|
1219
|
+
"expiration": 0,
|
|
1220
|
+
"nonce": int(nonce or 0),
|
|
1221
|
+
"feeRateBps": int(fee_bps or 0),
|
|
1222
|
+
"side": side_flag,
|
|
1223
|
+
"signatureType": sig_type,
|
|
1224
|
+
},
|
|
1225
|
+
)
|
|
1226
|
+
except RuntimeError:
|
|
1227
|
+
# Fallback when coincurve is unavailable
|
|
1228
|
+
return Auth.sign_polymarket_order(
|
|
1229
|
+
private_key=private_key,
|
|
1230
|
+
chain_id=self.chain_id,
|
|
1231
|
+
exchange_address=contract["exchange"],
|
|
1232
|
+
order={
|
|
1233
|
+
"maker": maker_addr,
|
|
1234
|
+
"signer": signer_addr,
|
|
1235
|
+
"taker": taker or ZERO_ADDRESS,
|
|
1236
|
+
"tokenId": str(token_id),
|
|
1237
|
+
"makerAmount": int(maker_amount),
|
|
1238
|
+
"takerAmount": int(taker_amount),
|
|
1239
|
+
"expiration": 0,
|
|
1240
|
+
"nonce": int(nonce or 0),
|
|
1241
|
+
"feeRateBps": int(fee_bps or 0),
|
|
1242
|
+
"side": side_flag,
|
|
1243
|
+
"signatureType": sig_type,
|
|
1244
|
+
},
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
async def _resolve_tick_size(self, token_id: str, tick_size: str | float | None) -> str:
|
|
1248
|
+
if tick_size is not None:
|
|
1249
|
+
return str(tick_size)
|
|
1250
|
+
tick_resp = await self.get_tick_size(token_id)
|
|
1251
|
+
if isinstance(tick_resp, dict):
|
|
1252
|
+
return str(tick_resp.get("minimum_tick_size") or tick_resp.get("tick_size") or "0.01")
|
|
1253
|
+
return str(tick_resp)
|
|
1254
|
+
|
|
1255
|
+
async def _resolve_fee_rate(self, token_id: str, fee_rate_bps: int | None) -> int:
|
|
1256
|
+
if fee_rate_bps is not None:
|
|
1257
|
+
return int(fee_rate_bps)
|
|
1258
|
+
fee_resp = await self.get_fee_rate(token_id)
|
|
1259
|
+
if isinstance(fee_resp, dict):
|
|
1260
|
+
return int(fee_resp.get("base_fee", 0))
|
|
1261
|
+
return int(fee_resp or 0)
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
async def _signed_request_via_session(
|
|
1265
|
+
self, method: str, path: str, body: Mapping[str, Any] | list[Any] | None
|
|
1266
|
+
) -> Any:
|
|
1267
|
+
import time, base64, hmac, hashlib
|
|
1268
|
+
from eth_account import Account as _A
|
|
1269
|
+
import aiohttp
|
|
1270
|
+
|
|
1271
|
+
method = method.upper()
|
|
1272
|
+
session: aiohttp.ClientSession = getattr(self.client, "_session", None)
|
|
1273
|
+
if session is None:
|
|
1274
|
+
raise RuntimeError("pybotters client session missing")
|
|
1275
|
+
creds = getattr(session, "_polymarket_api_creds", None)
|
|
1276
|
+
if not creds:
|
|
1277
|
+
raise RuntimeError("Polymarket API creds missing; call create_or_derive_api_creds")
|
|
1278
|
+
api_key = creds.get("api_key")
|
|
1279
|
+
api_secret = creds.get("api_secret")
|
|
1280
|
+
api_passphrase = creds.get("api_passphrase")
|
|
1281
|
+
|
|
1282
|
+
# Reuse signing context cache
|
|
1283
|
+
private_key, _, signer_addr = self._get_signing_context()
|
|
1284
|
+
|
|
1285
|
+
cache_key = (api_key, api_secret, api_passphrase, private_key)
|
|
1286
|
+
cached = getattr(self, "_rest_sign_cache", None)
|
|
1287
|
+
if cached and cached.get("key") == cache_key:
|
|
1288
|
+
secret_bytes = cached["secret"]
|
|
1289
|
+
else:
|
|
1290
|
+
secret_bytes = base64.urlsafe_b64decode(api_secret)
|
|
1291
|
+
self._rest_sign_cache = {"key": cache_key, "secret": secret_bytes}
|
|
1292
|
+
|
|
1293
|
+
ts = int(time.time())
|
|
1294
|
+
request_path = path
|
|
1295
|
+
url = f"{self.rest_api}{request_path}"
|
|
1296
|
+
if isinstance(body, dict):
|
|
1297
|
+
payload_obj = dict(body)
|
|
1298
|
+
else:
|
|
1299
|
+
payload_obj = body
|
|
1300
|
+
serialized = (
|
|
1301
|
+
str(payload_obj).replace("'", '"') if payload_obj is not None else ""
|
|
1302
|
+
)
|
|
1303
|
+
msg = f"{ts}{method}{request_path}{serialized}"
|
|
1304
|
+
sig = hmac.new(secret_bytes, msg.encode("utf-8"), hashlib.sha256).digest()
|
|
1305
|
+
sign_b64 = base64.urlsafe_b64encode(sig).decode("utf-8")
|
|
1306
|
+
|
|
1307
|
+
headers = {
|
|
1308
|
+
"POLY_ADDRESS": signer_addr,
|
|
1309
|
+
"POLY_SIGNATURE": sign_b64,
|
|
1310
|
+
"POLY_TIMESTAMP": str(ts),
|
|
1311
|
+
"POLY_API_KEY": api_key,
|
|
1312
|
+
"POLY_PASSPHRASE": api_passphrase,
|
|
1313
|
+
"Content-Type": "application/json",
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
async with session.request(method, url, headers=headers, data=(serialized or None)) as resp:
|
|
1317
|
+
if resp.status >= 400:
|
|
1318
|
+
text = await resp.text()
|
|
1319
|
+
raise RuntimeError(f"Polymarket {method} {path} failed: {resp.status} {text}")
|
|
1320
|
+
try:
|
|
1321
|
+
return await resp.json()
|
|
1322
|
+
except Exception:
|
|
1323
|
+
return await resp.text()
|
|
1324
|
+
|
|
1325
|
+
async def post_orders(
|
|
1326
|
+
self,
|
|
1327
|
+
orders: Iterable[tuple[Mapping[str, Any], str]],
|
|
1328
|
+
*,
|
|
1329
|
+
owner: str | None = None,
|
|
1330
|
+
) -> Any:
|
|
1331
|
+
owner_key = self._owner_key(owner)
|
|
1332
|
+
body = [
|
|
1333
|
+
{
|
|
1334
|
+
"order": dict(order),
|
|
1335
|
+
"owner": owner_key,
|
|
1336
|
+
"orderType": order_type,
|
|
1337
|
+
}
|
|
1338
|
+
for order, order_type in orders
|
|
1339
|
+
]
|
|
1340
|
+
return await self._signed_request_via_session("POST", "/orders", body)
|
|
1341
|
+
|
|
1342
|
+
async def place_orders(
|
|
1343
|
+
self,
|
|
1344
|
+
items: Iterable[Mapping[str, Any]],
|
|
1345
|
+
*,
|
|
1346
|
+
owner: str | None = None,
|
|
1347
|
+
) -> Any:
|
|
1348
|
+
"""Create, sign and submit multiple orders.
|
|
1349
|
+
|
|
1350
|
+
Each item must include: token_id, side, price, size
|
|
1351
|
+
Optional per-item: tick_size, fee_rate_bps, expiration, taker, neg_risk, nonce, order_type
|
|
1352
|
+
.. code:: json
|
|
1353
|
+
|
|
1354
|
+
[
|
|
1355
|
+
{
|
|
1356
|
+
"errorMsg": "",
|
|
1357
|
+
"orderID": "0x4b9c3ee4dee8653f15f716653e8ac83f0a086a38597e6ec4b72be2389c79b8b4",
|
|
1358
|
+
"takingAmount": "",
|
|
1359
|
+
"makingAmount": "",
|
|
1360
|
+
"status": "live",
|
|
1361
|
+
"success": true
|
|
1362
|
+
},
|
|
1363
|
+
{
|
|
1364
|
+
"errorMsg": "",
|
|
1365
|
+
"orderID": "0xb3507e9fda9541c3e038afcb4f24b96efcfa667d46cf5e9e52c41620711818df",
|
|
1366
|
+
"takingAmount": "",
|
|
1367
|
+
"makingAmount": "",
|
|
1368
|
+
"status": "live",
|
|
1369
|
+
"success": true
|
|
1370
|
+
}
|
|
1371
|
+
]
|
|
1372
|
+
"""
|
|
1373
|
+
private_key, maker_addr, signer_addr = self._get_signing_context()
|
|
1374
|
+
owner_key = self._owner_key(owner)
|
|
1375
|
+
|
|
1376
|
+
result_body: list[dict[str, Any]] = []
|
|
1377
|
+
for it in items:
|
|
1378
|
+
token_id = str(it["token_id"])
|
|
1379
|
+
side = str(it["side"]).upper()
|
|
1380
|
+
price = float(it["price"])
|
|
1381
|
+
size = float(it["size"])
|
|
1382
|
+
order_type = str(it.get("order_type", "GTC"))
|
|
1383
|
+
tick_size = it.get("tick_size")
|
|
1384
|
+
fee_rate_bps = it.get("fee_rate_bps")
|
|
1385
|
+
expiration = it.get("expiration")
|
|
1386
|
+
taker = it.get("taker", ZERO_ADDRESS)
|
|
1387
|
+
neg_risk = bool(it.get("neg_risk", False))
|
|
1388
|
+
nonce = it.get("nonce")
|
|
1389
|
+
|
|
1390
|
+
signed = await self._build_signed_order(
|
|
1391
|
+
private_key=private_key,
|
|
1392
|
+
maker_addr=maker_addr,
|
|
1393
|
+
signer_addr=signer_addr,
|
|
1394
|
+
token_id=token_id,
|
|
1395
|
+
side=side,
|
|
1396
|
+
price=price,
|
|
1397
|
+
size=size,
|
|
1398
|
+
tick_size=tick_size,
|
|
1399
|
+
fee_rate_bps=fee_rate_bps,
|
|
1400
|
+
expiration=expiration,
|
|
1401
|
+
taker=taker,
|
|
1402
|
+
neg_risk=neg_risk,
|
|
1403
|
+
nonce=nonce,
|
|
1404
|
+
)
|
|
1405
|
+
|
|
1406
|
+
result_body.append(
|
|
1407
|
+
{
|
|
1408
|
+
"order": signed,
|
|
1409
|
+
"owner": owner_key,
|
|
1410
|
+
"orderType": order_type,
|
|
1411
|
+
}
|
|
1412
|
+
)
|
|
1413
|
+
|
|
1414
|
+
return await self._signed_request_via_session("POST", "/orders", result_body)
|
|
1415
|
+
|
|
1416
|
+
async def cancel(self, order_id: str) -> Any:
|
|
1417
|
+
"""
|
|
1418
|
+
{'not_canceled': {}, 'canceled': ['0xb3507e9fda9541c3e038afcb4f24b96efcfa667d46cf5e9e52c41620711818df', '0x4b9c3ee4dee8653f15f716653e8ac83f0a086a38597e6ec4b72be2389c79b8b4']}
|
|
1419
|
+
"""
|
|
1420
|
+
return await self._signed_request_via_session("DELETE", "/order", {"orderID": order_id})
|
|
1421
|
+
|
|
1422
|
+
async def cancel_orders(self, order_ids: Sequence[str]) -> Any:
|
|
1423
|
+
"""
|
|
1424
|
+
{'not_canceled': {}, 'canceled': ['0xb3507e9fda9541c3e038afcb4f24b96efcfa667d46cf5e9e52c41620711818df', '0x4b9c3ee4dee8653f15f716653e8ac83f0a086a38597e6ec4b72be2389c79b8b4']}
|
|
1425
|
+
"""
|
|
1426
|
+
return await self._signed_request_via_session("DELETE", "/orders", list(order_ids))
|
|
1427
|
+
|
|
1428
|
+
async def cancel_all(self) -> Any:
|
|
1429
|
+
"""
|
|
1430
|
+
{'not_canceled': {}, 'canceled': ['0xb3507e9fda9541c3e038afcb4f24b96efcfa667d46cf5e9e52c41620711818df', '0x4b9c3ee4dee8653f15f716653e8ac83f0a086a38597e6ec4b72be2389c79b8b4']}
|
|
1431
|
+
"""
|
|
1432
|
+
return await self._signed_request_via_session("DELETE", "/cancel-all", None)
|
|
1433
|
+
|
|
1434
|
+
async def cancel_market_orders(self, market: str = "", asset_id: str = "") -> Any:
|
|
1435
|
+
body = {"market": market, "asset_id": asset_id}
|
|
1436
|
+
return await self._signed_request_via_session("DELETE", "/cancel-market-orders", body)
|
|
1437
|
+
|
|
1438
|
+
async def get_order(self, order_id: str) -> Any:
|
|
1439
|
+
"""
|
|
1440
|
+
{
|
|
1441
|
+
"id": "0x4c47db1458b36d535106cdb450f20a27f4ec6ba458b9a86dc4a69afb8a81215c",
|
|
1442
|
+
"status": "MATCHED",
|
|
1443
|
+
"owner": "d6dba4d1-b21d-4272-ab9a-5ef8e8bf23bb",
|
|
1444
|
+
"maker_address": "0x03C3B0236c5a01051381482E77f2210349073A1d",
|
|
1445
|
+
"market": "0x42dc093dfcdd9ba2962baab1bb5de6c7209b14ed5d3d1f7d19dec12c14cbb489",
|
|
1446
|
+
"asset_id": "16982568567216474731533472146787083736159238827774998324575366977332426392345",
|
|
1447
|
+
"side": "BUY",
|
|
1448
|
+
"original_size": "1.8518",
|
|
1449
|
+
"size_matched": "1.85185",
|
|
1450
|
+
"price": "0.54",
|
|
1451
|
+
"outcome": "Up",
|
|
1452
|
+
"expiration": "0",
|
|
1453
|
+
"order_type": "FOK",
|
|
1454
|
+
"associate_trades": [
|
|
1455
|
+
"e16c9c49-7f4e-492e-9cb0-8778e54ad38a"
|
|
1456
|
+
],
|
|
1457
|
+
"created_at": 1763701801
|
|
1458
|
+
}
|
|
1459
|
+
"""
|
|
1460
|
+
return await self._rest("GET", f"/data/order/{order_id}")
|
|
1461
|
+
|
|
1462
|
+
async def get_orders(self, params: Mapping[str, Any] | None = None) -> list[Any]:
|
|
1463
|
+
return await self._paginate("/data/orders", params)
|
|
1464
|
+
|
|
1465
|
+
async def get_trades(self, params: Mapping[str, Any] | None = None) -> list[Any]:
|
|
1466
|
+
return await self._paginate("/data/trades", params)
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
async def get_notifications(self, signature_type: int | None = None) -> Any:
|
|
1470
|
+
sig = signature_type if signature_type is not None else self.signature_type
|
|
1471
|
+
query = {"signature_type": str(sig)}
|
|
1472
|
+
return await self._rest("GET", "/notifications", params=query)
|
|
1473
|
+
|
|
1474
|
+
async def drop_notifications(self, ids: Sequence[str] | None = None) -> Any:
|
|
1475
|
+
params = {"ids": ",".join(ids)} if ids else None
|
|
1476
|
+
return await self._rest("DELETE", "/notifications", params=params)
|
|
1477
|
+
|
|
1478
|
+
async def get_balance_allowance(self, **params: Any) -> Any:
|
|
1479
|
+
query = dict(params or {})
|
|
1480
|
+
query.setdefault("signature_type", self.signature_type)
|
|
1481
|
+
return await self._rest("GET", "/balance-allowance", params=query or None)
|
|
1482
|
+
|
|
1483
|
+
async def update_balance_allowance(self, **params: Any) -> Any:
|
|
1484
|
+
body = dict(params or {})
|
|
1485
|
+
body.setdefault("signature_type", self.signature_type)
|
|
1486
|
+
return await self._rest("POST", "/balance-allowance/update", json=body or None)
|
|
1487
|
+
|
|
1488
|
+
async def get_usdc(self):
|
|
1489
|
+
data = await self.get_balance_allowance(asset_type='COLLATERAL')
|
|
1490
|
+
balance = float(data.get('balance', 0.0))
|
|
1491
|
+
if balance > 0:
|
|
1492
|
+
balance = balance / 1e6
|
|
1493
|
+
return balance
|
|
1494
|
+
|
|
1495
|
+
async def get_position(self, token_id: str) -> Any:
|
|
1496
|
+
data = await self.get_balance_allowance(asset_type='CONDITIONAL', token_id=token_id)
|
|
1497
|
+
position = float(data.get('balance', 0.0))
|
|
1498
|
+
if position > 0:
|
|
1499
|
+
position = position / 1e6
|
|
1500
|
+
return position
|
|
1501
|
+
|
|
1502
|
+
async def get_mergeable_positions(
|
|
1503
|
+
self,
|
|
1504
|
+
*,
|
|
1505
|
+
size_threshold: float = 0.1,
|
|
1506
|
+
limit: int = 100,
|
|
1507
|
+
sort_by: str = "TOKENS",
|
|
1508
|
+
sort_direction: str = "DESC",
|
|
1509
|
+
user: str | None = None,
|
|
1510
|
+
neg_risk: bool = False,
|
|
1511
|
+
mergeable: bool = True,
|
|
1512
|
+
) -> Any:
|
|
1513
|
+
params = {
|
|
1514
|
+
"sizeThreshold": str(size_threshold),
|
|
1515
|
+
"limit": str(limit),
|
|
1516
|
+
"sortBy": sort_by,
|
|
1517
|
+
"sortDirection": sort_direction,
|
|
1518
|
+
"mergeable": str(mergeable).lower(),
|
|
1519
|
+
}
|
|
1520
|
+
if user is not None:
|
|
1521
|
+
params["user"] = user
|
|
1522
|
+
else:
|
|
1523
|
+
params['user'] = self.funder
|
|
1524
|
+
|
|
1525
|
+
if neg_risk:
|
|
1526
|
+
params["negRisk"] = "true"
|
|
1527
|
+
return await self._rest("GET", "/positions", params=params, host=DEFAULT_DATA_ENDPOINT)
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
async def merge_tokens_strict(
|
|
1531
|
+
self,
|
|
1532
|
+
condition_id: str,
|
|
1533
|
+
amount: float | None = None,
|
|
1534
|
+
neg_risk: bool = False,
|
|
1535
|
+
*,
|
|
1536
|
+
rpc_url: str | None = None,
|
|
1537
|
+
wait_timeout: int | None = 120,
|
|
1538
|
+
verbose: bool = False,
|
|
1539
|
+
) -> bool:
|
|
1540
|
+
"""严格按外部示例通过 Safe 合并头寸,返回 True/False。"""
|
|
1541
|
+
return await asyncio.to_thread(
|
|
1542
|
+
self._merge_tokens_strict_sync,
|
|
1543
|
+
condition_id,
|
|
1544
|
+
amount,
|
|
1545
|
+
neg_risk,
|
|
1546
|
+
rpc_url,
|
|
1547
|
+
wait_timeout,
|
|
1548
|
+
verbose,
|
|
1549
|
+
)
|
|
1550
|
+
|
|
1551
|
+
def _merge_tokens_strict_sync(
|
|
1552
|
+
self,
|
|
1553
|
+
condition_id: str,
|
|
1554
|
+
amount: float | None,
|
|
1555
|
+
neg_risk: bool,
|
|
1556
|
+
rpc_url: str | None,
|
|
1557
|
+
wait_timeout: int | None,
|
|
1558
|
+
verbose: bool,
|
|
1559
|
+
) -> bool:
|
|
1560
|
+
try:
|
|
1561
|
+
rpc = rpc_url or os.getenv("RPC_URL")
|
|
1562
|
+
if not rpc:
|
|
1563
|
+
raise RuntimeError("RPC_URL is required for merge_tokens_strict")
|
|
1564
|
+
# 使用独立 provider,避免共享缓存的速率限制
|
|
1565
|
+
w3 = Web3(Web3.HTTPProvider(rpc, request_kwargs={"timeout": 30}))
|
|
1566
|
+
with suppress(Exception):
|
|
1567
|
+
from web3.middleware import geth_poa_middleware, ExtraDataToPOAMiddleware
|
|
1568
|
+
try:
|
|
1569
|
+
w3.middleware_onion.inject(geth_poa_middleware, layer=0)
|
|
1570
|
+
except Exception:
|
|
1571
|
+
w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
|
|
1572
|
+
|
|
1573
|
+
from eth_account import Account as _A
|
|
1574
|
+
|
|
1575
|
+
pk_env = os.getenv("PK")
|
|
1576
|
+
if pk_env:
|
|
1577
|
+
private_key = pk_env
|
|
1578
|
+
else:
|
|
1579
|
+
private_key, _, _ = self._get_signing_context()
|
|
1580
|
+
account = _A.from_key(private_key)
|
|
1581
|
+
signer_addr = account.address
|
|
1582
|
+
safe_address_env = os.getenv("PROXY_WALLET") or self.funder
|
|
1583
|
+
if not safe_address_env:
|
|
1584
|
+
raise RuntimeError("Safe/proxy wallet address缺失,请在 PROXY_WALLET 或 funder 配置")
|
|
1585
|
+
safe_address = w3.to_checksum_address(safe_address_env)
|
|
1586
|
+
|
|
1587
|
+
cfg = self._contracts(self.chain_id, neg_risk)
|
|
1588
|
+
ctf_addr = w3.to_checksum_address(cfg["conditional_tokens"])
|
|
1589
|
+
coll_addr = w3.to_checksum_address(cfg["collateral"])
|
|
1590
|
+
adapter_addr = cfg.get("neg_risk_adapter") or NEG_RISK_ADAPTER_ADDRESS
|
|
1591
|
+
if verbose:
|
|
1592
|
+
print(
|
|
1593
|
+
f"[merge_tokens_strict] rpc={getattr(w3.provider, 'endpoint_uri', '')} "
|
|
1594
|
+
f"signer={signer_addr} safe={safe_address} neg_risk={neg_risk}"
|
|
1595
|
+
)
|
|
1596
|
+
|
|
1597
|
+
ctf_contract = w3.eth.contract(address=ctf_addr, abi=ctf_abi)
|
|
1598
|
+
if amount is None:
|
|
1599
|
+
parent_collection_id = bytes(32)
|
|
1600
|
+
cond_bytes = bytes.fromhex(condition_id[2:] if condition_id.startswith("0x") else condition_id)
|
|
1601
|
+
collection_id_0 = ctf_contract.functions.getCollectionId(parent_collection_id, cond_bytes, 1).call()
|
|
1602
|
+
collection_id_1 = ctf_contract.functions.getCollectionId(parent_collection_id, cond_bytes, 2).call()
|
|
1603
|
+
position_id_0 = ctf_contract.functions.getPositionId(coll_addr, collection_id_0).call()
|
|
1604
|
+
position_id_1 = ctf_contract.functions.getPositionId(coll_addr, collection_id_1).call()
|
|
1605
|
+
balance_0 = ctf_contract.functions.balanceOf(safe_address, position_id_0).call()
|
|
1606
|
+
balance_1 = ctf_contract.functions.balanceOf(safe_address, position_id_1).call()
|
|
1607
|
+
amount_wei = min(balance_0, balance_1)
|
|
1608
|
+
if amount_wei == 0:
|
|
1609
|
+
if verbose:
|
|
1610
|
+
print("Merge failed: No tokens to merge")
|
|
1611
|
+
return False
|
|
1612
|
+
if verbose:
|
|
1613
|
+
print(f"[merge_tokens_strict] balance0={balance_0} balance1={balance_1} amount_wei={amount_wei}")
|
|
1614
|
+
else:
|
|
1615
|
+
amount_wei = int(float(amount) * (10 ** USDCE_DIGITS))
|
|
1616
|
+
if verbose:
|
|
1617
|
+
print(f"[merge_tokens_strict] amount_input={amount} amount_wei={amount_wei}")
|
|
1618
|
+
|
|
1619
|
+
parent_collection_id_hex = ZERO_BYTES32
|
|
1620
|
+
partition = [1, 2]
|
|
1621
|
+
|
|
1622
|
+
data = ctf_contract.functions.mergePositions(
|
|
1623
|
+
coll_addr,
|
|
1624
|
+
bytes.fromhex(parent_collection_id_hex[2:]),
|
|
1625
|
+
bytes.fromhex(condition_id[2:] if condition_id.startswith("0x") else condition_id),
|
|
1626
|
+
partition,
|
|
1627
|
+
amount_wei,
|
|
1628
|
+
)._encode_transaction_data()
|
|
1629
|
+
|
|
1630
|
+
safe = w3.eth.contract(address=safe_address, abi=safe_abi)
|
|
1631
|
+
nonce_safe = safe.functions.nonce().call()
|
|
1632
|
+
to = adapter_addr if neg_risk else ctf_addr
|
|
1633
|
+
if verbose:
|
|
1634
|
+
print(f"[merge_tokens_strict] to={to} safe_nonce={nonce_safe}")
|
|
1635
|
+
|
|
1636
|
+
tx_hash_bytes = safe.functions.getTransactionHash(
|
|
1637
|
+
w3.to_checksum_address(to),
|
|
1638
|
+
0,
|
|
1639
|
+
bytes.fromhex(data[2:]),
|
|
1640
|
+
0,
|
|
1641
|
+
0,
|
|
1642
|
+
0,
|
|
1643
|
+
0,
|
|
1644
|
+
w3.to_checksum_address(ZERO_ADDRESS),
|
|
1645
|
+
w3.to_checksum_address(ZERO_ADDRESS),
|
|
1646
|
+
nonce_safe,
|
|
1647
|
+
).call()
|
|
1648
|
+
|
|
1649
|
+
hash_bytes = Web3.to_bytes(hexstr=tx_hash_bytes.hex() if hasattr(tx_hash_bytes, "hex") else tx_hash_bytes)
|
|
1650
|
+
signature_obj = _A._sign_hash(hash_bytes, private_key)
|
|
1651
|
+
r = signature_obj.r.to_bytes(32, byteorder="big")
|
|
1652
|
+
s = signature_obj.s.to_bytes(32, byteorder="big")
|
|
1653
|
+
v = signature_obj.v.to_bytes(1, byteorder="big")
|
|
1654
|
+
signature = r + s + v
|
|
1655
|
+
|
|
1656
|
+
tx = safe.functions.execTransaction(
|
|
1657
|
+
w3.to_checksum_address(to),
|
|
1658
|
+
0,
|
|
1659
|
+
bytes.fromhex(data[2:]),
|
|
1660
|
+
0,
|
|
1661
|
+
0,
|
|
1662
|
+
0,
|
|
1663
|
+
0,
|
|
1664
|
+
w3.to_checksum_address(ZERO_ADDRESS),
|
|
1665
|
+
w3.to_checksum_address(ZERO_ADDRESS),
|
|
1666
|
+
signature,
|
|
1667
|
+
).build_transaction(
|
|
1668
|
+
{
|
|
1669
|
+
"from": account.address,
|
|
1670
|
+
"nonce": w3.eth.get_transaction_count(account.address),
|
|
1671
|
+
"gas": 500000,
|
|
1672
|
+
"gasPrice": w3.eth.gas_price,
|
|
1673
|
+
}
|
|
1674
|
+
)
|
|
1675
|
+
if verbose:
|
|
1676
|
+
print(
|
|
1677
|
+
f"[merge_tokens_strict] send from={account.address} nonce={tx['nonce']} "
|
|
1678
|
+
f"gas={tx['gas']} gasPrice={tx['gasPrice']}"
|
|
1679
|
+
)
|
|
1680
|
+
|
|
1681
|
+
signed_tx = account.sign_transaction(tx)
|
|
1682
|
+
raw_tx = getattr(signed_tx, "rawTransaction", None) or getattr(signed_tx, "raw_transaction", None)
|
|
1683
|
+
if raw_tx is None:
|
|
1684
|
+
raise RuntimeError("Signed transaction missing rawTransaction/raw_transaction")
|
|
1685
|
+
tx_hash = w3.eth.send_raw_transaction(raw_tx)
|
|
1686
|
+
if verbose:
|
|
1687
|
+
print(f"[merge_tokens_strict] sent tx={tx_hash.hex()} safe_nonce={nonce_safe}")
|
|
1688
|
+
|
|
1689
|
+
if wait_timeout and wait_timeout > 0:
|
|
1690
|
+
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=wait_timeout, poll_latency=2)
|
|
1691
|
+
if verbose and receipt:
|
|
1692
|
+
print(f"[merge_tokens_strict] receipt status={receipt.get('status')} gasUsed={receipt.get('gasUsed')}")
|
|
1693
|
+
if receipt.get("status") == 1:
|
|
1694
|
+
if verbose:
|
|
1695
|
+
print(f"Merge successful! Amount: {amount_wei / 10 ** USDCE_DIGITS} USDC")
|
|
1696
|
+
return True
|
|
1697
|
+
if verbose:
|
|
1698
|
+
print("Merge failed: Transaction reverted")
|
|
1699
|
+
return False
|
|
1700
|
+
return True
|
|
1701
|
+
except Exception as exc:
|
|
1702
|
+
# 按用户要求:失败直接抛出,不做重试或静默 False
|
|
1703
|
+
raise
|
|
1704
|
+
|
|
1705
|
+
@staticmethod
|
|
1706
|
+
def _normalize_condition_hex(value: str, field: str) -> str:
|
|
1707
|
+
if not value:
|
|
1708
|
+
raise ValueError(f"{field} is required")
|
|
1709
|
+
if isinstance(value, bytes):
|
|
1710
|
+
value = value.hex()
|
|
1711
|
+
v = str(value)
|
|
1712
|
+
if not v.startswith("0x"):
|
|
1713
|
+
v = f"0x{v}"
|
|
1714
|
+
if len(v) != 66:
|
|
1715
|
+
raise ValueError(f"{field} must be 32-byte hex string")
|
|
1716
|
+
return v
|
|
1717
|
+
|
|
1718
|
+
def _normalize_split_amount(self, amount: float | int, raw_amount: bool) -> int:
|
|
1719
|
+
if raw_amount:
|
|
1720
|
+
if isinstance(amount, float) and not float(amount).is_integer():
|
|
1721
|
+
raise ValueError("raw_amount=True expects integer base-unit amount")
|
|
1722
|
+
amount_int = int(amount)
|
|
1723
|
+
if amount_int <= 0:
|
|
1724
|
+
raise ValueError("amount must be positive")
|
|
1725
|
+
return amount_int
|
|
1726
|
+
value = float(amount)
|
|
1727
|
+
if value <= 0:
|
|
1728
|
+
raise ValueError("amount must be positive")
|
|
1729
|
+
return self._to_token_decimals(value)
|
|
1730
|
+
|
|
1731
|
+
async def claim_positions(
|
|
1732
|
+
self,
|
|
1733
|
+
*,
|
|
1734
|
+
user: str | None = None,
|
|
1735
|
+
dry_run: bool = False,
|
|
1736
|
+
rpc_url: str | None = None,
|
|
1737
|
+
rpc_urls: Sequence[str] | None = None,
|
|
1738
|
+
gas: int = 300000,
|
|
1739
|
+
verbose: bool = False,
|
|
1740
|
+
limit: int = 500,
|
|
1741
|
+
include_receipt: bool = False,
|
|
1742
|
+
) -> list[dict[str, Any]]:
|
|
1743
|
+
"""自动拉取 data-api 的 redeemable 头寸并逐个 claim。
|
|
1744
|
+
|
|
1745
|
+
只对满足 redeemable==True 且 curPrice==1 且 size>0 的仓位执行,index_set=1<<outcomeIndex。
|
|
1746
|
+
始终使用 Safe 方式执行 redeemPositions(owner 私钥签名)。
|
|
1747
|
+
"""
|
|
1748
|
+
# 默认用代理钱包/资金钱包
|
|
1749
|
+
if user is None:
|
|
1750
|
+
entry = self._api_entry()
|
|
1751
|
+
user = entry[2] if entry and len(entry) > 2 else None
|
|
1752
|
+
if not user:
|
|
1753
|
+
raise RuntimeError("Polymarket claim_positions 需要提供 user (proxy wallet)")
|
|
1754
|
+
if not self.funder:
|
|
1755
|
+
self.funder = user
|
|
1756
|
+
|
|
1757
|
+
# 构造候选 RPC 列表 (单个 rpc_url 优先, 其次用户传入 rpc_urls, 最后内置默认)
|
|
1758
|
+
candidates: list[str] = []
|
|
1759
|
+
if rpc_url:
|
|
1760
|
+
candidates.append(rpc_url)
|
|
1761
|
+
for u in (rpc_urls or []):
|
|
1762
|
+
if u and u not in candidates:
|
|
1763
|
+
candidates.append(u)
|
|
1764
|
+
for u in DEFAULT_POLYGON_RPCS:
|
|
1765
|
+
if u not in candidates:
|
|
1766
|
+
candidates.append(u)
|
|
1767
|
+
|
|
1768
|
+
params = {"user": user, "limit": limit, "mergeable": "true", 'sizeThreshold': '.1', 'offset': '0'}
|
|
1769
|
+
positions = await self._rest(
|
|
1770
|
+
"GET",
|
|
1771
|
+
"/positions",
|
|
1772
|
+
params=params,
|
|
1773
|
+
host=DEFAULT_DATA_ENDPOINT,
|
|
1774
|
+
)
|
|
1775
|
+
|
|
1776
|
+
claimable: list[dict[str, Any]] = []
|
|
1777
|
+
for pos in positions or []:
|
|
1778
|
+
try:
|
|
1779
|
+
size = float(pos.get("size", 0) or 0)
|
|
1780
|
+
except Exception:
|
|
1781
|
+
size = 0.0
|
|
1782
|
+
redeemable = bool(pos.get("redeemable"))
|
|
1783
|
+
try:
|
|
1784
|
+
cur_price = float(pos.get("curPrice", 0) or 0)
|
|
1785
|
+
except Exception:
|
|
1786
|
+
cur_price = 0.0
|
|
1787
|
+
|
|
1788
|
+
condition_id = pos.get("conditionId")
|
|
1789
|
+
outcome_idx = pos.get("outcomeIndex") or pos.get("outcome_index") or 0
|
|
1790
|
+
if (
|
|
1791
|
+
not condition_id
|
|
1792
|
+
or not redeemable
|
|
1793
|
+
or size <= 0
|
|
1794
|
+
or cur_price != 1
|
|
1795
|
+
):
|
|
1796
|
+
continue
|
|
1797
|
+
try:
|
|
1798
|
+
idx_set = 1 << int(outcome_idx)
|
|
1799
|
+
except Exception:
|
|
1800
|
+
idx_set = 1
|
|
1801
|
+
claimable.append(
|
|
1802
|
+
{
|
|
1803
|
+
"condition_id": condition_id,
|
|
1804
|
+
"index_sets": [idx_set],
|
|
1805
|
+
"size": size,
|
|
1806
|
+
"outcome": pos.get("outcome"),
|
|
1807
|
+
"title": pos.get("title"),
|
|
1808
|
+
}
|
|
1809
|
+
)
|
|
1810
|
+
|
|
1811
|
+
results: list[dict[str, Any]] = []
|
|
1812
|
+
for item in claimable:
|
|
1813
|
+
if dry_run:
|
|
1814
|
+
results.append({**item, "tx": None, "dry_run": True})
|
|
1815
|
+
continue
|
|
1816
|
+
tx_hash = await asyncio.to_thread(
|
|
1817
|
+
self._claim_via_safe_sync,
|
|
1818
|
+
candidates,
|
|
1819
|
+
item["condition_id"],
|
|
1820
|
+
item["index_sets"],
|
|
1821
|
+
gas,
|
|
1822
|
+
verbose,
|
|
1823
|
+
)
|
|
1824
|
+
result_row = {**item, "tx": tx_hash, "dry_run": False}
|
|
1825
|
+
if include_receipt:
|
|
1826
|
+
try:
|
|
1827
|
+
receipt_info = await self.decode_claim_receipt(
|
|
1828
|
+
tx_hash,
|
|
1829
|
+
rpc_url=rpc_url or (rpc_urls[0] if rpc_urls else None),
|
|
1830
|
+
)
|
|
1831
|
+
result_row.update({"receipt": receipt_info})
|
|
1832
|
+
except Exception as exc: # pragma: no cover - 辅助信息获取失败
|
|
1833
|
+
result_row.update({"receipt_error": str(exc)})
|
|
1834
|
+
results.append(result_row)
|
|
1835
|
+
return results
|
|
1836
|
+
|
|
1837
|
+
async def get_usdc_web3(
|
|
1838
|
+
self,
|
|
1839
|
+
wallet: str = None,
|
|
1840
|
+
rpc_urls: Sequence[str] | None = None,
|
|
1841
|
+
) -> float:
|
|
1842
|
+
if wallet is None:
|
|
1843
|
+
# 找代理钱包, apis['polymarket'][2]
|
|
1844
|
+
entry = self._api_entry()
|
|
1845
|
+
if not entry or len(entry) < 3 or not entry[2]:
|
|
1846
|
+
raise RuntimeError("Polymarket funder wallet address is not configured")
|
|
1847
|
+
wallet = entry[2]
|
|
1848
|
+
|
|
1849
|
+
urls = list(rpc_urls or [])
|
|
1850
|
+
if not urls:
|
|
1851
|
+
urls.extend(DEFAULT_POLYGON_RPCS)
|
|
1852
|
+
|
|
1853
|
+
last_error: Exception | None = None
|
|
1854
|
+
for url in urls:
|
|
1855
|
+
try:
|
|
1856
|
+
balance = await asyncio.to_thread(self._call_usdc_balance, url, wallet)
|
|
1857
|
+
return balance
|
|
1858
|
+
except Exception as exc: # pragma: no cover - network failure fallback
|
|
1859
|
+
last_error = exc
|
|
1860
|
+
continue
|
|
1861
|
+
|
|
1862
|
+
raise RuntimeError("Unable to fetch USDC balance") from last_error
|
|
1863
|
+
|
|
1864
|
+
@staticmethod
|
|
1865
|
+
def _call_usdc_balance(rpc_url: str, wallet: str) -> float:
|
|
1866
|
+
w3 = _get_web3(rpc_url)
|
|
1867
|
+
contract = w3.eth.contract(
|
|
1868
|
+
address=w3.to_checksum_address(USDC_CONTRACT),
|
|
1869
|
+
abi=ERC20_BALANCE_OF_ABI,
|
|
1870
|
+
)
|
|
1871
|
+
balance = contract.functions.balanceOf(w3.to_checksum_address(wallet)).call()
|
|
1872
|
+
return balance / 10 ** 6
|
|
1873
|
+
|
|
1874
|
+
# ------------------------------------------------------------------
|
|
1875
|
+
# Internal utilities
|
|
1876
|
+
|
|
1877
|
+
async def decode_claim_receipt(
|
|
1878
|
+
self,
|
|
1879
|
+
tx_hash: str,
|
|
1880
|
+
*,
|
|
1881
|
+
rpc_url: str | None = None,
|
|
1882
|
+
) -> dict[str, Any]:
|
|
1883
|
+
if not tx_hash:
|
|
1884
|
+
raise ValueError("tx_hash is required")
|
|
1885
|
+
url = rpc_url or DEFAULT_POLYGON_RPCS[0]
|
|
1886
|
+
return await asyncio.to_thread(self._decode_payout_receipt_sync, tx_hash, url)
|
|
1887
|
+
|
|
1888
|
+
def _decode_payout_receipt_sync(self, tx_hash: str, rpc_url: str) -> dict[str, Any]:
|
|
1889
|
+
w3 = _get_web3(rpc_url)
|
|
1890
|
+
receipt = w3.eth.get_transaction_receipt(tx_hash)
|
|
1891
|
+
contract_cfg = self._contracts(self.chain_id, False)
|
|
1892
|
+
ctf_addr = w3.to_checksum_address(contract_cfg["conditional_tokens"])
|
|
1893
|
+
ctf = w3.eth.contract(address=ctf_addr, abi=CONDITIONAL_TOKENS_ABI)
|
|
1894
|
+
|
|
1895
|
+
decoded = []
|
|
1896
|
+
try:
|
|
1897
|
+
decoded = ctf.events.PayoutRedemption().process_receipt(receipt)
|
|
1898
|
+
except Exception:
|
|
1899
|
+
decoded = []
|
|
1900
|
+
|
|
1901
|
+
if decoded:
|
|
1902
|
+
ev = decoded[0]["args"]
|
|
1903
|
+
index_sets = [int(x) for x in ev.get("indexSets", [])]
|
|
1904
|
+
payout = int(ev.get("payout", 0))
|
|
1905
|
+
return {
|
|
1906
|
+
"status": receipt.status,
|
|
1907
|
+
"gasUsed": receipt.gasUsed,
|
|
1908
|
+
"indexSets": index_sets,
|
|
1909
|
+
"payout": payout,
|
|
1910
|
+
"redeemer": ev.get("redeemer"),
|
|
1911
|
+
"collateralToken": ev.get("collateralToken"),
|
|
1912
|
+
"conditionId": ev.get("conditionId").hex()
|
|
1913
|
+
if hasattr(ev.get("conditionId"), "hex")
|
|
1914
|
+
else ev.get("conditionId"),
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
return {"status": receipt.status, "gasUsed": receipt.gasUsed}
|
|
1918
|
+
|
|
1919
|
+
def _claim_via_safe_sync(
|
|
1920
|
+
self,
|
|
1921
|
+
rpc_urls: Sequence[str],
|
|
1922
|
+
condition_id: str,
|
|
1923
|
+
index_sets: list[int],
|
|
1924
|
+
gas: int,
|
|
1925
|
+
verbose: bool = False,
|
|
1926
|
+
) -> str:
|
|
1927
|
+
from eth_account import Account as _A
|
|
1928
|
+
from time import sleep
|
|
1929
|
+
from web3 import Web3
|
|
1930
|
+
from web3.exceptions import Web3RPCError
|
|
1931
|
+
|
|
1932
|
+
# Prefer explicit RPC_URL env, then user-provided list
|
|
1933
|
+
candidates: list[str] = []
|
|
1934
|
+
env_rpc = os.getenv("RPC_URL")
|
|
1935
|
+
if env_rpc:
|
|
1936
|
+
candidates.append(env_rpc)
|
|
1937
|
+
for u in rpc_urls:
|
|
1938
|
+
if u and u not in candidates:
|
|
1939
|
+
candidates.append(u)
|
|
1940
|
+
if not candidates:
|
|
1941
|
+
candidates = list(DEFAULT_POLYGON_RPCS)
|
|
1942
|
+
|
|
1943
|
+
last_error: Exception | None = None
|
|
1944
|
+
w3 = None
|
|
1945
|
+
rpc_used = None
|
|
1946
|
+
for url in candidates:
|
|
1947
|
+
try:
|
|
1948
|
+
w3 = Web3(Web3.HTTPProvider(url, request_kwargs={"timeout": 30}))
|
|
1949
|
+
with suppress(Exception):
|
|
1950
|
+
from web3.middleware import geth_poa_middleware, ExtraDataToPOAMiddleware
|
|
1951
|
+
try:
|
|
1952
|
+
w3.middleware_onion.inject(geth_poa_middleware, layer=0)
|
|
1953
|
+
except Exception:
|
|
1954
|
+
w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
|
|
1955
|
+
rpc_used = url
|
|
1956
|
+
break
|
|
1957
|
+
except Exception as exc:
|
|
1958
|
+
last_error = exc
|
|
1959
|
+
continue
|
|
1960
|
+
if w3 is None:
|
|
1961
|
+
raise RuntimeError(f"All RPC endpoints failed: {candidates}") from last_error
|
|
1962
|
+
|
|
1963
|
+
pk_env = os.getenv("PK")
|
|
1964
|
+
if pk_env:
|
|
1965
|
+
private_key = pk_env
|
|
1966
|
+
else:
|
|
1967
|
+
private_key, _, _ = self._get_signing_context()
|
|
1968
|
+
signer_addr = _A.from_key(private_key).address
|
|
1969
|
+
safe_addr = self.funder
|
|
1970
|
+
if not safe_addr:
|
|
1971
|
+
raise RuntimeError("Safe/proxy wallet address未知, 请在 apis['polymarket'][2] 或构造函数 funder 设置")
|
|
1972
|
+
|
|
1973
|
+
ctf_addr = self._contracts(self.chain_id, False)["conditional_tokens"]
|
|
1974
|
+
ctf = w3.eth.contract(address=w3.to_checksum_address(ctf_addr), abi=CONDITIONAL_TOKENS_ABI)
|
|
1975
|
+
safe = w3.eth.contract(address=w3.to_checksum_address(safe_addr), abi=SAFE_ABI)
|
|
1976
|
+
|
|
1977
|
+
cond_bytes = bytes.fromhex(condition_id.replace("0x", ""))
|
|
1978
|
+
if len(cond_bytes) != 32:
|
|
1979
|
+
raise ValueError("condition_id must be 32-byte hex string")
|
|
1980
|
+
|
|
1981
|
+
redeem_calldata = ctf.encode_abi(
|
|
1982
|
+
"redeemPositions",
|
|
1983
|
+
args=[
|
|
1984
|
+
w3.to_checksum_address(USDC_CONTRACT),
|
|
1985
|
+
bytes(32),
|
|
1986
|
+
cond_bytes,
|
|
1987
|
+
index_sets,
|
|
1988
|
+
],
|
|
1989
|
+
)
|
|
1990
|
+
|
|
1991
|
+
safe_tx_gas = 0
|
|
1992
|
+
base_gas = 0
|
|
1993
|
+
gas_price = 0
|
|
1994
|
+
gas_token = ZERO_ADDRESS
|
|
1995
|
+
refund_receiver = ZERO_ADDRESS
|
|
1996
|
+
value = 0
|
|
1997
|
+
operation = 0 # CALL
|
|
1998
|
+
|
|
1999
|
+
try:
|
|
2000
|
+
safe_nonce = safe.functions.nonce().call()
|
|
2001
|
+
except Web3RPCError as exc:
|
|
2002
|
+
# 速率限制等错误直接抛出,便于调用层切换 RPC
|
|
2003
|
+
raise RuntimeError(f"获取 Safe nonce 失败 (rpc={rpc_used}): {exc}") from exc
|
|
2004
|
+
except Exception as exc:
|
|
2005
|
+
raise RuntimeError("无法获取 Safe nonce, 请确认 funder 地址为有效 Safe") from exc
|
|
2006
|
+
|
|
2007
|
+
try:
|
|
2008
|
+
safe_tx_hash = safe.functions.getTransactionHash(
|
|
2009
|
+
w3.to_checksum_address(ctf_addr),
|
|
2010
|
+
value,
|
|
2011
|
+
redeem_calldata,
|
|
2012
|
+
operation,
|
|
2013
|
+
safe_tx_gas,
|
|
2014
|
+
base_gas,
|
|
2015
|
+
gas_price,
|
|
2016
|
+
w3.to_checksum_address(gas_token),
|
|
2017
|
+
w3.to_checksum_address(refund_receiver),
|
|
2018
|
+
safe_nonce,
|
|
2019
|
+
).call()
|
|
2020
|
+
except Web3RPCError as exc:
|
|
2021
|
+
raise RuntimeError(f"Safe getTransactionHash 调用失败 (rpc={rpc_used}): {exc}") from exc
|
|
2022
|
+
except Exception as exc:
|
|
2023
|
+
raise RuntimeError("Safe getTransactionHash 调用失败") from exc
|
|
2024
|
+
|
|
2025
|
+
signed = _A._sign_hash(safe_tx_hash, private_key) # eth_sign 风格
|
|
2026
|
+
sig_bytes = (
|
|
2027
|
+
int(signed.r).to_bytes(32, "big")
|
|
2028
|
+
+ int(signed.s).to_bytes(32, "big")
|
|
2029
|
+
+ bytes([signed.v])
|
|
2030
|
+
)
|
|
2031
|
+
|
|
2032
|
+
try:
|
|
2033
|
+
acct = _A.from_key(private_key)
|
|
2034
|
+
sender = acct.address
|
|
2035
|
+
# 使用 pending 避免重复使用已在 mempool 的 nonce 触发 replacement underpriced
|
|
2036
|
+
nonce = w3.eth.get_transaction_count(sender)
|
|
2037
|
+
gas_price_chain = w3.eth.gas_price
|
|
2038
|
+
except Exception as exc:
|
|
2039
|
+
raise RuntimeError("获取 sender nonce/gas_price 失败") from exc
|
|
2040
|
+
|
|
2041
|
+
tx = safe.functions.execTransaction(
|
|
2042
|
+
w3.to_checksum_address(ctf_addr),
|
|
2043
|
+
value,
|
|
2044
|
+
redeem_calldata,
|
|
2045
|
+
operation,
|
|
2046
|
+
safe_tx_gas,
|
|
2047
|
+
base_gas,
|
|
2048
|
+
gas_price,
|
|
2049
|
+
w3.to_checksum_address(gas_token),
|
|
2050
|
+
w3.to_checksum_address(refund_receiver),
|
|
2051
|
+
sig_bytes,
|
|
2052
|
+
).build_transaction(
|
|
2053
|
+
{
|
|
2054
|
+
"from": sender,
|
|
2055
|
+
"nonce": nonce,
|
|
2056
|
+
"gas": gas,
|
|
2057
|
+
"gasPrice": gas_price_chain,
|
|
2058
|
+
}
|
|
2059
|
+
)
|
|
2060
|
+
|
|
2061
|
+
signed_tx = w3.eth.account.sign_transaction(tx, private_key)
|
|
2062
|
+
send_errors: list[str] = []
|
|
2063
|
+
for attempt in range(3):
|
|
2064
|
+
try:
|
|
2065
|
+
raw_tx = getattr(signed_tx, "rawTransaction", None) or getattr(signed_tx, "raw_transaction", None)
|
|
2066
|
+
if raw_tx is None: # pragma: no cover
|
|
2067
|
+
raise AttributeError("Signed transaction missing rawTransaction/raw_transaction")
|
|
2068
|
+
tx_hash = w3.eth.send_raw_transaction(raw_tx)
|
|
2069
|
+
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, poll_latency=2, timeout=180)
|
|
2070
|
+
if receipt.get("status") != 1:
|
|
2071
|
+
raise RuntimeError(f"Safe redeemPositions failed status!=1: {tx_hash.hex()}")
|
|
2072
|
+
if verbose:
|
|
2073
|
+
print(
|
|
2074
|
+
{
|
|
2075
|
+
"tx": tx_hash.hex(),
|
|
2076
|
+
"safe_nonce": safe_nonce,
|
|
2077
|
+
"wallet": sender,
|
|
2078
|
+
"gasPrice": gas_price_chain,
|
|
2079
|
+
"gas": gas,
|
|
2080
|
+
"rpc_used": rpc_used or getattr(w3.provider, "endpoint_uri", "unknown"),
|
|
2081
|
+
}
|
|
2082
|
+
)
|
|
2083
|
+
return tx_hash.hex()
|
|
2084
|
+
except Exception as exc:
|
|
2085
|
+
send_errors.append(str(exc))
|
|
2086
|
+
if verbose:
|
|
2087
|
+
print(f"Safe redeem attempt {attempt+1} failed: {exc}")
|
|
2088
|
+
sleep(0.5)
|
|
2089
|
+
continue
|
|
2090
|
+
raise RuntimeError(f"Safe redeem all attempts failed: {' | '.join(send_errors)}")
|
|
2091
|
+
|
|
2092
|
+
async def _fetch_event(self, slug: str) -> dict | None:
|
|
2093
|
+
resp = await self.client.get(GAMMA_EVENTS_API, params={"slug": slug})
|
|
2094
|
+
payload = await resp.json()
|
|
2095
|
+
if isinstance(payload, list) and payload:
|
|
2096
|
+
return payload[0]
|
|
2097
|
+
return None
|
|
2098
|
+
|
|
2099
|
+
async def find_active_market(
|
|
2100
|
+
self,
|
|
2101
|
+
*,
|
|
2102
|
+
base_slug: str = DEFAULT_BASE_SLUG,
|
|
2103
|
+
interval: int = DEFAULT_INTERVAL,
|
|
2104
|
+
window: int = DEFAULT_WINDOW,
|
|
2105
|
+
) -> tuple[str, dict, dict]:
|
|
2106
|
+
|
|
2107
|
+
"""
|
|
2108
|
+
返回值: slug, event, market
|
|
2109
|
+
https://docs.polymarket.com/api-reference/markets/get-market-by-id
|
|
2110
|
+
"""
|
|
2111
|
+
|
|
2112
|
+
async def _try_slug(slug: str | None) -> tuple[str, dict, dict] | None:
|
|
2113
|
+
if not slug:
|
|
2114
|
+
return None
|
|
2115
|
+
event = await self._fetch_event(slug)
|
|
2116
|
+
if not event:
|
|
2117
|
+
return None
|
|
2118
|
+
|
|
2119
|
+
event = {k: parse_field(v) for k, v in event.items()}
|
|
2120
|
+
for market in event.get("markets", []):
|
|
2121
|
+
if not _accepting_orders(market):
|
|
2122
|
+
continue
|
|
2123
|
+
market = {k: parse_field(v) for k, v in market.items()}
|
|
2124
|
+
return slug, event, market
|
|
2125
|
+
return None
|
|
2126
|
+
|
|
2127
|
+
if base_slug == HOURLY_BITCOIN_BASE_SLUG:
|
|
2128
|
+
hourly_slug = _compose_hourly_slug(base_slug)
|
|
2129
|
+
hourly_match = await _try_slug(hourly_slug)
|
|
2130
|
+
if hourly_match:
|
|
2131
|
+
return hourly_match
|
|
2132
|
+
|
|
2133
|
+
now_ts = int(datetime.now(UTC).timestamp())
|
|
2134
|
+
base_ts = (now_ts // interval) * interval
|
|
2135
|
+
|
|
2136
|
+
for offset in _iter_offsets(window):
|
|
2137
|
+
ts = base_ts + offset * interval
|
|
2138
|
+
if ts < 0:
|
|
2139
|
+
continue
|
|
2140
|
+
slug = f"{base_slug}-{ts}"
|
|
2141
|
+
result = await _try_slug(slug)
|
|
2142
|
+
if result:
|
|
2143
|
+
return result
|
|
2144
|
+
|
|
2145
|
+
raise RuntimeError(
|
|
2146
|
+
f"未在 {base_slug} 的 +/-{window} 个区间内找到可交易的市场"
|
|
2147
|
+
)
|
|
2148
|
+
|
|
2149
|
+
async def resolve_active_market_tokens(
|
|
2150
|
+
self,
|
|
2151
|
+
*,
|
|
2152
|
+
base_slug: str = DEFAULT_BASE_SLUG,
|
|
2153
|
+
interval: int = DEFAULT_INTERVAL,
|
|
2154
|
+
window: int = DEFAULT_WINDOW,
|
|
2155
|
+
) -> tuple[str, dict, dict, list[Any], list[Any]]:
|
|
2156
|
+
slug, event, market = await self.find_active_market(
|
|
2157
|
+
base_slug=base_slug,
|
|
2158
|
+
interval=interval,
|
|
2159
|
+
window=window,
|
|
2160
|
+
)
|
|
2161
|
+
|
|
2162
|
+
outcomes = _parse_list(market.get("outcomes"))
|
|
2163
|
+
token_ids = _parse_list(market.get("clobTokenIds"))
|
|
2164
|
+
|
|
2165
|
+
if not outcomes or not token_ids:
|
|
2166
|
+
raise RuntimeError("market 数据缺少 outcomes 或 clobTokenIds 字段")
|
|
2167
|
+
if len(outcomes) != len(token_ids):
|
|
2168
|
+
raise RuntimeError("outcomes 与 clobTokenIds 数量不匹配")
|
|
2169
|
+
|
|
2170
|
+
return slug, event, market, outcomes, token_ids
|
|
2171
|
+
|
|
2172
|
+
async def _rest(
|
|
2173
|
+
self,
|
|
2174
|
+
method: str,
|
|
2175
|
+
path: str,
|
|
2176
|
+
*,
|
|
2177
|
+
params: Mapping[str, Any] | None = None,
|
|
2178
|
+
json: Any = None,
|
|
2179
|
+
host: str | None = None,
|
|
2180
|
+
) -> Any:
|
|
2181
|
+
|
|
2182
|
+
url = f"{host}{path}" if host else f"{self.rest_api}{path}"
|
|
2183
|
+
request_kwargs: dict[str, Any] = {}
|
|
2184
|
+
if params:
|
|
2185
|
+
request_kwargs["params"] = {k: v for k, v in params.items() if v is not None}
|
|
2186
|
+
if json is not None:
|
|
2187
|
+
request_kwargs["json"] = json
|
|
2188
|
+
|
|
2189
|
+
requester = getattr(self.client, method.lower())
|
|
2190
|
+
resp = await requester(url, **request_kwargs)
|
|
2191
|
+
if resp.status >= 400:
|
|
2192
|
+
text = await resp.text()
|
|
2193
|
+
raise RuntimeError(f"Polymarket {method} {path} failed: {resp.status} {text}")
|
|
2194
|
+
if resp.content_length == 0:
|
|
2195
|
+
return None
|
|
2196
|
+
return await resp.json()
|
|
2197
|
+
|
|
2198
|
+
async def _paginate(
|
|
2199
|
+
self,
|
|
2200
|
+
path: str,
|
|
2201
|
+
params: Mapping[str, Any] | None = None,
|
|
2202
|
+
) -> list[Any]:
|
|
2203
|
+
filters = {k: v for k, v in (params or {}).items() if v is not None}
|
|
2204
|
+
cursor = "MA=="
|
|
2205
|
+
results: list[Any] = []
|
|
2206
|
+
while cursor != END_CURSOR:
|
|
2207
|
+
query = dict(filters)
|
|
2208
|
+
if cursor:
|
|
2209
|
+
query["next_cursor"] = cursor
|
|
2210
|
+
payload = await self._rest("GET", path, params=query or None)
|
|
2211
|
+
cursor = payload.get("next_cursor", END_CURSOR)
|
|
2212
|
+
results.extend(payload.get("data", []))
|
|
2213
|
+
return results
|
|
2214
|
+
|
|
2215
|
+
def _token_list(self, token_ids: Sequence[str] | str) -> list[str]:
|
|
2216
|
+
if isinstance(token_ids, str):
|
|
2217
|
+
tokens = [token_ids]
|
|
2218
|
+
else:
|
|
2219
|
+
tokens = list(token_ids)
|
|
2220
|
+
tokens = [tid for tid in tokens if tid]
|
|
2221
|
+
if not tokens:
|
|
2222
|
+
raise ValueError("token_ids must not be empty")
|
|
2223
|
+
return tokens
|
|
2224
|
+
|
|
2225
|
+
def _owner_key(self, owner: str | None = None) -> str:
|
|
2226
|
+
if owner:
|
|
2227
|
+
return owner
|
|
2228
|
+
creds = self._api_creds()
|
|
2229
|
+
api_key = creds.get("api_key") if creds else None
|
|
2230
|
+
if not api_key:
|
|
2231
|
+
raise RuntimeError("Polymarket API key missing; call create_or_derive_api_creds first")
|
|
2232
|
+
return api_key
|
|
2233
|
+
|
|
2234
|
+
def _api_entry(self) -> list[Any] | None:
|
|
2235
|
+
session = getattr(self.client, "_session", None)
|
|
2236
|
+
if session is None:
|
|
2237
|
+
return None
|
|
2238
|
+
apis = getattr(session, "_apis", None) or session.__dict__.get("_apis")
|
|
2239
|
+
if apis is None:
|
|
2240
|
+
return None
|
|
2241
|
+
return apis.get(API_NAME)
|
|
2242
|
+
|
|
2243
|
+
def _api_creds(self) -> dict[str, Any] | None:
|
|
2244
|
+
session = getattr(self.client, "_session", None)
|
|
2245
|
+
if session is None:
|
|
2246
|
+
return None
|
|
2247
|
+
return getattr(session, "_polymarket_api_creds", None)
|
|
2248
|
+
|
|
2249
|
+
def _store_api_creds(self, data: Mapping[str, Any]) -> None:
|
|
2250
|
+
session = getattr(self.client, "_session", None)
|
|
2251
|
+
if session is None:
|
|
2252
|
+
raise RuntimeError("pybotters Client session not initialized for Polymarket creds")
|
|
2253
|
+
creds = {
|
|
2254
|
+
"api_key": data.get("apiKey") or data.get("api_key"),
|
|
2255
|
+
"api_secret": data.get("secret") or data.get("api_secret"),
|
|
2256
|
+
"api_passphrase": data.get("passphrase") or data.get("api_passphrase"),
|
|
2257
|
+
}
|
|
2258
|
+
if not creds["api_key"] or not creds["api_secret"] or not creds["api_passphrase"]:
|
|
2259
|
+
raise RuntimeError("Polymarket API creds response missing key/secret/passphrase")
|
|
2260
|
+
session.__dict__["_polymarket_api_creds"] = creds
|
|
2261
|
+
apis = session.__dict__.get("_apis")
|
|
2262
|
+
if isinstance(apis, dict):
|
|
2263
|
+
entry = list(apis.get(API_NAME, []))
|
|
2264
|
+
while len(entry) < 7:
|
|
2265
|
+
entry.append("")
|
|
2266
|
+
entry[4] = creds["api_key"]
|
|
2267
|
+
entry[5] = creds["api_secret"]
|
|
2268
|
+
entry[6] = creds["api_passphrase"]
|
|
2269
|
+
apis[API_NAME] = entry
|
|
2270
|
+
|
|
2271
|
+
def _ensure_session_entry(
|
|
2272
|
+
self,
|
|
2273
|
+
*,
|
|
2274
|
+
private_key: str | None,
|
|
2275
|
+
funder: str | None,
|
|
2276
|
+
chain_id: int | None,
|
|
2277
|
+
) -> None:
|
|
2278
|
+
session = getattr(self.client, "_session", None)
|
|
2279
|
+
if session is None:
|
|
2280
|
+
raise RuntimeError("pybotters.Client session not initialized")
|
|
2281
|
+
apis = getattr(session, "_apis", None)
|
|
2282
|
+
if apis is None:
|
|
2283
|
+
raise RuntimeError("pybotters Client missing _apis; load apis.json when creating the client")
|
|
2284
|
+
|
|
2285
|
+
entry = list(apis.get(API_NAME, []))
|
|
2286
|
+
if not entry and not private_key:
|
|
2287
|
+
return
|
|
2288
|
+
|
|
2289
|
+
packed = entry[2] if len(entry) > 2 else None
|
|
2290
|
+
if not isinstance(packed, (list, tuple)):
|
|
2291
|
+
packed = None
|
|
2292
|
+
|
|
2293
|
+
def _packed_value(idx: int) -> Any | None:
|
|
2294
|
+
if packed is None:
|
|
2295
|
+
return None
|
|
2296
|
+
if idx >= len(packed):
|
|
2297
|
+
return None
|
|
2298
|
+
value = packed[idx]
|
|
2299
|
+
if isinstance(value, str):
|
|
2300
|
+
value = value.strip()
|
|
2301
|
+
return value or None
|
|
2302
|
+
|
|
2303
|
+
packed_api_key = _packed_value(0)
|
|
2304
|
+
packed_api_secret = _packed_value(1)
|
|
2305
|
+
packed_passphrase = _packed_value(2)
|
|
2306
|
+
packed_chain_id = _packed_value(3)
|
|
2307
|
+
packed_wallet = _packed_value(4)
|
|
2308
|
+
|
|
2309
|
+
while len(entry) < 3:
|
|
2310
|
+
entry.append("")
|
|
2311
|
+
|
|
2312
|
+
existing_pk = entry[0] if entry else None
|
|
2313
|
+
normalized_pk: str | None = None
|
|
2314
|
+
candidate_pk = private_key or existing_pk
|
|
2315
|
+
if candidate_pk:
|
|
2316
|
+
candidate_pk = str(candidate_pk)
|
|
2317
|
+
normalized_pk = (
|
|
2318
|
+
candidate_pk if candidate_pk.startswith("0x") else f"0x{candidate_pk}"
|
|
2319
|
+
)
|
|
2320
|
+
|
|
2321
|
+
if not normalized_pk:
|
|
2322
|
+
raise RuntimeError("Polymarket需要钱包私钥 (apis['polymarket'][0])")
|
|
2323
|
+
|
|
2324
|
+
entry[0] = normalized_pk
|
|
2325
|
+
|
|
2326
|
+
existing_wallet = entry[2] if isinstance(entry[2], str) and entry[2] else None
|
|
2327
|
+
effective_wallet = funder or packed_wallet or existing_wallet or self.funder
|
|
2328
|
+
if effective_wallet:
|
|
2329
|
+
entry[2] = effective_wallet
|
|
2330
|
+
self.funder = effective_wallet
|
|
2331
|
+
else:
|
|
2332
|
+
entry[2] = ""
|
|
2333
|
+
|
|
2334
|
+
derived_chain_id: int | None = None
|
|
2335
|
+
if packed_chain_id is not None:
|
|
2336
|
+
try:
|
|
2337
|
+
derived_chain_id = int(packed_chain_id)
|
|
2338
|
+
except (TypeError, ValueError):
|
|
2339
|
+
derived_chain_id = None
|
|
2340
|
+
if chain_id is None and derived_chain_id is not None:
|
|
2341
|
+
self.chain_id = derived_chain_id
|
|
2342
|
+
|
|
2343
|
+
if packed_api_key and packed_api_secret and packed_passphrase:
|
|
2344
|
+
session.__dict__["_polymarket_api_creds"] = {
|
|
2345
|
+
"api_key": packed_api_key,
|
|
2346
|
+
"api_secret": packed_api_secret,
|
|
2347
|
+
"api_passphrase": packed_passphrase,
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
apis[API_NAME] = entry
|
|
2351
|
+
session.__dict__["_apis"] = apis
|
|
2352
|
+
session.__dict__["_polymarket_chain_id"] = self.chain_id
|
|
2353
|
+
session.__dict__["_polymarket_signature_type"] = self.signature_type
|
|
2354
|
+
self.auth = True
|
|
2355
|
+
|
|
2356
|
+
@staticmethod
|
|
2357
|
+
def load_poly_api():
|
|
2358
|
+
from dotenv import load_dotenv
|
|
2359
|
+
|
|
2360
|
+
load_dotenv()
|
|
2361
|
+
pk = os.getenv("PK")
|
|
2362
|
+
api_key = os.getenv("CLOB_API_KEY")
|
|
2363
|
+
api_secret = os.getenv("CLOB_API_SECRET")
|
|
2364
|
+
passphrase = os.getenv("CLOB_API_PASSPHRASE")
|
|
2365
|
+
chain_id = os.getenv("CHAIN_ID") or 137
|
|
2366
|
+
wallet_address = os.getenv("POLY_WALLET_ADDRESS")
|
|
2367
|
+
return [pk, "", (api_key, api_secret, passphrase, chain_id, wallet_address)]
|
|
2368
|
+
|
|
2369
|
+
@lru_cache(maxsize=8)
|
|
2370
|
+
def _get_web3(rpc_url: str | None):
|
|
2371
|
+
"""创建 web3 对象, 带多 RPC 备用与重试.
|
|
2372
|
+
|
|
2373
|
+
逻辑:
|
|
2374
|
+
1. 优先使用传入 rpc_url (如果提供)
|
|
2375
|
+
2. 失败则按 DEFAULT_POLYGON_RPCS 顺序依次尝试
|
|
2376
|
+
3. 任一连接成功立即返回, 全部失败抛出统一异常
|
|
2377
|
+
"""
|
|
2378
|
+
from web3 import Web3
|
|
2379
|
+
|
|
2380
|
+
candidates: list[str] = []
|
|
2381
|
+
if rpc_url:
|
|
2382
|
+
candidates.append(rpc_url)
|
|
2383
|
+
for u in DEFAULT_POLYGON_RPCS:
|
|
2384
|
+
if u not in candidates:
|
|
2385
|
+
candidates.append(u)
|
|
2386
|
+
|
|
2387
|
+
last_error: Exception | None = None
|
|
2388
|
+
for url in candidates:
|
|
2389
|
+
try:
|
|
2390
|
+
provider = Web3.HTTPProvider(url, request_kwargs={"timeout": 7})
|
|
2391
|
+
w3 = Web3(provider)
|
|
2392
|
+
if w3.is_connected():
|
|
2393
|
+
return w3
|
|
2394
|
+
last_error = RuntimeError(f"RPC not connected: {url}")
|
|
2395
|
+
except Exception as exc: # pragma: no cover - 网络异常
|
|
2396
|
+
last_error = exc
|
|
2397
|
+
continue
|
|
2398
|
+
|
|
2399
|
+
raise RuntimeError(f"Failed to connect Polygon RPCs: {candidates}") from last_error
|