polynode 0.5.5__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.
- polynode/__init__.py +41 -0
- polynode/_version.py +1 -0
- polynode/cache/__init__.py +11 -0
- polynode/client.py +635 -0
- polynode/engine.py +201 -0
- polynode/errors.py +35 -0
- polynode/orderbook.py +243 -0
- polynode/orderbook_state.py +77 -0
- polynode/redemption_watcher.py +339 -0
- polynode/short_form.py +321 -0
- polynode/subscription.py +137 -0
- polynode/testing.py +83 -0
- polynode/trading/__init__.py +19 -0
- polynode/trading/clob_api.py +158 -0
- polynode/trading/constants.py +31 -0
- polynode/trading/cosigner.py +86 -0
- polynode/trading/eip712.py +163 -0
- polynode/trading/onboarding.py +242 -0
- polynode/trading/signer.py +91 -0
- polynode/trading/sqlite_backend.py +208 -0
- polynode/trading/trader.py +506 -0
- polynode/trading/types.py +191 -0
- polynode/types/__init__.py +8 -0
- polynode/types/enums.py +51 -0
- polynode/types/events.py +270 -0
- polynode/types/orderbook.py +66 -0
- polynode/types/rest.py +376 -0
- polynode/types/short_form.py +35 -0
- polynode/types/ws.py +38 -0
- polynode/ws.py +278 -0
- polynode-0.5.5.dist-info/METADATA +133 -0
- polynode-0.5.5.dist-info/RECORD +33 -0
- polynode-0.5.5.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Redemption watcher — tracks wallet positions and alerts on market resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from .client import PolyNode
|
|
10
|
+
from .ws import PolyNodeWS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class RedeemableAlert:
|
|
15
|
+
wallet: str
|
|
16
|
+
condition_id: str
|
|
17
|
+
token_id: str
|
|
18
|
+
outcome: str
|
|
19
|
+
winning_outcome: str
|
|
20
|
+
is_winner: bool
|
|
21
|
+
size: float
|
|
22
|
+
estimated_payout_usd: float
|
|
23
|
+
market_title: str
|
|
24
|
+
market_slug: str
|
|
25
|
+
market_image: str | None
|
|
26
|
+
resolved_price: float
|
|
27
|
+
payouts: list[float]
|
|
28
|
+
block_number: int
|
|
29
|
+
timestamp: float
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class TrackedPosition:
|
|
34
|
+
wallet: str
|
|
35
|
+
token_id: str
|
|
36
|
+
condition_id: str
|
|
37
|
+
outcome: str
|
|
38
|
+
size: float
|
|
39
|
+
market_title: str
|
|
40
|
+
market_slug: str
|
|
41
|
+
market_image: str | None = None
|
|
42
|
+
outcomes: list[str] = field(default_factory=list)
|
|
43
|
+
token_ids: list[str] = field(default_factory=list)
|
|
44
|
+
neg_risk: bool | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RedemptionWatcher:
|
|
48
|
+
"""Tracks wallet positions and emits alerts when markets resolve."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
api_key: str,
|
|
53
|
+
*,
|
|
54
|
+
base_url: str | None = None,
|
|
55
|
+
ws_url: str | None = None,
|
|
56
|
+
compress: bool = True,
|
|
57
|
+
auto_reconnect: bool = True,
|
|
58
|
+
track_position_changes: bool = True,
|
|
59
|
+
refresh_interval: float = 300.0,
|
|
60
|
+
) -> None:
|
|
61
|
+
self._client = PolyNode(api_key, base_url=base_url or "https://api.polynode.dev")
|
|
62
|
+
from .types.ws import WsOptions
|
|
63
|
+
self._ws = PolyNodeWS(
|
|
64
|
+
api_key,
|
|
65
|
+
ws_url or "wss://ws.polynode.dev/ws",
|
|
66
|
+
WsOptions(compress=compress, auto_reconnect=auto_reconnect),
|
|
67
|
+
)
|
|
68
|
+
self._track_position_changes = track_position_changes
|
|
69
|
+
self._refresh_interval = refresh_interval
|
|
70
|
+
|
|
71
|
+
self._by_condition: dict[str, list[TrackedPosition]] = {}
|
|
72
|
+
self._by_wallet: dict[str, set[str]] = {}
|
|
73
|
+
|
|
74
|
+
self._oracle_sub = None
|
|
75
|
+
self._wallet_sub = None
|
|
76
|
+
self._refresh_task: asyncio.Task | None = None
|
|
77
|
+
self._closed = False
|
|
78
|
+
|
|
79
|
+
self._handlers: dict[str, list[Callable]] = {"alert": [], "ready": [], "error": []}
|
|
80
|
+
self._alert_queue: asyncio.Queue[RedeemableAlert | None] = asyncio.Queue()
|
|
81
|
+
|
|
82
|
+
async def start(self, wallets: list[str]) -> None:
|
|
83
|
+
if self._closed:
|
|
84
|
+
raise RuntimeError("RedemptionWatcher is closed")
|
|
85
|
+
|
|
86
|
+
await self._fetch_and_load(wallets)
|
|
87
|
+
|
|
88
|
+
self._oracle_sub = await self._ws.subscribe("oracle").send()
|
|
89
|
+
self._oracle_sub.on("oracle", self._handle_oracle_event)
|
|
90
|
+
|
|
91
|
+
if self._track_position_changes and wallets:
|
|
92
|
+
self._wallet_sub = await self._ws.subscribe("wallets").wallets(wallets).send()
|
|
93
|
+
self._wallet_sub.on("position_change", self._handle_position_change)
|
|
94
|
+
|
|
95
|
+
if self._refresh_interval > 0:
|
|
96
|
+
self._refresh_task = asyncio.ensure_future(self._refresh_loop())
|
|
97
|
+
|
|
98
|
+
for h in self._handlers["ready"]:
|
|
99
|
+
h()
|
|
100
|
+
|
|
101
|
+
async def add_wallets(self, wallets: list[str]) -> None:
|
|
102
|
+
if self._closed:
|
|
103
|
+
raise RuntimeError("RedemptionWatcher is closed")
|
|
104
|
+
await self._fetch_and_load(wallets)
|
|
105
|
+
if self._track_position_changes:
|
|
106
|
+
await self._resubscribe_wallets()
|
|
107
|
+
|
|
108
|
+
def remove_wallets(self, wallets: list[str]) -> None:
|
|
109
|
+
for wallet in wallets:
|
|
110
|
+
conditions = self._by_wallet.pop(wallet, set())
|
|
111
|
+
for cond_id in conditions:
|
|
112
|
+
positions = self._by_condition.get(cond_id, [])
|
|
113
|
+
filtered = [p for p in positions if p.wallet.lower() != wallet.lower()]
|
|
114
|
+
if filtered:
|
|
115
|
+
self._by_condition[cond_id] = filtered
|
|
116
|
+
else:
|
|
117
|
+
self._by_condition.pop(cond_id, None)
|
|
118
|
+
if self._track_position_changes and not self._closed:
|
|
119
|
+
asyncio.ensure_future(self._resubscribe_wallets())
|
|
120
|
+
|
|
121
|
+
def on(self, event: str, handler: Callable) -> RedemptionWatcher:
|
|
122
|
+
if event in self._handlers:
|
|
123
|
+
self._handlers[event].append(handler)
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
def off(self, event: str, handler: Callable) -> RedemptionWatcher:
|
|
127
|
+
if event in self._handlers:
|
|
128
|
+
try:
|
|
129
|
+
self._handlers[event].remove(handler)
|
|
130
|
+
except ValueError:
|
|
131
|
+
pass
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def wallets(self) -> list[str]:
|
|
136
|
+
return list(self._by_wallet.keys())
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def conditions(self) -> list[str]:
|
|
140
|
+
return list(self._by_condition.keys())
|
|
141
|
+
|
|
142
|
+
def positions_for(self, wallet: str) -> list[TrackedPosition]:
|
|
143
|
+
conditions = self._by_wallet.get(wallet, set())
|
|
144
|
+
result = []
|
|
145
|
+
for cond_id in conditions:
|
|
146
|
+
for p in self._by_condition.get(cond_id, []):
|
|
147
|
+
if p.wallet.lower() == wallet.lower():
|
|
148
|
+
result.append(p)
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def size(self) -> int:
|
|
153
|
+
return sum(len(ps) for ps in self._by_condition.values())
|
|
154
|
+
|
|
155
|
+
def close(self) -> None:
|
|
156
|
+
self._closed = True
|
|
157
|
+
if self._refresh_task and not self._refresh_task.done():
|
|
158
|
+
self._refresh_task.cancel()
|
|
159
|
+
if self._oracle_sub:
|
|
160
|
+
self._oracle_sub.unsubscribe()
|
|
161
|
+
if self._wallet_sub:
|
|
162
|
+
self._wallet_sub.unsubscribe()
|
|
163
|
+
self._ws.disconnect()
|
|
164
|
+
self._client.close()
|
|
165
|
+
self._alert_queue.put_nowait(None)
|
|
166
|
+
|
|
167
|
+
def __aiter__(self):
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
async def __anext__(self) -> RedeemableAlert:
|
|
171
|
+
if self._closed:
|
|
172
|
+
raise StopAsyncIteration
|
|
173
|
+
alert = await self._alert_queue.get()
|
|
174
|
+
if alert is None:
|
|
175
|
+
raise StopAsyncIteration
|
|
176
|
+
return alert
|
|
177
|
+
|
|
178
|
+
# ── Private ──
|
|
179
|
+
|
|
180
|
+
async def _fetch_and_load(self, wallets: list[str]) -> None:
|
|
181
|
+
for wallet in wallets:
|
|
182
|
+
try:
|
|
183
|
+
data = self._client.wallet_positions(wallet)
|
|
184
|
+
self._load_positions(wallet, data.get("positions", []))
|
|
185
|
+
except Exception as e:
|
|
186
|
+
for h in self._handlers["error"]:
|
|
187
|
+
h(e)
|
|
188
|
+
|
|
189
|
+
def _load_positions(self, wallet: str, positions: list[dict]) -> None:
|
|
190
|
+
wallet_conditions = self._by_wallet.get(wallet, set())
|
|
191
|
+
|
|
192
|
+
for pos in positions:
|
|
193
|
+
condition_id = pos.get("conditionId") or pos.get("condition_id")
|
|
194
|
+
token_id = pos.get("asset") or pos.get("token_id")
|
|
195
|
+
size = pos.get("size", 0)
|
|
196
|
+
if not condition_id or not token_id or size <= 0:
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
tracked = TrackedPosition(
|
|
200
|
+
wallet=wallet,
|
|
201
|
+
token_id=token_id,
|
|
202
|
+
condition_id=condition_id,
|
|
203
|
+
outcome=pos.get("outcome", ""),
|
|
204
|
+
size=size,
|
|
205
|
+
market_title=pos.get("market_title") or pos.get("title", ""),
|
|
206
|
+
market_slug=pos.get("market_slug") or pos.get("slug", ""),
|
|
207
|
+
market_image=pos.get("market_image") or pos.get("icon") or pos.get("image"),
|
|
208
|
+
outcomes=pos.get("outcomes", []),
|
|
209
|
+
token_ids=pos.get("token_ids", []),
|
|
210
|
+
neg_risk=pos.get("neg_risk") or pos.get("negativeRisk"),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
existing = self._by_condition.get(condition_id, [])
|
|
214
|
+
dup_idx = next(
|
|
215
|
+
(i for i, p in enumerate(existing) if p.wallet.lower() == wallet.lower() and p.token_id == token_id),
|
|
216
|
+
None,
|
|
217
|
+
)
|
|
218
|
+
if dup_idx is not None:
|
|
219
|
+
existing[dup_idx] = tracked
|
|
220
|
+
else:
|
|
221
|
+
existing.append(tracked)
|
|
222
|
+
self._by_condition[condition_id] = existing
|
|
223
|
+
wallet_conditions.add(condition_id)
|
|
224
|
+
|
|
225
|
+
self._by_wallet[wallet] = wallet_conditions
|
|
226
|
+
|
|
227
|
+
def _handle_oracle_event(self, event: Any) -> None:
|
|
228
|
+
if event.oracle_type != "condition_resolution":
|
|
229
|
+
return
|
|
230
|
+
condition_id = event.condition_id
|
|
231
|
+
if not condition_id:
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
positions = self._by_condition.get(condition_id, [])
|
|
235
|
+
if not positions:
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
for pos in positions:
|
|
239
|
+
token_ids = event.token_ids or []
|
|
240
|
+
token_index = token_ids.index(pos.token_id) if pos.token_id in token_ids else -1
|
|
241
|
+
is_winner = token_index >= 0 and (event.payouts or [])[token_index] > 0 if event.payouts and token_index >= 0 else False
|
|
242
|
+
|
|
243
|
+
alert = RedeemableAlert(
|
|
244
|
+
wallet=pos.wallet,
|
|
245
|
+
condition_id=pos.condition_id,
|
|
246
|
+
token_id=pos.token_id,
|
|
247
|
+
outcome=pos.outcome,
|
|
248
|
+
winning_outcome=event.resolved_outcome or "Unknown",
|
|
249
|
+
is_winner=is_winner,
|
|
250
|
+
size=pos.size,
|
|
251
|
+
estimated_payout_usd=pos.size if is_winner else 0,
|
|
252
|
+
market_title=pos.market_title,
|
|
253
|
+
market_slug=pos.market_slug,
|
|
254
|
+
market_image=pos.market_image,
|
|
255
|
+
resolved_price=event.resolved_price or 0,
|
|
256
|
+
payouts=event.payouts or [],
|
|
257
|
+
block_number=event.block_number,
|
|
258
|
+
timestamp=event.timestamp,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
for h in self._handlers["alert"]:
|
|
262
|
+
h(alert)
|
|
263
|
+
self._alert_queue.put_nowait(alert)
|
|
264
|
+
|
|
265
|
+
# Evict resolved condition
|
|
266
|
+
self._by_condition.pop(condition_id, None)
|
|
267
|
+
for wc in self._by_wallet.values():
|
|
268
|
+
wc.discard(condition_id)
|
|
269
|
+
|
|
270
|
+
def _handle_position_change(self, event: Any) -> None:
|
|
271
|
+
matched = False
|
|
272
|
+
affected_condition_id = None
|
|
273
|
+
|
|
274
|
+
for cond_id, positions in self._by_condition.items():
|
|
275
|
+
for pos in positions:
|
|
276
|
+
if pos.token_id != event.token_id:
|
|
277
|
+
continue
|
|
278
|
+
matched = True
|
|
279
|
+
affected_condition_id = cond_id
|
|
280
|
+
if event.to.lower() == pos.wallet.lower():
|
|
281
|
+
pos.size += event.amount
|
|
282
|
+
if event.from_address.lower() == pos.wallet.lower():
|
|
283
|
+
pos.size -= event.amount
|
|
284
|
+
if pos.size < 0:
|
|
285
|
+
pos.size = 0
|
|
286
|
+
|
|
287
|
+
if affected_condition_id:
|
|
288
|
+
positions = self._by_condition.get(affected_condition_id, [])
|
|
289
|
+
remaining = [p for p in positions if p.size > 0]
|
|
290
|
+
if not remaining:
|
|
291
|
+
self._by_condition.pop(affected_condition_id, None)
|
|
292
|
+
for wc in self._by_wallet.values():
|
|
293
|
+
wc.discard(affected_condition_id)
|
|
294
|
+
elif len(remaining) < len(positions):
|
|
295
|
+
self._by_condition[affected_condition_id] = remaining
|
|
296
|
+
|
|
297
|
+
if not matched and hasattr(event, "condition_id") and event.condition_id:
|
|
298
|
+
to_wallet = event.to.lower()
|
|
299
|
+
if to_wallet in self._by_wallet:
|
|
300
|
+
tracked = TrackedPosition(
|
|
301
|
+
wallet=to_wallet,
|
|
302
|
+
token_id=event.token_id,
|
|
303
|
+
condition_id=event.condition_id,
|
|
304
|
+
outcome=getattr(event, "outcome", "") or "",
|
|
305
|
+
size=event.amount,
|
|
306
|
+
market_title=getattr(event, "market_title", "") or "",
|
|
307
|
+
market_slug=getattr(event, "market_slug", "") or "",
|
|
308
|
+
market_image=getattr(event, "market_image", None),
|
|
309
|
+
outcomes=getattr(event, "outcomes", []) or [],
|
|
310
|
+
token_ids=getattr(event, "token_ids", []) or [],
|
|
311
|
+
neg_risk=getattr(event, "neg_risk", None),
|
|
312
|
+
)
|
|
313
|
+
existing = self._by_condition.get(event.condition_id, [])
|
|
314
|
+
existing.append(tracked)
|
|
315
|
+
self._by_condition[event.condition_id] = existing
|
|
316
|
+
self._by_wallet[to_wallet].add(event.condition_id)
|
|
317
|
+
|
|
318
|
+
async def _resubscribe_wallets(self) -> None:
|
|
319
|
+
if self._wallet_sub:
|
|
320
|
+
self._wallet_sub.unsubscribe()
|
|
321
|
+
self._wallet_sub = None
|
|
322
|
+
all_wallets = list(self._by_wallet.keys())
|
|
323
|
+
if not all_wallets:
|
|
324
|
+
return
|
|
325
|
+
self._wallet_sub = await self._ws.subscribe("wallets").wallets(all_wallets).send()
|
|
326
|
+
self._wallet_sub.on("position_change", self._handle_position_change)
|
|
327
|
+
|
|
328
|
+
async def _refresh_loop(self) -> None:
|
|
329
|
+
while not self._closed:
|
|
330
|
+
await asyncio.sleep(self._refresh_interval)
|
|
331
|
+
if self._closed:
|
|
332
|
+
break
|
|
333
|
+
all_wallets = list(self._by_wallet.keys())
|
|
334
|
+
if all_wallets:
|
|
335
|
+
try:
|
|
336
|
+
await self._fetch_and_load(all_wallets)
|
|
337
|
+
except Exception as e:
|
|
338
|
+
for h in self._handlers["error"]:
|
|
339
|
+
h(e)
|
polynode/short_form.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Auto-rotating stream for Polymarket short-form crypto markets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
from zoneinfo import ZoneInfo
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .types.events import PolyNodeEvent
|
|
15
|
+
from .types.short_form import (
|
|
16
|
+
RotationEvent,
|
|
17
|
+
ShortFormCoin,
|
|
18
|
+
ShortFormInterval,
|
|
19
|
+
ShortFormMarket,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
ALL_COINS: list[ShortFormCoin] = ["btc", "eth", "sol", "xrp", "doge", "hype", "bnb"]
|
|
23
|
+
|
|
24
|
+
COIN_FULL_NAMES: dict[ShortFormCoin, str] = {
|
|
25
|
+
"btc": "bitcoin",
|
|
26
|
+
"eth": "ethereum",
|
|
27
|
+
"sol": "solana",
|
|
28
|
+
"xrp": "xrp",
|
|
29
|
+
"doge": "dogecoin",
|
|
30
|
+
"hype": "hype",
|
|
31
|
+
"bnb": "bnb",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
COIN_SYMBOLS: dict[ShortFormCoin, str] = {
|
|
35
|
+
"btc": "BTC", "eth": "ETH", "sol": "SOL", "xrp": "XRP",
|
|
36
|
+
"doge": "DOGE", "hype": "HYPE", "bnb": "BNB",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
INTERVAL_VARIANT: dict[ShortFormInterval, str] = {
|
|
40
|
+
"5m": "five", "15m": "fifteen", "1h": "sixty",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
WINDOW_SECONDS: dict[ShortFormInterval, int] = {
|
|
44
|
+
"5m": 300, "15m": 900, "1h": 3600,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
DEFAULT_API_BASE = "https://api.polynode.dev"
|
|
48
|
+
DEFAULT_ROTATION_BUFFER = 3
|
|
49
|
+
DISCOVERY_RETRIES = 3
|
|
50
|
+
DISCOVERY_RETRY_DELAY = 2.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ShortFormStream:
|
|
54
|
+
"""Auto-rotating stream for Polymarket short-form crypto markets."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
ws: Any,
|
|
59
|
+
interval: ShortFormInterval,
|
|
60
|
+
*,
|
|
61
|
+
coins: list[ShortFormCoin] | None = None,
|
|
62
|
+
api_base_url: str | None = None,
|
|
63
|
+
rotation_buffer: int = DEFAULT_ROTATION_BUFFER,
|
|
64
|
+
) -> None:
|
|
65
|
+
self._ws = ws
|
|
66
|
+
self.interval = interval
|
|
67
|
+
self.coins: list[ShortFormCoin] = coins or list(ALL_COINS)
|
|
68
|
+
self._api_base = (api_base_url or DEFAULT_API_BASE).rstrip("/")
|
|
69
|
+
self._rotation_buffer = rotation_buffer
|
|
70
|
+
self._handlers: dict[str, set[Callable]] = {}
|
|
71
|
+
self._sub = None
|
|
72
|
+
self._markets: list[ShortFormMarket] = []
|
|
73
|
+
self._rotation_task: asyncio.Task | None = None
|
|
74
|
+
self._rotating = False
|
|
75
|
+
self._stopped = False
|
|
76
|
+
|
|
77
|
+
def on(self, type: str, handler: Callable) -> ShortFormStream:
|
|
78
|
+
if type not in self._handlers:
|
|
79
|
+
self._handlers[type] = set()
|
|
80
|
+
self._handlers[type].add(handler)
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
def off(self, type: str, handler: Callable) -> ShortFormStream:
|
|
84
|
+
if type in self._handlers:
|
|
85
|
+
self._handlers[type].discard(handler)
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def markets(self) -> list[ShortFormMarket]:
|
|
90
|
+
return self._markets
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def time_remaining(self) -> int:
|
|
94
|
+
if not self._markets:
|
|
95
|
+
return 0
|
|
96
|
+
window_end = max(m.window_end for m in self._markets)
|
|
97
|
+
return max(0, window_end - int(time.time()))
|
|
98
|
+
|
|
99
|
+
async def start(self) -> None:
|
|
100
|
+
if self._stopped:
|
|
101
|
+
return
|
|
102
|
+
self._ws.on_reconnect(lambda _: asyncio.ensure_future(self._rotate()) if not self._stopped else None)
|
|
103
|
+
await self._rotate()
|
|
104
|
+
|
|
105
|
+
def stop(self) -> None:
|
|
106
|
+
self._stopped = True
|
|
107
|
+
if self._rotation_task and not self._rotation_task.done():
|
|
108
|
+
self._rotation_task.cancel()
|
|
109
|
+
if self._sub:
|
|
110
|
+
self._sub.unsubscribe()
|
|
111
|
+
self._sub = None
|
|
112
|
+
|
|
113
|
+
async def _rotate(self) -> None:
|
|
114
|
+
if self._stopped or self._rotating:
|
|
115
|
+
return
|
|
116
|
+
self._rotating = True
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
if self._sub:
|
|
120
|
+
self._sub.unsubscribe()
|
|
121
|
+
self._sub = None
|
|
122
|
+
|
|
123
|
+
markets = await self._discover_markets()
|
|
124
|
+
if self._stopped:
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
self._markets = markets
|
|
128
|
+
|
|
129
|
+
if not markets:
|
|
130
|
+
self._emit("error", Exception(f"No markets found for {self.interval}"))
|
|
131
|
+
self._schedule_rotation()
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
slugs = [m.slug for m in markets]
|
|
135
|
+
sub = await self._ws.subscribe("settlements").slugs(slugs).status("all").send()
|
|
136
|
+
|
|
137
|
+
if self._stopped:
|
|
138
|
+
sub.unsubscribe()
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
self._sub = sub
|
|
142
|
+
sub.on("*", lambda event: (self._emit(event.event_type, event), self._emit("*", event)))
|
|
143
|
+
|
|
144
|
+
window_end = max(m.window_end for m in markets)
|
|
145
|
+
window_start = min(m.window_start for m in markets)
|
|
146
|
+
self._emit("rotation", RotationEvent(
|
|
147
|
+
interval=self.interval,
|
|
148
|
+
markets=markets,
|
|
149
|
+
window_start=window_start,
|
|
150
|
+
window_end=window_end,
|
|
151
|
+
time_remaining=max(0, window_end - int(time.time())),
|
|
152
|
+
))
|
|
153
|
+
|
|
154
|
+
self._schedule_rotation(window_end)
|
|
155
|
+
except Exception as e:
|
|
156
|
+
self._emit("error", e)
|
|
157
|
+
if not self._stopped:
|
|
158
|
+
self._rotation_task = asyncio.ensure_future(self._delayed_rotate(self._rotation_buffer))
|
|
159
|
+
finally:
|
|
160
|
+
self._rotating = False
|
|
161
|
+
|
|
162
|
+
async def _delayed_rotate(self, delay: float) -> None:
|
|
163
|
+
await asyncio.sleep(delay)
|
|
164
|
+
await self._rotate()
|
|
165
|
+
|
|
166
|
+
def _schedule_rotation(self, window_end: int | None = None) -> None:
|
|
167
|
+
if self._rotation_task and not self._rotation_task.done():
|
|
168
|
+
self._rotation_task.cancel()
|
|
169
|
+
end = window_end or self._compute_window_end()
|
|
170
|
+
delay = max((end - int(time.time()) + self._rotation_buffer), 1)
|
|
171
|
+
self._rotation_task = asyncio.ensure_future(self._delayed_rotate(delay))
|
|
172
|
+
|
|
173
|
+
def _compute_window_end(self) -> int:
|
|
174
|
+
now = int(time.time())
|
|
175
|
+
w = WINDOW_SECONDS[self.interval]
|
|
176
|
+
return (now // w) * w + w
|
|
177
|
+
|
|
178
|
+
async def _discover_markets(self) -> list[ShortFormMarket]:
|
|
179
|
+
results: list[ShortFormMarket] = []
|
|
180
|
+
async with httpx.AsyncClient(timeout=5.0) as http:
|
|
181
|
+
tasks = [self._discover_coin(http, coin) for coin in self.coins]
|
|
182
|
+
for coro in asyncio.as_completed(tasks):
|
|
183
|
+
market = await coro
|
|
184
|
+
if market:
|
|
185
|
+
results.append(market)
|
|
186
|
+
# Fetch price-to-beat in parallel
|
|
187
|
+
ptb_tasks = [self._fetch_price_to_beat(http, m) for m in results]
|
|
188
|
+
prices = await asyncio.gather(*ptb_tasks, return_exceptions=True)
|
|
189
|
+
for m, p in zip(results, prices):
|
|
190
|
+
if isinstance(p, (int, float)):
|
|
191
|
+
m.price_to_beat = p
|
|
192
|
+
return results
|
|
193
|
+
|
|
194
|
+
async def _discover_coin(self, http: httpx.AsyncClient, coin: ShortFormCoin) -> ShortFormMarket | None:
|
|
195
|
+
for attempt in range(DISCOVERY_RETRIES):
|
|
196
|
+
try:
|
|
197
|
+
url = self._build_discovery_url(coin)
|
|
198
|
+
resp = await http.get(url)
|
|
199
|
+
if not resp.is_success:
|
|
200
|
+
if attempt < DISCOVERY_RETRIES - 1:
|
|
201
|
+
await asyncio.sleep(DISCOVERY_RETRY_DELAY)
|
|
202
|
+
continue
|
|
203
|
+
return None
|
|
204
|
+
data = resp.json()
|
|
205
|
+
if not data:
|
|
206
|
+
if attempt < DISCOVERY_RETRIES - 1:
|
|
207
|
+
await asyncio.sleep(DISCOVERY_RETRY_DELAY)
|
|
208
|
+
continue
|
|
209
|
+
return None
|
|
210
|
+
return self._parse_gamma_event(coin, data[0])
|
|
211
|
+
except Exception:
|
|
212
|
+
if attempt < DISCOVERY_RETRIES - 1:
|
|
213
|
+
await asyncio.sleep(DISCOVERY_RETRY_DELAY)
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
def _build_discovery_url(self, coin: ShortFormCoin) -> str:
|
|
217
|
+
if self.interval == "1h":
|
|
218
|
+
slug = _build_hourly_slug(coin)
|
|
219
|
+
return f"{self._api_base}/proxy/gamma/events?slug={slug}"
|
|
220
|
+
w = WINDOW_SECONDS[self.interval]
|
|
221
|
+
now = int(time.time())
|
|
222
|
+
ts = (now // w) * w
|
|
223
|
+
slug = f"{coin}-updown-{self.interval}-{ts}"
|
|
224
|
+
return f"{self._api_base}/proxy/gamma/events?slug={slug}"
|
|
225
|
+
|
|
226
|
+
async def _fetch_price_to_beat(self, http: httpx.AsyncClient, market: ShortFormMarket) -> float | None:
|
|
227
|
+
try:
|
|
228
|
+
symbol = COIN_SYMBOLS[market.coin]
|
|
229
|
+
variant = INTERVAL_VARIANT[self.interval]
|
|
230
|
+
start_time = datetime.fromtimestamp(market.window_start).isoformat() + "Z"
|
|
231
|
+
end_date = datetime.fromtimestamp(market.window_end).isoformat() + "Z"
|
|
232
|
+
url = (
|
|
233
|
+
f"{self._api_base}/proxy/polymarket/api/crypto/crypto-price"
|
|
234
|
+
f"?symbol={symbol}&eventStartTime={start_time}&variant={variant}&endDate={end_date}"
|
|
235
|
+
)
|
|
236
|
+
resp = await http.get(url)
|
|
237
|
+
if not resp.is_success:
|
|
238
|
+
return None
|
|
239
|
+
data = resp.json()
|
|
240
|
+
return data.get("openPrice")
|
|
241
|
+
except Exception:
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
def _parse_gamma_event(self, coin: ShortFormCoin, event: dict) -> ShortFormMarket | None:
|
|
245
|
+
market = (event.get("markets") or [{}])[0] if event.get("markets") else None
|
|
246
|
+
if not market:
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
outcomes = _safe_json_parse(market.get("outcomes"), [])
|
|
250
|
+
raw_prices = [float(p) for p in _safe_json_parse(market.get("outcomePrices"), [])]
|
|
251
|
+
clob_token_ids = _safe_json_parse(market.get("clobTokenIds"), [])
|
|
252
|
+
|
|
253
|
+
up_odds = 0.5
|
|
254
|
+
down_odds = 0.5
|
|
255
|
+
for i, label in enumerate(outcomes):
|
|
256
|
+
price = raw_prices[i] if i < len(raw_prices) else 0
|
|
257
|
+
if "up" in label.lower():
|
|
258
|
+
up_odds = price
|
|
259
|
+
if "down" in label.lower():
|
|
260
|
+
down_odds = price
|
|
261
|
+
|
|
262
|
+
slug = event.get("slug", "")
|
|
263
|
+
if self.interval == "1h":
|
|
264
|
+
now = int(time.time())
|
|
265
|
+
window_start = (now // 3600) * 3600
|
|
266
|
+
window_end = window_start + 3600
|
|
267
|
+
if event.get("endDate"):
|
|
268
|
+
window_end = int(datetime.fromisoformat(event["endDate"].replace("Z", "+00:00")).timestamp())
|
|
269
|
+
window_start = window_end - 3600
|
|
270
|
+
else:
|
|
271
|
+
w = WINDOW_SECONDS[self.interval]
|
|
272
|
+
parts = slug.split("-")
|
|
273
|
+
slug_ts = int(parts[-1]) if parts and parts[-1].isdigit() else 0
|
|
274
|
+
window_start = slug_ts or (int(time.time()) // w) * w
|
|
275
|
+
window_end = window_start + w
|
|
276
|
+
|
|
277
|
+
return ShortFormMarket(
|
|
278
|
+
coin=coin,
|
|
279
|
+
slug=slug,
|
|
280
|
+
title=event.get("title", ""),
|
|
281
|
+
condition_id=market.get("conditionId", ""),
|
|
282
|
+
window_start=window_start,
|
|
283
|
+
window_end=window_end,
|
|
284
|
+
outcomes=outcomes,
|
|
285
|
+
outcome_prices=raw_prices,
|
|
286
|
+
clob_token_ids=clob_token_ids,
|
|
287
|
+
up_odds=up_odds,
|
|
288
|
+
down_odds=down_odds,
|
|
289
|
+
liquidity=float(market.get("liquidity", 0)),
|
|
290
|
+
volume_24h=float(market.get("volume24hr") or market.get("volume") or 0),
|
|
291
|
+
price_to_beat=None,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def _emit(self, type: str, data: Any) -> None:
|
|
295
|
+
handlers = self._handlers.get(type)
|
|
296
|
+
if handlers:
|
|
297
|
+
for h in handlers:
|
|
298
|
+
h(data)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _build_hourly_slug(coin: ShortFormCoin) -> str:
|
|
302
|
+
et = ZoneInfo("America/New_York")
|
|
303
|
+
now = datetime.now(et)
|
|
304
|
+
month = now.strftime("%B").lower()
|
|
305
|
+
day = str(now.day)
|
|
306
|
+
year = str(now.year)
|
|
307
|
+
hour = now.strftime("%I").lstrip("0") or "12"
|
|
308
|
+
ampm = now.strftime("%p").lower()
|
|
309
|
+
coin_name = COIN_FULL_NAMES[coin]
|
|
310
|
+
return f"{coin_name}-up-or-down-{month}-{day}-{year}-{hour}{ampm}-et"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _safe_json_parse(value: Any, fallback: Any) -> Any:
|
|
314
|
+
if isinstance(value, str):
|
|
315
|
+
try:
|
|
316
|
+
return json.loads(value)
|
|
317
|
+
except Exception:
|
|
318
|
+
return fallback
|
|
319
|
+
if isinstance(value, list):
|
|
320
|
+
return value
|
|
321
|
+
return fallback
|