doulfa commited on
Commit
7ad7d85
Β·
verified Β·
1 Parent(s): 3458abb

Add data module

Browse files
Files changed (1) hide show
  1. polymarket_bot/data.py +349 -0
polymarket_bot/data.py ADDED
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module de donnΓ©es: rΓ©cupΓ©ration des marchΓ©s, carnets d'ordres, et WebSocket.
3
+ """
4
+ import asyncio
5
+ import json
6
+ import logging
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import Optional
10
+
11
+ import aiohttp
12
+ import requests
13
+
14
+ from .config import GAMMA_API_URL, CLOB_API_URL, WS_URL
15
+
16
+ logger = logging.getLogger("polybot.data")
17
+
18
+
19
+ # ══════════════════════════════════════════════════════════════════
20
+ # DATA MODELS
21
+ # ══════════════════════════════════════════════════════════════════
22
+ @dataclass
23
+ class Token:
24
+ token_id: str
25
+ outcome: str # "Yes" or "No"
26
+ price: float = 0.0
27
+
28
+
29
+ @dataclass
30
+ class Market:
31
+ market_id: str
32
+ condition_id: str
33
+ question: str
34
+ tokens: list # [Token(YES), Token(NO)]
35
+ volume: float = 0.0
36
+ active: bool = True
37
+ closed: bool = False
38
+ end_date: str = ""
39
+ tags: list = field(default_factory=list)
40
+ neg_risk: bool = False
41
+ neg_risk_market_id: str = ""
42
+ last_updated: float = 0.0
43
+
44
+ @property
45
+ def yes_token(self) -> Optional[Token]:
46
+ for t in self.tokens:
47
+ if t.outcome.lower() == "yes":
48
+ return t
49
+ return None
50
+
51
+ @property
52
+ def no_token(self) -> Optional[Token]:
53
+ for t in self.tokens:
54
+ if t.outcome.lower() == "no":
55
+ return t
56
+ return None
57
+
58
+ @property
59
+ def spread(self) -> float:
60
+ """DiffΓ©rence entre YES+NO et 1.0. Positif = arbitrage long possible."""
61
+ yes = self.yes_token
62
+ no = self.no_token
63
+ if yes and no and yes.price > 0 and no.price > 0:
64
+ return 1.0 - (yes.price + no.price)
65
+ return 0.0
66
+
67
+
68
+ @dataclass
69
+ class OrderBookLevel:
70
+ price: float
71
+ size: float
72
+
73
+
74
+ @dataclass
75
+ class OrderBook:
76
+ token_id: str
77
+ bids: list # [OrderBookLevel]
78
+ asks: list # [OrderBookLevel]
79
+ timestamp: float = 0.0
80
+
81
+ @property
82
+ def best_bid(self) -> Optional[float]:
83
+ return self.bids[0].price if self.bids else None
84
+
85
+ @property
86
+ def best_ask(self) -> Optional[float]:
87
+ return self.asks[0].price if self.asks else None
88
+
89
+ @property
90
+ def mid_price(self) -> Optional[float]:
91
+ if self.best_bid is not None and self.best_ask is not None:
92
+ return (self.best_bid + self.best_ask) / 2
93
+ return None
94
+
95
+ @property
96
+ def total_bid_depth(self) -> float:
97
+ return sum(l.price * l.size for l in self.bids)
98
+
99
+ @property
100
+ def total_ask_depth(self) -> float:
101
+ return sum(l.price * l.size for l in self.asks)
102
+
103
+
104
+ # ══════════════════════════════════════════════════════════════════
105
+ # GAMMA API (Public Market Data)
106
+ # ══════════════════════════════════════════════════════════════════
107
+ class GammaClient:
108
+ """Client pour l'API Gamma (donnΓ©es de marchΓ© publiques)."""
109
+
110
+ def __init__(self, base_url: str = GAMMA_API_URL):
111
+ self.base_url = base_url
112
+ self._session: Optional[aiohttp.ClientSession] = None
113
+
114
+ async def _get_session(self) -> aiohttp.ClientSession:
115
+ if self._session is None or self._session.closed:
116
+ self._session = aiohttp.ClientSession()
117
+ return self._session
118
+
119
+ async def close(self):
120
+ if self._session and not self._session.closed:
121
+ await self._session.close()
122
+
123
+ async def get_active_markets(self, limit: int = 100, offset: int = 0) -> list[dict]:
124
+ """Récupère les marchés actifs depuis Gamma API."""
125
+ session = await self._get_session()
126
+ params = {
127
+ "active": "true",
128
+ "closed": "false",
129
+ "limit": limit,
130
+ "offset": offset,
131
+ }
132
+ try:
133
+ async with session.get(f"{self.base_url}/markets", params=params) as resp:
134
+ if resp.status == 200:
135
+ data = await resp.json()
136
+ return data if isinstance(data, list) else data.get("data", [])
137
+ else:
138
+ logger.error(f"Gamma API error: {resp.status}")
139
+ return []
140
+ except Exception as e:
141
+ logger.error(f"Gamma API request failed: {e}")
142
+ return []
143
+
144
+ async def get_all_active_markets(self, max_markets: int = 500) -> list[Market]:
145
+ """Récupère tous les marchés actifs avec pagination."""
146
+ all_markets = []
147
+ offset = 0
148
+ batch_size = 100
149
+
150
+ while offset < max_markets:
151
+ raw = await self.get_active_markets(limit=batch_size, offset=offset)
152
+ if not raw:
153
+ break
154
+
155
+ for m in raw:
156
+ try:
157
+ tokens = []
158
+ # Parse outcome prices (can be JSON string or list)
159
+ outcome_prices_raw = m.get("outcomePrices", "[]")
160
+ if isinstance(outcome_prices_raw, str):
161
+ outcome_prices = json.loads(outcome_prices_raw)
162
+ else:
163
+ outcome_prices = outcome_prices_raw
164
+
165
+ # Parse outcomes (can be JSON string or list)
166
+ outcomes_raw = m.get("outcomes", "[]")
167
+ if isinstance(outcomes_raw, str):
168
+ outcomes = json.loads(outcomes_raw)
169
+ else:
170
+ outcomes = outcomes_raw
171
+
172
+ # Parse token IDs from clobTokenIds
173
+ clob_token_ids_raw = m.get("clobTokenIds", "[]")
174
+ if isinstance(clob_token_ids_raw, str):
175
+ clob_token_ids = json.loads(clob_token_ids_raw)
176
+ else:
177
+ clob_token_ids = clob_token_ids_raw
178
+
179
+ # Build tokens from outcomes + clobTokenIds + prices
180
+ if outcomes and clob_token_ids and outcome_prices:
181
+ for i in range(min(len(outcomes), len(clob_token_ids), len(outcome_prices))):
182
+ tokens.append(Token(
183
+ token_id=str(clob_token_ids[i]),
184
+ outcome=outcomes[i],
185
+ price=float(outcome_prices[i]),
186
+ ))
187
+
188
+ market = Market(
189
+ market_id=m.get("id", ""),
190
+ condition_id=m.get("conditionId", ""),
191
+ question=m.get("question", ""),
192
+ tokens=tokens,
193
+ volume=float(m.get("volumeNum", m.get("volume", 0))),
194
+ active=m.get("active", True),
195
+ closed=m.get("closed", False),
196
+ end_date=m.get("endDate", ""),
197
+ tags=[t.get("label", t) if isinstance(t, dict) else str(t) for t in (m.get("events", [{}])[0].get("tags", []) if m.get("events") else [])],
198
+ neg_risk=m.get("negRisk", False),
199
+ neg_risk_market_id=m.get("negRiskRequestID", ""),
200
+ last_updated=time.time(),
201
+ )
202
+ all_markets.append(market)
203
+ except Exception as e:
204
+ logger.warning(f"Failed to parse market: {e}")
205
+ continue
206
+
207
+ offset += batch_size
208
+ if len(raw) < batch_size:
209
+ break
210
+
211
+ logger.info(f"Fetched {len(all_markets)} active markets from Gamma API")
212
+ return all_markets
213
+
214
+ async def get_events(self) -> list[dict]:
215
+ """Récupère les événements (groupes de marchés)."""
216
+ session = await self._get_session()
217
+ try:
218
+ async with session.get(f"{self.base_url}/events", params={"active": "true"}) as resp:
219
+ if resp.status == 200:
220
+ return await resp.json()
221
+ return []
222
+ except Exception as e:
223
+ logger.error(f"Failed to get events: {e}")
224
+ return []
225
+
226
+
227
+ # ══════════════════════════════════════════════════════════════════
228
+ # CLOB API (Order Book Data)
229
+ # ══════════════════════════════════════════════════════════════════
230
+ class CLOBDataClient:
231
+ """Client pour les donnΓ©es du CLOB (carnet d'ordres)."""
232
+
233
+ def __init__(self, base_url: str = CLOB_API_URL):
234
+ self.base_url = base_url
235
+ self._session: Optional[aiohttp.ClientSession] = None
236
+
237
+ async def _get_session(self) -> aiohttp.ClientSession:
238
+ if self._session is None or self._session.closed:
239
+ self._session = aiohttp.ClientSession()
240
+ return self._session
241
+
242
+ async def close(self):
243
+ if self._session and not self._session.closed:
244
+ await self._session.close()
245
+
246
+ async def get_order_book(self, token_id: str) -> Optional[OrderBook]:
247
+ """Récupère le carnet d'ordres pour un token."""
248
+ session = await self._get_session()
249
+ try:
250
+ async with session.get(f"{self.base_url}/book", params={"token_id": token_id}) as resp:
251
+ if resp.status == 200:
252
+ data = await resp.json()
253
+ bids = [OrderBookLevel(float(b["price"]), float(b["size"]))
254
+ for b in data.get("bids", [])]
255
+ asks = [OrderBookLevel(float(a["price"]), float(a["size"]))
256
+ for a in data.get("asks", [])]
257
+ # Trier: bids dΓ©croissant, asks croissant
258
+ bids.sort(key=lambda x: x.price, reverse=True)
259
+ asks.sort(key=lambda x: x.price)
260
+ return OrderBook(token_id=token_id, bids=bids, asks=asks, timestamp=time.time())
261
+ else:
262
+ logger.warning(f"Order book fetch failed for {token_id}: {resp.status}")
263
+ return None
264
+ except Exception as e:
265
+ logger.error(f"Order book request failed: {e}")
266
+ return None
267
+
268
+ async def get_midpoint(self, token_id: str) -> Optional[float]:
269
+ """Récupère le prix médian pour un token."""
270
+ session = await self._get_session()
271
+ try:
272
+ async with session.get(f"{self.base_url}/midpoint", params={"token_id": token_id}) as resp:
273
+ if resp.status == 200:
274
+ data = await resp.json()
275
+ return float(data.get("mid", 0))
276
+ return None
277
+ except Exception as e:
278
+ logger.error(f"Midpoint request failed: {e}")
279
+ return None
280
+
281
+ async def get_price(self, token_id: str, side: str = "BUY") -> Optional[float]:
282
+ """Récupère le prix pour un côté (BUY/SELL)."""
283
+ session = await self._get_session()
284
+ try:
285
+ params = {"token_id": token_id, "side": side}
286
+ async with session.get(f"{self.base_url}/price", params=params) as resp:
287
+ if resp.status == 200:
288
+ data = await resp.json()
289
+ return float(data.get("price", 0))
290
+ return None
291
+ except Exception as e:
292
+ logger.error(f"Price request failed: {e}")
293
+ return None
294
+
295
+
296
+ # ══════════════════════════════════════════════════════════════════
297
+ # WEBSOCKET REAL-TIME FEED
298
+ # ══════════════════════════════════════════════════════════════════
299
+ class WebSocketFeed:
300
+ """Flux WebSocket temps rΓ©el pour les carnets d'ordres."""
301
+
302
+ def __init__(self, url: str = WS_URL):
303
+ self.url = url
304
+ self._callbacks: dict = {} # token_id -> [callback]
305
+ self._running = False
306
+ self._ws = None
307
+
308
+ def subscribe(self, token_id: str, callback):
309
+ """Enregistre un callback pour les mises Γ  jour d'un token."""
310
+ if token_id not in self._callbacks:
311
+ self._callbacks[token_id] = []
312
+ self._callbacks[token_id].append(callback)
313
+
314
+ async def start(self, token_ids: list[str]):
315
+ """DΓ©marre le flux WebSocket."""
316
+ import websockets
317
+
318
+ self._running = True
319
+ while self._running:
320
+ try:
321
+ async with websockets.connect(self.url) as ws:
322
+ self._ws = ws
323
+ # Subscribe to all markets
324
+ subscribe_msg = {
325
+ "auth": {},
326
+ "markets": [{"asset_id": tid, "type": "book"} for tid in token_ids]
327
+ }
328
+ await ws.send(json.dumps(subscribe_msg))
329
+ logger.info(f"WebSocket connected, subscribed to {len(token_ids)} tokens")
330
+
331
+ async for msg in ws:
332
+ try:
333
+ data = json.loads(msg)
334
+ asset_id = data.get("asset_id", "")
335
+ if asset_id in self._callbacks:
336
+ for cb in self._callbacks[asset_id]:
337
+ await cb(data)
338
+ except json.JSONDecodeError:
339
+ continue
340
+
341
+ except Exception as e:
342
+ logger.error(f"WebSocket error: {e}, reconnecting in 5s...")
343
+ await asyncio.sleep(5)
344
+
345
+ async def stop(self):
346
+ """ArrΓͺte le flux WebSocket."""
347
+ self._running = False
348
+ if self._ws:
349
+ await self._ws.close()