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,16 @@
|
|
|
1
|
+
"""High level service for monitoring Polymarket positions in real time."""
|
|
2
|
+
|
|
3
|
+
from .position_service import PositionWatcherService
|
|
4
|
+
from .schema.position_model import UserPosition, TradeMessage, OrderMessage
|
|
5
|
+
from .trade_calculator import calculate_position_from_trades, calculate_position_with_price
|
|
6
|
+
from ._version import __version__
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"PositionWatcherService",
|
|
10
|
+
"calculate_position_from_trades",
|
|
11
|
+
"calculate_position_with_price",
|
|
12
|
+
"UserPosition",
|
|
13
|
+
"TradeMessage",
|
|
14
|
+
"OrderMessage",
|
|
15
|
+
"__version__",
|
|
16
|
+
]
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# -*- coding = utf-8 -*-
|
|
2
|
+
# @Time: 2025/12/1 16:17
|
|
3
|
+
# @Author: pinbar
|
|
4
|
+
# @Site:
|
|
5
|
+
# @File: api_worker.py
|
|
6
|
+
# @Software: PyCharm
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
from typing import List, Optional, TYPE_CHECKING
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
12
|
+
|
|
13
|
+
from py_clob_client.client import ClobClient
|
|
14
|
+
from py_clob_client.clob_types import TradeParams
|
|
15
|
+
|
|
16
|
+
from poly_position_watcher.schema.position_model import TradeMessage, OrderMessage
|
|
17
|
+
from poly_position_watcher.common.logger import logger
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from poly_position_watcher.position_service import PositionWatcherService
|
|
21
|
+
|
|
22
|
+
executor = ThreadPoolExecutor(max_workers=3)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class APIWorker:
|
|
26
|
+
"""
|
|
27
|
+
Pulls trade history through the CLOB HTTP API using the official SDK.
|
|
28
|
+
The worker normalizes responses into TradeMessage instances so
|
|
29
|
+
downstream consumers can share the same data model as the WebSocket stream.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, client: ClobClient, maker_address: str):
|
|
33
|
+
self.client = client
|
|
34
|
+
self.maker_address = maker_address
|
|
35
|
+
|
|
36
|
+
def fetch_order(self, order_id: str) -> OrderMessage | None:
|
|
37
|
+
if order := self.client.get_order(order_id):
|
|
38
|
+
return self._parse_order(order)
|
|
39
|
+
|
|
40
|
+
def fetch_trades(
|
|
41
|
+
self,
|
|
42
|
+
market: Optional[str] = None,
|
|
43
|
+
after: Optional[int] = None,
|
|
44
|
+
before: Optional[int] = None,
|
|
45
|
+
) -> List[TradeMessage]:
|
|
46
|
+
"""
|
|
47
|
+
Fetches historical trades for this maker.
|
|
48
|
+
|
|
49
|
+
:param market: Optional condition_id filter.
|
|
50
|
+
:param after: Only return trades updated after this timestamp (ms).
|
|
51
|
+
:param before: Only return trades updated before this timestamp (ms).
|
|
52
|
+
"""
|
|
53
|
+
params_kwargs = {"maker_address": self.maker_address}
|
|
54
|
+
if market:
|
|
55
|
+
params_kwargs["market"] = market
|
|
56
|
+
if after:
|
|
57
|
+
params_kwargs["after"] = after
|
|
58
|
+
if before:
|
|
59
|
+
params_kwargs["before"] = before
|
|
60
|
+
|
|
61
|
+
params = TradeParams(**params_kwargs)
|
|
62
|
+
raw_trades = self.client.get_trades(params)
|
|
63
|
+
|
|
64
|
+
trades: List[TradeMessage] = []
|
|
65
|
+
for raw in raw_trades:
|
|
66
|
+
trades.append(self._parse_trade(raw))
|
|
67
|
+
return trades
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _parse_trade(payload: dict) -> TradeMessage:
|
|
71
|
+
normalized = dict(payload)
|
|
72
|
+
normalized.setdefault("type", "TRADE")
|
|
73
|
+
normalized.setdefault("event_type", "trade")
|
|
74
|
+
return TradeMessage(**normalized)
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _parse_order(payload: dict) -> OrderMessage:
|
|
78
|
+
normalized = dict(payload)
|
|
79
|
+
normalized.setdefault("type", "update")
|
|
80
|
+
normalized.setdefault("event_type", "order")
|
|
81
|
+
normalized.setdefault("timestamp", 0)
|
|
82
|
+
normalized["owner"] = ""
|
|
83
|
+
return OrderMessage(**normalized)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class HttpListenerContext:
|
|
87
|
+
"""
|
|
88
|
+
Thread-safe context manager that:
|
|
89
|
+
- applies temporary HTTP listen lists
|
|
90
|
+
- starts HTTP trade/order polling threads on enter
|
|
91
|
+
- stops threads on exit
|
|
92
|
+
- restores previous listening state
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
service: "PositionWatcherService",
|
|
98
|
+
markets=None,
|
|
99
|
+
orders=None,
|
|
100
|
+
http_poll_interval: float = 3,
|
|
101
|
+
bootstrap_http: bool = False,
|
|
102
|
+
):
|
|
103
|
+
self.service = service
|
|
104
|
+
self._lock = threading.RLock()
|
|
105
|
+
self.markets: set[str] = set(markets) if markets else set()
|
|
106
|
+
self.orders: set[str] = set(orders) if orders else set()
|
|
107
|
+
self.http_poll_interval = http_poll_interval
|
|
108
|
+
self.bootstrap_http = bootstrap_http
|
|
109
|
+
self.api_worker = APIWorker(self.service.client, self.service.user_address)
|
|
110
|
+
|
|
111
|
+
# Local thread control
|
|
112
|
+
self._stop_event = threading.Event()
|
|
113
|
+
self._trade_thread = None
|
|
114
|
+
self._order_thread = None
|
|
115
|
+
|
|
116
|
+
# -------------------------
|
|
117
|
+
# Context enter
|
|
118
|
+
# -------------------------
|
|
119
|
+
def __enter__(self):
|
|
120
|
+
if self.bootstrap_http:
|
|
121
|
+
self.sync_trade_from_http(is_init=True)
|
|
122
|
+
self.sync_order_from_http()
|
|
123
|
+
|
|
124
|
+
# Start HTTP polling threads
|
|
125
|
+
self._start_threads()
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
# -------------------------
|
|
129
|
+
# add markets/orders safely
|
|
130
|
+
# -------------------------
|
|
131
|
+
def add(self, markets=None, orders=None):
|
|
132
|
+
with self._lock:
|
|
133
|
+
if markets:
|
|
134
|
+
self.markets.update(markets)
|
|
135
|
+
if orders:
|
|
136
|
+
self.orders.update(orders)
|
|
137
|
+
|
|
138
|
+
def reset(self, markets: list[str] = None, orders: list[str] = None):
|
|
139
|
+
with self._lock:
|
|
140
|
+
if markets:
|
|
141
|
+
self.markets = set(markets)
|
|
142
|
+
if orders:
|
|
143
|
+
self.orders = set(orders)
|
|
144
|
+
|
|
145
|
+
def clear(self):
|
|
146
|
+
with self._lock:
|
|
147
|
+
self.markets = set()
|
|
148
|
+
self.orders = set()
|
|
149
|
+
|
|
150
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
151
|
+
self._stop_threads()
|
|
152
|
+
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
# -------------------------
|
|
156
|
+
# internal: start threads
|
|
157
|
+
# -------------------------
|
|
158
|
+
def _start_threads(self):
|
|
159
|
+
self._stop_event.clear()
|
|
160
|
+
|
|
161
|
+
self._trade_thread = threading.Thread(
|
|
162
|
+
target=self._trade_loop,
|
|
163
|
+
daemon=True,
|
|
164
|
+
)
|
|
165
|
+
self._order_thread = threading.Thread(
|
|
166
|
+
target=self._order_loop,
|
|
167
|
+
daemon=True,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
self._trade_thread.start()
|
|
171
|
+
self._order_thread.start()
|
|
172
|
+
|
|
173
|
+
# -------------------------
|
|
174
|
+
# internal: stop threads
|
|
175
|
+
# -------------------------
|
|
176
|
+
def _stop_threads(self):
|
|
177
|
+
self._stop_event.set()
|
|
178
|
+
|
|
179
|
+
def _trade_loop(self):
|
|
180
|
+
while not self._stop_event.wait(self.http_poll_interval):
|
|
181
|
+
self.sync_trade_from_http()
|
|
182
|
+
logger.info(f"{self.markets}, trade loop is stopped")
|
|
183
|
+
|
|
184
|
+
def _order_loop(self):
|
|
185
|
+
while not self._stop_event.wait(self.http_poll_interval):
|
|
186
|
+
self.sync_order_from_http()
|
|
187
|
+
logger.info(f"{self.orders}, order loop is stopped")
|
|
188
|
+
|
|
189
|
+
# -------------------------------------------------------------------------
|
|
190
|
+
# HTTP sync (manual)
|
|
191
|
+
# -------------------------------------------------------------------------
|
|
192
|
+
def sync_trade_from_http(self, is_init: bool = False):
|
|
193
|
+
with self._lock:
|
|
194
|
+
markets = list(self.markets)
|
|
195
|
+
tasks = []
|
|
196
|
+
for market in markets:
|
|
197
|
+
task = executor.submit(self.api_worker.fetch_trades, market)
|
|
198
|
+
task._market_id = market
|
|
199
|
+
tasks.append(task)
|
|
200
|
+
for task in as_completed(tasks):
|
|
201
|
+
try:
|
|
202
|
+
trades = task.result()
|
|
203
|
+
except Exception as e:
|
|
204
|
+
logger.error(f"Failed to http fetch trades market {task._market_id}: {e}")
|
|
205
|
+
continue
|
|
206
|
+
if is_init:
|
|
207
|
+
self.service._init_trades(sorted(trades, key=lambda x: x.match_time))
|
|
208
|
+
else:
|
|
209
|
+
for trade in sorted(trades, key=lambda x: x.match_time):
|
|
210
|
+
self.service._ingest_trade(trade)
|
|
211
|
+
|
|
212
|
+
def sync_order_from_http(self):
|
|
213
|
+
with self._lock:
|
|
214
|
+
order_ids = list(self.orders)
|
|
215
|
+
tasks = []
|
|
216
|
+
for order_id in order_ids:
|
|
217
|
+
task = executor.submit(self.api_worker.fetch_order, order_id)
|
|
218
|
+
task._order_id = order_id
|
|
219
|
+
tasks.append(task)
|
|
220
|
+
for task in as_completed(tasks):
|
|
221
|
+
try:
|
|
222
|
+
order = task.result()
|
|
223
|
+
except Exception as e:
|
|
224
|
+
logger.error(f"Failed to fetch order {task._order_id}: {e}")
|
|
225
|
+
continue
|
|
226
|
+
if order is None:
|
|
227
|
+
exists = self.service.position_store.orders.get(task._order_id)
|
|
228
|
+
if exists:
|
|
229
|
+
exists.status = "canceled"
|
|
230
|
+
self.service._ingest_order(exists)
|
|
231
|
+
else:
|
|
232
|
+
self.service._ingest_order(order)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Enum definitions used across the position watcher package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Side(str, Enum):
|
|
9
|
+
"""Order side in Polymarket's CLOB."""
|
|
10
|
+
|
|
11
|
+
BUY = "BUY"
|
|
12
|
+
SELL = "SELL"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MarketEvent(str, Enum):
|
|
16
|
+
"""Supported market level websocket event names."""
|
|
17
|
+
|
|
18
|
+
PRICE_CHANGE = "price_change"
|
|
19
|
+
TICK_SIZE_CHANGE = "tick_size_change"
|
|
20
|
+
BOOK = "book"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Lightweight logger used throughout the position watcher package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
_LOG_LEVEL = os.getenv("poly_position_watcher_LOG_LEVEL", "INFO").upper()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _BraceAdapter:
|
|
13
|
+
"""Simple adapter that supports ``logger.info("foo {}", bar)`` formatting."""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
logging.basicConfig(
|
|
17
|
+
level=getattr(logging, _LOG_LEVEL, logging.INFO),
|
|
18
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
19
|
+
)
|
|
20
|
+
self._logger = logging.getLogger("poly_position_watcher")
|
|
21
|
+
|
|
22
|
+
def _log(self, level: int, msg: str, *args: Any, **kwargs: Any) -> None:
|
|
23
|
+
if args or kwargs:
|
|
24
|
+
try:
|
|
25
|
+
msg = msg.format(*args, **kwargs)
|
|
26
|
+
except Exception: # pragma: no cover - fallback to raw message
|
|
27
|
+
msg = " ".join([msg, *map(str, args)])
|
|
28
|
+
self._logger.log(level, msg, **kwargs)
|
|
29
|
+
|
|
30
|
+
def debug(self, msg: str, *args: Any, **kwargs: Any) -> None:
|
|
31
|
+
self._log(logging.DEBUG, msg, *args, **kwargs)
|
|
32
|
+
|
|
33
|
+
def info(self, msg: str, *args: Any, **kwargs: Any) -> None:
|
|
34
|
+
self._log(logging.INFO, msg, *args, **kwargs)
|
|
35
|
+
|
|
36
|
+
def warning(self, msg: str, *args: Any, **kwargs: Any) -> None:
|
|
37
|
+
self._log(logging.WARNING, msg, *args, **kwargs)
|
|
38
|
+
|
|
39
|
+
def error(self, msg: str, *args: Any, **kwargs: Any) -> None:
|
|
40
|
+
self._log(logging.ERROR, msg, *args, **kwargs)
|
|
41
|
+
|
|
42
|
+
def exception(self, msg: str, *args: Any, **kwargs: Any) -> None:
|
|
43
|
+
kwargs.setdefault("exc_info", True)
|
|
44
|
+
self._log(logging.ERROR, msg, *args, **kwargs)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
logger = _BraceAdapter()
|
|
48
|
+
|
|
49
|
+
__all__ = ["logger"]
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
# -*- coding = utf-8 -*-
|
|
2
|
+
# @Time: 2025/12/3 15:35
|
|
3
|
+
# @Author: pinbar
|
|
4
|
+
# @Site:
|
|
5
|
+
# @File: position_service.py
|
|
6
|
+
# @Software: PyCharm
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from queue import Queue, Empty
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from typing import Dict
|
|
14
|
+
|
|
15
|
+
from poly_position_watcher.common.enums import Side
|
|
16
|
+
from poly_position_watcher.common.logger import logger
|
|
17
|
+
from poly_position_watcher.api_worker import HttpListenerContext
|
|
18
|
+
|
|
19
|
+
from poly_position_watcher.schema.position_model import (
|
|
20
|
+
UserPosition,
|
|
21
|
+
TradeMessage,
|
|
22
|
+
OrderMessage,
|
|
23
|
+
)
|
|
24
|
+
from poly_position_watcher.trade_calculator import calculate_position_from_trades
|
|
25
|
+
from poly_position_watcher.wss_worker import PolymarketUserWS
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PositionStore:
|
|
29
|
+
"""
|
|
30
|
+
Keeps an in-memory view of per-market trades and exposes aggregated positions.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, user_address: str):
|
|
34
|
+
self.user_address = user_address
|
|
35
|
+
self.trades_by_token: Dict[str, Dict[str, TradeMessage]] = defaultdict(dict)
|
|
36
|
+
self.positions: Dict[str, UserPosition] = {}
|
|
37
|
+
self.orders: Dict[str, OrderMessage] = {}
|
|
38
|
+
self._lock = threading.RLock()
|
|
39
|
+
self.queue_dict: Dict[str, Queue] = {}
|
|
40
|
+
|
|
41
|
+
def get_token_id_from_trade(self, trade: TradeMessage) -> tuple[str, str] | None:
|
|
42
|
+
if trade.maker_address == self.user_address:
|
|
43
|
+
return trade.outcome, trade.asset_id
|
|
44
|
+
for order in trade.maker_orders:
|
|
45
|
+
if order.maker_address == self.user_address:
|
|
46
|
+
return order.outcome, order.asset_id
|
|
47
|
+
|
|
48
|
+
def _put(self, _id: str, item: UserPosition | OrderMessage) -> None:
|
|
49
|
+
if _id not in self.queue_dict:
|
|
50
|
+
self.queue_dict[_id] = Queue()
|
|
51
|
+
self.queue_dict[_id].put(item)
|
|
52
|
+
|
|
53
|
+
def _clear_q(self, _id: str) -> None:
|
|
54
|
+
if _id in self.queue_dict:
|
|
55
|
+
self.queue_dict[_id] = Queue()
|
|
56
|
+
|
|
57
|
+
def _get(self, _id: str, timeout=None) -> OrderMessage | UserPosition | None:
|
|
58
|
+
with self._lock:
|
|
59
|
+
if _id not in self.queue_dict:
|
|
60
|
+
self.queue_dict[_id] = Queue()
|
|
61
|
+
return self.queue_dict[_id].get(timeout=timeout)
|
|
62
|
+
|
|
63
|
+
def append_trade(self, trade: TradeMessage):
|
|
64
|
+
"""
|
|
65
|
+
Stores a new trade snapshot.
|
|
66
|
+
If duplicate trade ids arrive, the payload with the latest update timestamp wins.
|
|
67
|
+
"""
|
|
68
|
+
with self._lock:
|
|
69
|
+
result = self.get_token_id_from_trade(trade)
|
|
70
|
+
if result is None:
|
|
71
|
+
logger.debug(
|
|
72
|
+
"Skip trade without matching maker/taker context: {}", trade.id
|
|
73
|
+
)
|
|
74
|
+
return
|
|
75
|
+
outcome, token_id = result
|
|
76
|
+
trades_map = self.trades_by_token[token_id]
|
|
77
|
+
existing = trades_map.get(trade.id)
|
|
78
|
+
if existing and trade.match_time <= existing.match_time:
|
|
79
|
+
return
|
|
80
|
+
else:
|
|
81
|
+
trades_map[trade.id] = trade
|
|
82
|
+
user_pos = self.build_position(
|
|
83
|
+
trades=list(trades_map.values()), token_id=token_id, outcome=outcome
|
|
84
|
+
)
|
|
85
|
+
self.positions[token_id] = user_pos
|
|
86
|
+
self._put(token_id, user_pos)
|
|
87
|
+
|
|
88
|
+
def init_trades(self, trades: list[TradeMessage]):
|
|
89
|
+
with self._lock:
|
|
90
|
+
if not trades:
|
|
91
|
+
return
|
|
92
|
+
result = self.get_token_id_from_trade(trades[0])
|
|
93
|
+
if result is None:
|
|
94
|
+
logger.debug(
|
|
95
|
+
"Skip trade without matching maker/taker context: {}", trades[0].id
|
|
96
|
+
)
|
|
97
|
+
return
|
|
98
|
+
outcome, token_id = result
|
|
99
|
+
trades_map = self.trades_by_token[token_id]
|
|
100
|
+
for trade in trades:
|
|
101
|
+
trades_map[trade.id] = trade
|
|
102
|
+
user_pos = self.build_position(
|
|
103
|
+
trades=list(trades_map.values()), token_id=token_id, outcome=outcome
|
|
104
|
+
)
|
|
105
|
+
self._clear_q(token_id)
|
|
106
|
+
self.positions[token_id] = user_pos
|
|
107
|
+
self._put(token_id, user_pos)
|
|
108
|
+
|
|
109
|
+
def append_order(self, order: OrderMessage | None):
|
|
110
|
+
"""
|
|
111
|
+
Stores a new order snapshot.
|
|
112
|
+
If duplicate trade ids arrive, the payload with the latest update timestamp wins.
|
|
113
|
+
"""
|
|
114
|
+
with self._lock:
|
|
115
|
+
existing = self.orders.get(order.id)
|
|
116
|
+
if (
|
|
117
|
+
existing
|
|
118
|
+
and order.size_matched <= existing.size_matched
|
|
119
|
+
and order.status == existing.status
|
|
120
|
+
):
|
|
121
|
+
return
|
|
122
|
+
if abs(order.size_matched - order.original_size) < 0.5:
|
|
123
|
+
order.filled = True
|
|
124
|
+
self.orders[order.id] = order
|
|
125
|
+
self._put(order.id, order)
|
|
126
|
+
|
|
127
|
+
def build_position(
|
|
128
|
+
self, trades: list[TradeMessage], token_id, outcome: str
|
|
129
|
+
) -> UserPosition | None:
|
|
130
|
+
position_result = calculate_position_from_trades(
|
|
131
|
+
trades, user_address=self.user_address
|
|
132
|
+
)
|
|
133
|
+
current = UserPosition(
|
|
134
|
+
price=position_result.avg_price,
|
|
135
|
+
size=position_result.size,
|
|
136
|
+
volume=position_result.amount,
|
|
137
|
+
token_id=token_id,
|
|
138
|
+
last_update=position_result.last_update,
|
|
139
|
+
market_id=trades[0].market,
|
|
140
|
+
outcome=outcome,
|
|
141
|
+
created_at=datetime.fromtimestamp(position_result.last_update),
|
|
142
|
+
)
|
|
143
|
+
return current
|
|
144
|
+
# if exists_pos := self.positions.get(token_id):
|
|
145
|
+
# if exists_pos.last_update < current.last_update:
|
|
146
|
+
# return current
|
|
147
|
+
# else:
|
|
148
|
+
# return current
|
|
149
|
+
|
|
150
|
+
def get_token_position(self, token_id: str) -> UserPosition:
|
|
151
|
+
return self.positions.get(token_id)
|
|
152
|
+
|
|
153
|
+
def get_token_order(self, token_id: str) -> list[OrderMessage]:
|
|
154
|
+
orders = []
|
|
155
|
+
for key, value in self.orders.items():
|
|
156
|
+
if value.asset_id == token_id:
|
|
157
|
+
orders.append(value)
|
|
158
|
+
return orders
|
|
159
|
+
|
|
160
|
+
def get_order_by_id(self, order_id: str) -> OrderMessage:
|
|
161
|
+
return self.orders.get(order_id)
|
|
162
|
+
|
|
163
|
+
def blocking_get_token_position(
|
|
164
|
+
self, token_id: str, timeout: float = None
|
|
165
|
+
) -> UserPosition:
|
|
166
|
+
return self._get(token_id, timeout)
|
|
167
|
+
|
|
168
|
+
def blocking_get_order_by_id(
|
|
169
|
+
self, order_id: str, timeout: float = None
|
|
170
|
+
) -> OrderMessage:
|
|
171
|
+
return self._get(order_id, timeout)
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _calculate_size(order, size: float, volume: float):
|
|
175
|
+
if order.side == Side.BUY:
|
|
176
|
+
size += order.size
|
|
177
|
+
volume += order.size * order.price
|
|
178
|
+
elif order.side == Side.SELL:
|
|
179
|
+
size -= order.size
|
|
180
|
+
volume -= order.size * order.price
|
|
181
|
+
return size, volume
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class PositionWatcherService:
|
|
185
|
+
"""
|
|
186
|
+
High level service:
|
|
187
|
+
- Bootstraps positions via HTTP
|
|
188
|
+
- Maintains updates via WebSocket
|
|
189
|
+
- Provides context-based HTTP listener for temporary polling threads
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
client,
|
|
195
|
+
ws_idle_timeout=60 * 60,
|
|
196
|
+
wss_proxies: dict | None = None,
|
|
197
|
+
):
|
|
198
|
+
"""
|
|
199
|
+
wss_proxies example: {
|
|
200
|
+
"http_proxy_host": "127.0.0.1",
|
|
201
|
+
"http_proxy_port": 8118,
|
|
202
|
+
"proxy_type": "http",
|
|
203
|
+
}
|
|
204
|
+
"""
|
|
205
|
+
self.client = client
|
|
206
|
+
self.user_address = self._resolve_user_address()
|
|
207
|
+
self.position_store = PositionStore(self.user_address)
|
|
208
|
+
self._wss_proxies = wss_proxies or {}
|
|
209
|
+
# Setup WS client
|
|
210
|
+
creds = self.client.creds or self.client.create_or_derive_api_creds()
|
|
211
|
+
self.ws_client = PolymarketUserWS(
|
|
212
|
+
api_key=creds.api_key,
|
|
213
|
+
api_secret=creds.api_secret,
|
|
214
|
+
api_passphrase=creds.api_passphrase,
|
|
215
|
+
idle_timeout=ws_idle_timeout,
|
|
216
|
+
on_message_callback=self._handle_ws_message,
|
|
217
|
+
wss_proxies=self._wss_proxies,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
self._ws_thread = None
|
|
221
|
+
|
|
222
|
+
# -------------------------------------------------------------------------
|
|
223
|
+
# Context: start/stop entire service
|
|
224
|
+
# -------------------------------------------------------------------------
|
|
225
|
+
def __enter__(self):
|
|
226
|
+
self.start()
|
|
227
|
+
return self
|
|
228
|
+
|
|
229
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
230
|
+
self.stop()
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
# -------------------------------------------------------------------------
|
|
234
|
+
# Public factory: returns the context manager
|
|
235
|
+
# -------------------------------------------------------------------------
|
|
236
|
+
def http_listen(
|
|
237
|
+
self,
|
|
238
|
+
markets=None,
|
|
239
|
+
orders=None,
|
|
240
|
+
http_poll_interval: float = 3,
|
|
241
|
+
bootstrap_http: bool = False,
|
|
242
|
+
):
|
|
243
|
+
"""
|
|
244
|
+
如果在启动前已经有历史仓位需要: bootstrap_http=True
|
|
245
|
+
"""
|
|
246
|
+
return HttpListenerContext(
|
|
247
|
+
self,
|
|
248
|
+
markets,
|
|
249
|
+
orders,
|
|
250
|
+
http_poll_interval=http_poll_interval,
|
|
251
|
+
bootstrap_http=bootstrap_http,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# -------------------------------------------------------------------------
|
|
255
|
+
# Start / Stop
|
|
256
|
+
# -------------------------------------------------------------------------
|
|
257
|
+
def start(self, bootstrap_http=True):
|
|
258
|
+
# Start WebSocket
|
|
259
|
+
if not self._ws_thread or not self._ws_thread.is_alive():
|
|
260
|
+
self._ws_thread = threading.Thread(target=self.ws_client.start, daemon=True)
|
|
261
|
+
self._ws_thread.start()
|
|
262
|
+
logger.info("Started WebSocket worker.")
|
|
263
|
+
|
|
264
|
+
def stop(self):
|
|
265
|
+
self.ws_client.stop()
|
|
266
|
+
# Join WS
|
|
267
|
+
if self._ws_thread:
|
|
268
|
+
self._ws_thread.join(timeout=1)
|
|
269
|
+
|
|
270
|
+
logger.info("Position watcher stopped.")
|
|
271
|
+
|
|
272
|
+
# -------------------------------------------------------------------------
|
|
273
|
+
# WS handler
|
|
274
|
+
# -------------------------------------------------------------------------
|
|
275
|
+
def _handle_ws_message(self, payload):
|
|
276
|
+
logger.info(f"WS message: {payload.get('type')}")
|
|
277
|
+
if payload.get("type") == "TRADE":
|
|
278
|
+
self._ingest_trade(TradeMessage(**payload))
|
|
279
|
+
else:
|
|
280
|
+
self._ingest_order(OrderMessage(**payload))
|
|
281
|
+
|
|
282
|
+
# -------------------------------------------------------------------------
|
|
283
|
+
# Ingestion
|
|
284
|
+
# -------------------------------------------------------------------------
|
|
285
|
+
def _ingest_trade(self, trade):
|
|
286
|
+
self.position_store.append_trade(trade)
|
|
287
|
+
|
|
288
|
+
def _init_trades(self, trades: list[TradeMessage]):
|
|
289
|
+
self.position_store.init_trades(trades)
|
|
290
|
+
|
|
291
|
+
def _ingest_order(self, order):
|
|
292
|
+
self.position_store.append_order(order)
|
|
293
|
+
|
|
294
|
+
# -------------------------------------------------------------------------
|
|
295
|
+
# Helpers
|
|
296
|
+
# -------------------------------------------------------------------------
|
|
297
|
+
def _resolve_user_address(self):
|
|
298
|
+
funder = getattr(getattr(self.client, "builder", None), "funder", None)
|
|
299
|
+
if funder:
|
|
300
|
+
return funder
|
|
301
|
+
return self.client.get_address()
|
|
302
|
+
|
|
303
|
+
def get_position(self, token_id: str) -> UserPosition:
|
|
304
|
+
if position := self.position_store.get_token_position(token_id):
|
|
305
|
+
return position
|
|
306
|
+
return UserPosition(token_id=token_id, price=0, size=0, volume=0, last_update=0)
|
|
307
|
+
|
|
308
|
+
def get_order_by_token(self, token_id: str) -> list[OrderMessage]:
|
|
309
|
+
return self.position_store.get_token_order(token_id)
|
|
310
|
+
|
|
311
|
+
def get_order(self, order_id: str) -> OrderMessage:
|
|
312
|
+
return self.position_store.get_order_by_id(order_id)
|
|
313
|
+
|
|
314
|
+
def blocking_get_position(
|
|
315
|
+
self, token_id: str, timeout: float = None
|
|
316
|
+
) -> UserPosition | None:
|
|
317
|
+
"""
|
|
318
|
+
超时返回None;若无仓位则返回 size=0 的占位 UserPosition
|
|
319
|
+
"""
|
|
320
|
+
try:
|
|
321
|
+
return self.position_store.blocking_get_token_position(token_id, timeout)
|
|
322
|
+
except Empty:
|
|
323
|
+
# 若超时未收到任何仓位更新,则返回 size=0 的占位对象,方便上层统一处理
|
|
324
|
+
return UserPosition(
|
|
325
|
+
token_id=token_id, price=0, size=0, volume=0, last_update=0
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def blocking_get_order(
|
|
329
|
+
self, order_id: str, timeout: float = None
|
|
330
|
+
) -> OrderMessage | None:
|
|
331
|
+
"""
|
|
332
|
+
超时返回None(即返回 None,表示没有订单更新)
|
|
333
|
+
"""
|
|
334
|
+
try:
|
|
335
|
+
return self.position_store.blocking_get_order_by_id(order_id, timeout)
|
|
336
|
+
except Empty:
|
|
337
|
+
# 超时未拿到订单更新时直接返回 None,由调用方自行判断
|
|
338
|
+
return None
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Pydantic models used by the position watcher service."""
|
|
2
|
+
|
|
3
|
+
from .position_model import (
|
|
4
|
+
MakerOrder,
|
|
5
|
+
TradeMessage,
|
|
6
|
+
OrderMessage,
|
|
7
|
+
UserPosition,
|
|
8
|
+
PositionDetails,
|
|
9
|
+
PositionResult,
|
|
10
|
+
)
|
|
11
|
+
from .common_model import OrderBookSummary
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"MakerOrder",
|
|
15
|
+
"TradeMessage",
|
|
16
|
+
"OrderMessage",
|
|
17
|
+
"UserPosition",
|
|
18
|
+
"PositionDetails",
|
|
19
|
+
"PositionResult",
|
|
20
|
+
"OrderBookSummary",
|
|
21
|
+
]
|