hyperquant 1.48__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hyperquant might be problematic. Click here for more details.

Files changed (42) hide show
  1. hyperquant/__init__.py +8 -0
  2. hyperquant/broker/auth.py +972 -0
  3. hyperquant/broker/bitget.py +311 -0
  4. hyperquant/broker/bitmart.py +720 -0
  5. hyperquant/broker/coinw.py +487 -0
  6. hyperquant/broker/deepcoin.py +651 -0
  7. hyperquant/broker/edgex.py +500 -0
  8. hyperquant/broker/hyperliquid.py +570 -0
  9. hyperquant/broker/lbank.py +661 -0
  10. hyperquant/broker/lib/edgex_sign.py +455 -0
  11. hyperquant/broker/lib/hpstore.py +252 -0
  12. hyperquant/broker/lib/hyper_types.py +48 -0
  13. hyperquant/broker/lib/polymarket/ctfAbi.py +721 -0
  14. hyperquant/broker/lib/polymarket/safeAbi.py +1138 -0
  15. hyperquant/broker/lib/util.py +22 -0
  16. hyperquant/broker/lighter.py +679 -0
  17. hyperquant/broker/models/apexpro.py +150 -0
  18. hyperquant/broker/models/bitget.py +359 -0
  19. hyperquant/broker/models/bitmart.py +635 -0
  20. hyperquant/broker/models/coinw.py +724 -0
  21. hyperquant/broker/models/deepcoin.py +809 -0
  22. hyperquant/broker/models/edgex.py +1053 -0
  23. hyperquant/broker/models/hyperliquid.py +284 -0
  24. hyperquant/broker/models/lbank.py +557 -0
  25. hyperquant/broker/models/lighter.py +868 -0
  26. hyperquant/broker/models/ourbit.py +1155 -0
  27. hyperquant/broker/models/polymarket.py +1071 -0
  28. hyperquant/broker/ourbit.py +550 -0
  29. hyperquant/broker/polymarket.py +2399 -0
  30. hyperquant/broker/ws.py +132 -0
  31. hyperquant/core.py +513 -0
  32. hyperquant/datavison/_util.py +18 -0
  33. hyperquant/datavison/binance.py +111 -0
  34. hyperquant/datavison/coinglass.py +237 -0
  35. hyperquant/datavison/okx.py +177 -0
  36. hyperquant/db.py +191 -0
  37. hyperquant/draw.py +1200 -0
  38. hyperquant/logkit.py +205 -0
  39. hyperquant/notikit.py +124 -0
  40. hyperquant-1.48.dist-info/METADATA +32 -0
  41. hyperquant-1.48.dist-info/RECORD +42 -0
  42. hyperquant-1.48.dist-info/WHEEL +4 -0
@@ -0,0 +1,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