poly-position-watcher 0.1.0__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.
- poly_position_watcher/__init__.py +16 -0
- poly_position_watcher/_version.py +3 -0
- poly_position_watcher/api_worker.py +232 -0
- poly_position_watcher/common/__init__.py +6 -0
- poly_position_watcher/common/enums.py +20 -0
- poly_position_watcher/common/logger.py +49 -0
- poly_position_watcher/position_service.py +338 -0
- poly_position_watcher/schema/__init__.py +21 -0
- poly_position_watcher/schema/base.py +27 -0
- poly_position_watcher/schema/common_model.py +150 -0
- poly_position_watcher/schema/position_model.py +152 -0
- poly_position_watcher/trade_calculator.py +148 -0
- poly_position_watcher/wss_worker.py +460 -0
- poly_position_watcher-0.1.0.dist-info/METADATA +162 -0
- poly_position_watcher-0.1.0.dist-info/RECORD +18 -0
- poly_position_watcher-0.1.0.dist-info/WHEEL +5 -0
- poly_position_watcher-0.1.0.dist-info/licenses/LICENSE +21 -0
- poly_position_watcher-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
# -*- coding = utf-8 -*-
|
|
2
|
+
# @Time: 2025/12/1 16:16
|
|
3
|
+
# @Author: pinbar
|
|
4
|
+
# @Site:
|
|
5
|
+
# @File: wss.py
|
|
6
|
+
# @Software: PyCharm
|
|
7
|
+
import json
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from typing import Callable, List, Optional
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
from websocket import WebSocketApp, WebSocket
|
|
15
|
+
|
|
16
|
+
from poly_position_watcher.common.enums import MarketEvent
|
|
17
|
+
from poly_position_watcher.common.logger import logger
|
|
18
|
+
from poly_position_watcher.schema.common_model import OrderBookSummary
|
|
19
|
+
|
|
20
|
+
WSS_URL = "wss://ws-subscriptions-clob.polymarket.com"
|
|
21
|
+
MARKET_CHANNEL = "market"
|
|
22
|
+
USER_CHANNEL = "user"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def json_dumps(msg: dict) -> str:
|
|
26
|
+
return json.dumps(msg, indent=4, ensure_ascii=False)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fetch_order_books(asset_ids: list[str]) -> list[OrderBookSummary]:
|
|
30
|
+
"""
|
|
31
|
+
Fetch an initial snapshot for every asset id before subscribing to the WS stream.
|
|
32
|
+
"""
|
|
33
|
+
if not asset_ids:
|
|
34
|
+
return []
|
|
35
|
+
url = "https://clob.polymarket.com/books"
|
|
36
|
+
payload = [{"token_id": token_id} for token_id in asset_ids]
|
|
37
|
+
response = requests.post(
|
|
38
|
+
url, json=payload, headers={"Content-Type": "application/json"}
|
|
39
|
+
)
|
|
40
|
+
response.raise_for_status()
|
|
41
|
+
books = response.json()
|
|
42
|
+
return [OrderBookSummary(**book) for book in books]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PolymarketUserWS:
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
api_key: str,
|
|
49
|
+
api_secret: str,
|
|
50
|
+
api_passphrase: str,
|
|
51
|
+
markets: Optional[List[str]] = None,
|
|
52
|
+
ping_interval: int = 10,
|
|
53
|
+
ping_timeout: int = 6,
|
|
54
|
+
idle_timeout: Optional[int] = 60 * 60,
|
|
55
|
+
reconnect_delay: int = 5,
|
|
56
|
+
on_message_callback: Optional[Callable[[dict], None]] = None,
|
|
57
|
+
wss_proxies: Optional[dict] = None,
|
|
58
|
+
):
|
|
59
|
+
"""
|
|
60
|
+
:param markets: 订阅的 condition_ids 列表,为 None 或 [] 时表示订阅全部(视后端协议而定)
|
|
61
|
+
:param ping_interval: WebSocket 库自带 PING 间隔(秒)
|
|
62
|
+
:param ping_timeout: WebSocket 库自带 PONG 超时时间(秒),超过这个时间视为连接异常
|
|
63
|
+
:param idle_timeout: 业务层“长时间没有任意消息”的超时(秒);为 None / 0 时关闭此功能
|
|
64
|
+
:param reconnect_delay: 断线后重连间隔(秒)
|
|
65
|
+
:param on_message_callback: 收到业务消息时的回调函数,参数为 dict
|
|
66
|
+
"""
|
|
67
|
+
self.api_key = api_key
|
|
68
|
+
self.api_secret = api_secret
|
|
69
|
+
self.api_passphrase = api_passphrase
|
|
70
|
+
self.markets = markets or []
|
|
71
|
+
|
|
72
|
+
self.ping_interval = ping_interval
|
|
73
|
+
self.ping_timeout = ping_timeout
|
|
74
|
+
self.idle_timeout = idle_timeout
|
|
75
|
+
self.reconnect_delay = reconnect_delay
|
|
76
|
+
|
|
77
|
+
self.on_message_callback = on_message_callback
|
|
78
|
+
self._wss_proxies = wss_proxies or {}
|
|
79
|
+
|
|
80
|
+
self.ws: Optional[WebSocketApp] = None
|
|
81
|
+
self._stop = False
|
|
82
|
+
|
|
83
|
+
# 业务消息 / 任意消息的最近活跃时间
|
|
84
|
+
self._last_activity = time.time()
|
|
85
|
+
|
|
86
|
+
# 监控“长时间无消息”的线程
|
|
87
|
+
self._monitor_thread: Optional[threading.Thread] = None
|
|
88
|
+
self._monitor_stop_evt = threading.Event()
|
|
89
|
+
|
|
90
|
+
# ---------- 公共方法 ----------
|
|
91
|
+
|
|
92
|
+
def start(self):
|
|
93
|
+
"""
|
|
94
|
+
阻塞运行,带自动重连。
|
|
95
|
+
使用 websocket-client 自带 ping/pong(ping_interval & ping_timeout)。
|
|
96
|
+
"""
|
|
97
|
+
while not self._stop:
|
|
98
|
+
try:
|
|
99
|
+
logger.info("[WS] Connecting...")
|
|
100
|
+
|
|
101
|
+
self.ws = WebSocketApp(
|
|
102
|
+
f"{WSS_URL}/ws/user",
|
|
103
|
+
on_open=self._on_open,
|
|
104
|
+
on_message=self._on_message,
|
|
105
|
+
on_error=self._on_error,
|
|
106
|
+
on_close=self._on_close,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# 每次新连接重置最近活跃时间
|
|
110
|
+
self._last_activity = time.time()
|
|
111
|
+
self._monitor_stop_evt.clear()
|
|
112
|
+
|
|
113
|
+
# 如果配置了 idle_timeout,则启动一个监控线程
|
|
114
|
+
if self.idle_timeout and self.idle_timeout > 0:
|
|
115
|
+
self._monitor_thread = threading.Thread(
|
|
116
|
+
target=self._activity_monitor_loop,
|
|
117
|
+
daemon=True,
|
|
118
|
+
)
|
|
119
|
+
self._monitor_thread.start()
|
|
120
|
+
else:
|
|
121
|
+
self._monitor_thread = None
|
|
122
|
+
|
|
123
|
+
# 这里使用库自带的 ping_interval / ping_timeout,负责底层心跳与超时
|
|
124
|
+
self.ws.run_forever(
|
|
125
|
+
**self._wss_proxies,
|
|
126
|
+
ping_interval=self.ping_interval,
|
|
127
|
+
ping_timeout=self.ping_timeout,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
except Exception as e:
|
|
131
|
+
logger.exception("[WS] run_forever error:")
|
|
132
|
+
|
|
133
|
+
# 当前连接生命周期结束,停止监控线程
|
|
134
|
+
self._monitor_stop_evt.set()
|
|
135
|
+
if self._monitor_thread and self._monitor_thread.is_alive():
|
|
136
|
+
self._monitor_thread.join(timeout=1)
|
|
137
|
+
|
|
138
|
+
if self._stop:
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
logger.info(
|
|
142
|
+
f"[WS] Disconnected, reconnecting in {self.reconnect_delay}s..."
|
|
143
|
+
)
|
|
144
|
+
time.sleep(self.reconnect_delay)
|
|
145
|
+
|
|
146
|
+
def stop(self):
|
|
147
|
+
"""
|
|
148
|
+
手动停止
|
|
149
|
+
"""
|
|
150
|
+
self._stop = True
|
|
151
|
+
self._monitor_stop_evt.set()
|
|
152
|
+
if self.ws:
|
|
153
|
+
try:
|
|
154
|
+
self.ws.close()
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
def _on_open(self, ws: WebSocket):
|
|
159
|
+
logger.info("[WS] Opened")
|
|
160
|
+
|
|
161
|
+
auth = {
|
|
162
|
+
"apiKey": self.api_key,
|
|
163
|
+
"secret": self.api_secret,
|
|
164
|
+
"passphrase": self.api_passphrase,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
sub_msg = {
|
|
168
|
+
"type": "USER", # 文档要求 - 订阅 user channel
|
|
169
|
+
"auth": auth,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if self.markets:
|
|
173
|
+
sub_msg["markets"] = self.markets
|
|
174
|
+
else:
|
|
175
|
+
sub_msg["markets"] = []
|
|
176
|
+
|
|
177
|
+
ws.send(json.dumps(sub_msg))
|
|
178
|
+
logger.info("[WS] Sent subscribe: \n{}", json_dumps(sub_msg))
|
|
179
|
+
|
|
180
|
+
def _on_message(self, ws: WebSocket, message: str):
|
|
181
|
+
# 收到任何消息都视为有“活跃”
|
|
182
|
+
self._last_activity = time.time()
|
|
183
|
+
|
|
184
|
+
# 对方如果返回的是纯文本心跳,可在这里过滤
|
|
185
|
+
# 但 websocket-client 的 ping/pong 是底层帧,不会走到这里
|
|
186
|
+
if str(message) == "PONG":
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
data = json.loads(message)
|
|
191
|
+
except json.JSONDecodeError:
|
|
192
|
+
# 对于非 JSON 消息,按需处理或直接打印
|
|
193
|
+
logger.warning("[WS] Non-JSON message: {}", message)
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
if self.on_message_callback:
|
|
197
|
+
try:
|
|
198
|
+
self.on_message_callback(data)
|
|
199
|
+
except Exception as e:
|
|
200
|
+
logger.exception("[WS] on_message_callback error:")
|
|
201
|
+
|
|
202
|
+
def _on_error(self, ws: WebSocket, error):
|
|
203
|
+
logger.error("[WS] Error: {}", error)
|
|
204
|
+
# 出现错误时关闭连接,交给外层循环重连
|
|
205
|
+
try:
|
|
206
|
+
ws.close()
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
def _on_close(self, ws: WebSocket, close_status_code, close_msg):
|
|
211
|
+
logger.info(f"[WS] Closed: code={close_status_code}, msg={close_msg}")
|
|
212
|
+
|
|
213
|
+
# ---------- 业务层“长时间无消息”监控 ----------
|
|
214
|
+
|
|
215
|
+
def _activity_monitor_loop(self):
|
|
216
|
+
"""
|
|
217
|
+
仅在配置了 idle_timeout 时启用:
|
|
218
|
+
如果长时间(idle_timeout 秒)没有收到任何消息,则主动关闭连接,
|
|
219
|
+
交由外层循环进行重连。
|
|
220
|
+
"""
|
|
221
|
+
while not self._monitor_stop_evt.is_set():
|
|
222
|
+
now = time.time()
|
|
223
|
+
if now - self._last_activity > self.idle_timeout:
|
|
224
|
+
logger.info(
|
|
225
|
+
f"[WS] No activity for {self.idle_timeout}s, "
|
|
226
|
+
f"closing connection to force reconnect..."
|
|
227
|
+
)
|
|
228
|
+
try:
|
|
229
|
+
if self.ws:
|
|
230
|
+
self.ws.close()
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
# 触发一次关闭即可,退出监控线程
|
|
234
|
+
break
|
|
235
|
+
|
|
236
|
+
# 等待 1 秒再检查,可按需要调整粒度
|
|
237
|
+
self._monitor_stop_evt.wait(1)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class OrderBookWS:
|
|
241
|
+
def __init__(
|
|
242
|
+
self,
|
|
243
|
+
asset_ids: list[str],
|
|
244
|
+
url: str = "wss://ws-subscriptions-clob.polymarket.com",
|
|
245
|
+
event_name: str = "",
|
|
246
|
+
ping_interval: int = 10,
|
|
247
|
+
ping_timeout: int = 6, # 如需用 websocket 内建 ping,可通过 run_forever 传入
|
|
248
|
+
idle_timeout: Optional[int] = 60 * 60,
|
|
249
|
+
reconnect_delay: int = 5,
|
|
250
|
+
callback: Callable = None,
|
|
251
|
+
wss_proxies: Optional[dict] = None,
|
|
252
|
+
):
|
|
253
|
+
"""
|
|
254
|
+
:param asset_ids: 订阅的 asset_id 列表
|
|
255
|
+
:param url: 基础 ws url
|
|
256
|
+
:param event_name: 日志里的事件名,仅用于调试
|
|
257
|
+
:param ping_interval: 自定义业务层 "PING" 间隔(秒)
|
|
258
|
+
:param ping_timeout: 预留,如果想用 websocket 内建 ping/pong,可透传给 run_forever
|
|
259
|
+
:param idle_timeout: 若长时间未收到任何消息(秒),则主动断开重连;为 None/0 表示关闭
|
|
260
|
+
:param reconnect_delay: 断线后重连间隔(秒)
|
|
261
|
+
"""
|
|
262
|
+
self.url = url
|
|
263
|
+
self.asset_ids = asset_ids
|
|
264
|
+
self.event_name = event_name
|
|
265
|
+
|
|
266
|
+
self.ping_interval = ping_interval
|
|
267
|
+
self.ping_timeout = ping_timeout
|
|
268
|
+
self.idle_timeout = idle_timeout
|
|
269
|
+
self.reconnect_delay = reconnect_delay
|
|
270
|
+
|
|
271
|
+
self.ws: Optional[WebSocketApp] = None
|
|
272
|
+
self._stop = False
|
|
273
|
+
|
|
274
|
+
# 活跃时间(收到任意消息时更新)
|
|
275
|
+
self._last_activity = time.time()
|
|
276
|
+
|
|
277
|
+
# “长时间无消息”监控线程
|
|
278
|
+
self._monitor_thread: Optional[threading.Thread] = None
|
|
279
|
+
self._monitor_stop_evt = threading.Event()
|
|
280
|
+
|
|
281
|
+
self.order_books: dict[str, OrderBookSummary] = {}
|
|
282
|
+
|
|
283
|
+
self._furl = url.rstrip("/") + "/ws/" + MARKET_CHANNEL
|
|
284
|
+
self.callback = callback
|
|
285
|
+
self._wss_proxies = wss_proxies or {}
|
|
286
|
+
|
|
287
|
+
self.initialize()
|
|
288
|
+
|
|
289
|
+
# ---------- 初始化 order book 快照 ----------
|
|
290
|
+
|
|
291
|
+
def initialize(self):
|
|
292
|
+
books = fetch_order_books(self.asset_ids)
|
|
293
|
+
for book in books:
|
|
294
|
+
self.order_books[book.asset_id] = book
|
|
295
|
+
|
|
296
|
+
# ---------- 公共方法 ----------
|
|
297
|
+
|
|
298
|
+
def start(self):
|
|
299
|
+
"""
|
|
300
|
+
阻塞运行,带自动重连逻辑。
|
|
301
|
+
"""
|
|
302
|
+
while not self._stop:
|
|
303
|
+
try:
|
|
304
|
+
logger.info("[OrderBookWS] Connecting to {} ...", self._furl)
|
|
305
|
+
|
|
306
|
+
self.ws = WebSocketApp(
|
|
307
|
+
self._furl,
|
|
308
|
+
on_message=self._on_message,
|
|
309
|
+
on_error=self._on_error,
|
|
310
|
+
on_close=self._on_close,
|
|
311
|
+
on_open=self._on_open,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# 重置状态
|
|
315
|
+
self._last_activity = time.time()
|
|
316
|
+
self._monitor_stop_evt.clear()
|
|
317
|
+
|
|
318
|
+
# 启动“长时间无消息”监控线程
|
|
319
|
+
if self.idle_timeout and self.idle_timeout > 0:
|
|
320
|
+
self._monitor_thread = threading.Thread(
|
|
321
|
+
target=self._activity_monitor_loop,
|
|
322
|
+
daemon=True,
|
|
323
|
+
)
|
|
324
|
+
self._monitor_thread.start()
|
|
325
|
+
else:
|
|
326
|
+
self._monitor_thread = None
|
|
327
|
+
|
|
328
|
+
self.ws.run_forever(
|
|
329
|
+
ping_interval=self.ping_interval,
|
|
330
|
+
ping_timeout=self.ping_timeout,
|
|
331
|
+
**self._wss_proxies,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
except Exception as e:
|
|
335
|
+
logger.exception(
|
|
336
|
+
"[OrderBookWS] run_forever error",
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# 连接生命周期结束,停止监控线程和 ping 线程
|
|
340
|
+
self._monitor_stop_evt.set()
|
|
341
|
+
|
|
342
|
+
if self._monitor_thread and self._monitor_thread.is_alive():
|
|
343
|
+
self._monitor_thread.join(timeout=1)
|
|
344
|
+
|
|
345
|
+
if self._stop:
|
|
346
|
+
break
|
|
347
|
+
|
|
348
|
+
logger.info(
|
|
349
|
+
f"[OrderBookWS] Disconnected, reconnecting in {self.reconnect_delay}",
|
|
350
|
+
)
|
|
351
|
+
time.sleep(self.reconnect_delay)
|
|
352
|
+
|
|
353
|
+
def stop(self):
|
|
354
|
+
"""
|
|
355
|
+
手动停止(优雅退出)
|
|
356
|
+
"""
|
|
357
|
+
logger.info("[OrderBookWS] Stopping...")
|
|
358
|
+
self._stop = True
|
|
359
|
+
self._monitor_stop_evt.set()
|
|
360
|
+
|
|
361
|
+
if self.ws:
|
|
362
|
+
try:
|
|
363
|
+
self.ws.close()
|
|
364
|
+
except Exception:
|
|
365
|
+
logger.exception("[OrderBookWS] error when closing ws")
|
|
366
|
+
|
|
367
|
+
# ---------- WebSocket 回调 ----------
|
|
368
|
+
|
|
369
|
+
def _on_open(self, ws: WebSocket):
|
|
370
|
+
logger.info("[OrderBookWS] Opened")
|
|
371
|
+
|
|
372
|
+
sub_msg = {"assets_ids": self.asset_ids, "type": MARKET_CHANNEL}
|
|
373
|
+
|
|
374
|
+
ws.send(json.dumps(sub_msg))
|
|
375
|
+
logger.info("[OrderBookWS] Sent subscribe: {}", json_dumps(sub_msg))
|
|
376
|
+
|
|
377
|
+
def _on_callback(self):
|
|
378
|
+
if self.callback is not None:
|
|
379
|
+
try:
|
|
380
|
+
self.callback(self.order_books)
|
|
381
|
+
except Exception:
|
|
382
|
+
logger.exception(f"wss callback error")
|
|
383
|
+
|
|
384
|
+
def _on_message(self, ws: WebSocket, message: str):
|
|
385
|
+
# 收到任何消息都算活跃
|
|
386
|
+
self._last_activity = time.time()
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
messages = json.loads(message)
|
|
390
|
+
if not isinstance(messages, list):
|
|
391
|
+
messages = [messages]
|
|
392
|
+
except json.decoder.JSONDecodeError:
|
|
393
|
+
# 非 JSON 消息直接忽略,或者按需处理
|
|
394
|
+
logger.debug(f"[OrderBookWS] Non-JSON message: {message}")
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
for message in messages:
|
|
399
|
+
event = message["event_type"]
|
|
400
|
+
timestamp = int(message["timestamp"]) / 1000
|
|
401
|
+
|
|
402
|
+
if event == MarketEvent.PRICE_CHANGE:
|
|
403
|
+
for price in message["price_changes"]:
|
|
404
|
+
if price["asset_id"] in self.asset_ids:
|
|
405
|
+
self.order_books[price["asset_id"]].set_price(
|
|
406
|
+
price, timestamp
|
|
407
|
+
)
|
|
408
|
+
elif event == MarketEvent.TICK_SIZE_CHANGE:
|
|
409
|
+
self.order_books[message["asset_id"]].tick_size = message[
|
|
410
|
+
"new_tick_size"
|
|
411
|
+
]
|
|
412
|
+
elif event == MarketEvent.BOOK:
|
|
413
|
+
order = self.order_books[message["asset_id"]]
|
|
414
|
+
self.order_books[message["asset_id"]] = order.model_validate(
|
|
415
|
+
{**order.model_dump(), **message}
|
|
416
|
+
)
|
|
417
|
+
self._on_callback()
|
|
418
|
+
except Exception:
|
|
419
|
+
logger.exception(f"[OrderBookWS] receive message error: {message}")
|
|
420
|
+
# 不再 exit(1),而是交给外层重连
|
|
421
|
+
|
|
422
|
+
def _on_error(self, ws: WebSocket, error):
|
|
423
|
+
logger.error(f"[OrderBookWS] Error: {error}")
|
|
424
|
+
# 出现错误时关闭连接,交给外层循环重连
|
|
425
|
+
try:
|
|
426
|
+
ws.close()
|
|
427
|
+
except Exception:
|
|
428
|
+
logger.exception("[OrderBookWS] error when closing on error")
|
|
429
|
+
|
|
430
|
+
def _on_close(self, ws: WebSocket, close_status_code, close_msg):
|
|
431
|
+
logger.info(f"[OrderBookWS] Closed: code={close_status_code}, msg={close_msg}")
|
|
432
|
+
# 不再 exit(0),让 run_forever 返回,外层 while 负责重连
|
|
433
|
+
|
|
434
|
+
# ---------- “长时间无消息”监控 ----------
|
|
435
|
+
|
|
436
|
+
def _activity_monitor_loop(self):
|
|
437
|
+
"""
|
|
438
|
+
如果长时间(idle_timeout 秒)没有收到任何消息,则主动关闭连接,
|
|
439
|
+
交由外层循环进行重连。
|
|
440
|
+
"""
|
|
441
|
+
while not self._monitor_stop_evt.is_set() and not self._stop:
|
|
442
|
+
now = time.time()
|
|
443
|
+
if self.idle_timeout and now - self._last_activity > self.idle_timeout:
|
|
444
|
+
logger.info(
|
|
445
|
+
f"[OrderBookWS] No activity for %ss, closing connection to force reconnect..., {self.idle_timeout}"
|
|
446
|
+
)
|
|
447
|
+
try:
|
|
448
|
+
if self.ws:
|
|
449
|
+
self.ws.close()
|
|
450
|
+
except Exception:
|
|
451
|
+
logger.exception("[OrderBookWS] error when closing in idle monitor")
|
|
452
|
+
break
|
|
453
|
+
|
|
454
|
+
# 每秒检查一次
|
|
455
|
+
self._monitor_stop_evt.wait(1)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def handle_user_message(msg: dict):
|
|
459
|
+
# 这里可以根据 type=TRADE / PLACEMENT / UPDATE 等自己做处理
|
|
460
|
+
logger.info("[USER EVENT], type: {}, msg: {}", msg.get("type"), json_dumps(msg))
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: poly-position-watcher
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: clear about domain + functionality.
|
|
5
|
+
Home-page: https://github.com/tosmart01/polymarket-position-watcher
|
|
6
|
+
Author: pinbar
|
|
7
|
+
Project-URL: Homepage, https://github.com/tosmart01/polymarket-position-watcher
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: py-clob-client>=0.25.0
|
|
12
|
+
Requires-Dist: websocket-client>=1.8.0
|
|
13
|
+
Dynamic: author
|
|
14
|
+
Dynamic: home-page
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
Dynamic: requires-python
|
|
17
|
+
|
|
18
|
+
# poly-position-watcher
|
|
19
|
+
|
|
20
|
+
`poly-position-watcher` 将我们在内部使用的仓位监控逻辑独立成了一个可复用的 Python 包,方便接入者基于 Polymarket 官方 `py-clob-client` SDK 快速搭建自己的仓位看板或做市机器人。该包负责:
|
|
21
|
+
|
|
22
|
+
- 通过 WebSocket 追踪实时 `TRADE` 与 `ORDER` 事件
|
|
23
|
+
- 把 HTTP API 的历史数据和 WebSocket 增量数据统一成同一套 Pydantic 模型
|
|
24
|
+
- 在内存中维护每个 `token_id` 的仓位、订单状态及阻塞式读取接口
|
|
25
|
+
- 提供易于扩展的 HTTP 轮询上下文(在 WebSocket 之外兜底同步)
|
|
26
|
+
- 内置 FIFO 仓位计算器,支持带市价估值与盈亏指标
|
|
27
|
+
|
|
28
|
+
## 安装
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install poly-position-watcher
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
如果你是从源码安装,先克隆本仓库然后执行 `pip install -e .`。
|
|
35
|
+
|
|
36
|
+
## 快速开始
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from py_clob_client.client import ClobClient
|
|
40
|
+
from poly_position_watcher import PositionWatcherService
|
|
41
|
+
|
|
42
|
+
client = ClobClient(
|
|
43
|
+
base_url="https://clob.polymarket.com",
|
|
44
|
+
key="<wallet-key>",
|
|
45
|
+
secret="<wallet-secret>",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
with PositionWatcherService(client=client) as service:
|
|
49
|
+
# 可选:HTTP 轮询兜底历史仓位
|
|
50
|
+
with service.http_listen(markets=["<condition_id>"], bootstrap_http=True):
|
|
51
|
+
position = service.blocking_get_position("<token_id>", timeout=5)
|
|
52
|
+
order = service.get_order("<order_id>")
|
|
53
|
+
print(position)
|
|
54
|
+
print(order)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 进阶示例(`examples/http_bootstrap_example.py`)
|
|
58
|
+
|
|
59
|
+
下面是一段更完整的示例脚本(对应 `examples/http_bootstrap_example.py`),演示如何在启动时通过 `http_listen` 获取历史订单 & 仓位,并实时追踪:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from py_clob_client.client import ClobClient
|
|
63
|
+
from poly_position_watcher import PositionWatcherService
|
|
64
|
+
|
|
65
|
+
client = ClobClient(...)
|
|
66
|
+
TARGET_MARKETS = ["0x3b7e9926575eb7fae204d27ee9d3c9db0f34d357e4b8c..."]
|
|
67
|
+
TARGET_ORDERS = ["0x74a71abb9efe59c994e0987fa81963aae23d7165f036afb..."]
|
|
68
|
+
token_id = ""
|
|
69
|
+
|
|
70
|
+
with PositionWatcherService(client=client) as service:
|
|
71
|
+
# 如果已经存在历史仓位,需要提前告诉 http_listen 所有关心的 markets / orders 并开启 bootstrap_http
|
|
72
|
+
with service.http_listen(markets=TARGET_MARKETS, orders=TARGET_ORDERS, bootstrap_http=True):
|
|
73
|
+
order = service.blocking_get_order(TARGET_ORDERS[0], timeout=5)
|
|
74
|
+
position = service.blocking_get_position(
|
|
75
|
+
token_id=token_id,
|
|
76
|
+
timeout=5,
|
|
77
|
+
)
|
|
78
|
+
print(order)
|
|
79
|
+
print(position)
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
示例输出:
|
|
84
|
+
|
|
85
|
+
```shell
|
|
86
|
+
OrderMessage(
|
|
87
|
+
type: 'update',
|
|
88
|
+
event_type: 'order',
|
|
89
|
+
asset_id: '7718951783559279583290056782453440...',
|
|
90
|
+
associate_trades: ['8bf02a75-5...'],
|
|
91
|
+
id: '0x74a71abb9efe59c994e0...',
|
|
92
|
+
market: '0x3b7e9926575eb7fae2...',
|
|
93
|
+
order_owner: None,
|
|
94
|
+
original_size: 37.5,
|
|
95
|
+
outcome: 'Up',
|
|
96
|
+
owner: '',
|
|
97
|
+
price: 0.52,
|
|
98
|
+
side: 'BUY',
|
|
99
|
+
size_matched: 37.5,
|
|
100
|
+
timestamp: 0.0,
|
|
101
|
+
filled: True,
|
|
102
|
+
status: 'MATCHED',
|
|
103
|
+
created_at: datetime.datetime(2025, 12, 8, 9, 44, 50, tzinfo=TzInfo(0))
|
|
104
|
+
)
|
|
105
|
+
UserPosition(
|
|
106
|
+
price: 0.0,
|
|
107
|
+
size: 0.0,
|
|
108
|
+
volume: 0.0,
|
|
109
|
+
token_id: '',
|
|
110
|
+
last_update: 0.0,
|
|
111
|
+
market_id: None,
|
|
112
|
+
outcome: None,
|
|
113
|
+
created_at: None
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
> ⚠️ 注意:如果你是先启动监控再产生仓位,可令 `bootstrap_http=False` 且 `markets/orders` 参数为空列表即可;只有当已经存在历史仓位/订单需要补偿时才需要提前传入,并开启 `bootstrap_http=True`。
|
|
118
|
+
|
|
119
|
+
### 只使用 HTTP 轮询
|
|
120
|
+
|
|
121
|
+
`HttpListenerContext` 可在需要时单独使用:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
with service.http_listen(markets=["<condition_id>"], http_poll_interval=2.5) as ctx:
|
|
125
|
+
ctx.add(markets=["other_condition_id"], orders=["<order_id>"])
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## 可选配置
|
|
129
|
+
|
|
130
|
+
| 环境变量 | 说明 |
|
|
131
|
+
| --- | --- |
|
|
132
|
+
| `poly_position_watcher_LOG_LEVEL` | 调整日志级别,默认为 `INFO` |
|
|
133
|
+
|
|
134
|
+
若需要为 WebSocket 连接设置代理,可在实例化 `PositionWatcherService` 及 `http_listen` 前自行构造一个字典并通过 `wss_proxies` 传入,例如:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
PROXY = {"http_proxy_host": "127.0.0.1", "http_proxy_port": 7890}
|
|
138
|
+
service = PositionWatcherService(client, wss_proxies=PROXY)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## 依赖
|
|
142
|
+
|
|
143
|
+
- [`py-clob-client`](https://github.com/Polymarket/py-clob-client)
|
|
144
|
+
- [`pydantic`](https://docs.pydantic.dev/)
|
|
145
|
+
- [`websocket-client`](https://github.com/websocket-client/websocket-client)
|
|
146
|
+
- [`requests`](https://requests.readthedocs.io/en/latest/)
|
|
147
|
+
|
|
148
|
+
## 目录结构
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
poly_position_watcher/
|
|
152
|
+
├── api_worker.py # HTTP 补数与上下文管理
|
|
153
|
+
├── position_service.py # 核心入口,维护仓位/订单缓存
|
|
154
|
+
├── trade_calculator.py # 仓位计算工具
|
|
155
|
+
├── wss_worker.py # WebSocket 客户端实现
|
|
156
|
+
├── common/ # 日志与枚举
|
|
157
|
+
└── schema/ # Pydantic 数据模型
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## 许可证
|
|
161
|
+
|
|
162
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
poly_position_watcher/__init__.py,sha256=VR3T8B08l2YJbRIeysWzN0xPCTdh0k1m2ZPQzphhnRU,546
|
|
2
|
+
poly_position_watcher/_version.py,sha256=_tAoPRxHlRWnnqYQFVPJqTq0794zoiaHgxaXAAREvUI,52
|
|
3
|
+
poly_position_watcher/api_worker.py,sha256=05q5siawpyrNnCCqO59oskOqzbkg4cVEC_tQia2Gwcg,7939
|
|
4
|
+
poly_position_watcher/position_service.py,sha256=GvrNtQoASZm_lWUVPBZtlf86l8UT4kyjYwNC4skHrwo,12656
|
|
5
|
+
poly_position_watcher/trade_calculator.py,sha256=8AVJ16NEU7dPHvB37fa3RlbUGWA27xFSfGbgxKWFiQI,4612
|
|
6
|
+
poly_position_watcher/wss_worker.py,sha256=WLHyjxCs5AdXcuTZOus8e-WpLg5unY0raVED8HecQyY,16700
|
|
7
|
+
poly_position_watcher/common/__init__.py,sha256=8tLsESE6V-OHYvu1fHozSeLll1vO2vZ1nSMjoHGBiys,183
|
|
8
|
+
poly_position_watcher/common/enums.py,sha256=L7L5ehkLSOlbplfBP0uRRESF5wPqtx7tsp86nSz_4rY,430
|
|
9
|
+
poly_position_watcher/common/logger.py,sha256=JU8d5MhOrRUFaMtD6mDjQ2t4TaIBT6msPL09wVPl_uw,1716
|
|
10
|
+
poly_position_watcher/schema/__init__.py,sha256=r4fZmcqPLw7FL6kIYiUr0hdiAs5lWdbptZIpVx9i5uU,430
|
|
11
|
+
poly_position_watcher/schema/base.py,sha256=ZMY4YpKC6g9Zg6QJlZ0PCkVLd0KFfjjTElofqCsUacA,780
|
|
12
|
+
poly_position_watcher/schema/common_model.py,sha256=tCcZN-oZPZETuX6s1WBkiIkBXIhrPb_2rJituDAMHrw,4732
|
|
13
|
+
poly_position_watcher/schema/position_model.py,sha256=fB84lneZuO1KOHpyLXyDLAKINSe2UazVJUIOZFzNq2A,4075
|
|
14
|
+
poly_position_watcher-0.1.0.dist-info/licenses/LICENSE,sha256=s7XOR4h-6YPXj4pJvUjZ4OXOy13IlQuQGPcIP4tWDus,1085
|
|
15
|
+
poly_position_watcher-0.1.0.dist-info/METADATA,sha256=_xGsBYbowdlGwTFeBooKVgxnsECqxSemMmhyKyCPLLo,5447
|
|
16
|
+
poly_position_watcher-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
17
|
+
poly_position_watcher-0.1.0.dist-info/top_level.txt,sha256=UahKe-xC4_pgBU0_USUmMIweRzHaS5PTA9P-1TDK9AY,22
|
|
18
|
+
poly_position_watcher-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Donvink
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
poly_position_watcher
|