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.
@@ -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