doulfa commited on
Commit
77f0b05
·
verified ·
1 Parent(s): c90fc0d

Add strategies module

Browse files
Files changed (1) hide show
  1. polymarket_bot/strategies.py +539 -0
polymarket_bot/strategies.py ADDED
@@ -0,0 +1,539 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stratégies de trading pour Polymarket.
3
+ 3 stratégies basées sur la recherche académique:
4
+ 1. Arbitrage intra-marché (YES+NO < $1)
5
+ 2. Value Bet assisté par LLM
6
+ 3. Leader-Follower sémantique
7
+ """
8
+ import asyncio
9
+ import logging
10
+ import math
11
+ import time
12
+ from abc import ABC, abstractmethod
13
+ from dataclasses import dataclass
14
+ from typing import Optional
15
+
16
+ from .config import BotConfig
17
+ from .data import Market, OrderBook, CLOBDataClient, GammaClient
18
+ from .execution import ExecutionEngine, Trade
19
+
20
+ logger = logging.getLogger("polybot.strategies")
21
+
22
+
23
+ # ══════════════════════════════════════════════════════════════════
24
+ # UTILS
25
+ # ══════════════════════════════════════════════════════════════════
26
+ def kelly_fraction(p_true: float, p_market: float, kelly_mult: float = 0.25) -> float:
27
+ """
28
+ Calcule la fraction Kelly pour un marché binaire.
29
+ p_true: probabilité estimée réelle
30
+ p_market: prix du marché (probabilité implicite)
31
+ kelly_mult: multiplicateur Kelly (0.25 = quart Kelly, conservateur)
32
+ """
33
+ if p_market <= 0 or p_market >= 1 or p_true <= 0 or p_true >= 1:
34
+ return 0.0
35
+
36
+ b = (1.0 - p_market) / p_market # cote nette
37
+ q = 1.0 - p_true
38
+ kelly = (b * p_true - q) / b
39
+
40
+ return max(0.0, min(kelly * kelly_mult, 0.20)) # Cap à 20% du bankroll
41
+
42
+
43
+ @dataclass
44
+ class Signal:
45
+ """Signal de trading généré par une stratégie."""
46
+ market_id: str
47
+ strategy: str
48
+ action: str # "BUY_YES", "BUY_NO", "ARB_LONG", "ARB_SHORT"
49
+ confidence: float # 0-1
50
+ expected_profit: float
51
+ size_usd: float
52
+ metadata: dict = None
53
+
54
+ def __post_init__(self):
55
+ if self.metadata is None:
56
+ self.metadata = {}
57
+
58
+
59
+ # ══════════════════════════════════════════════════════════════════
60
+ # BASE STRATEGY
61
+ # ══════════════════════════════════════════════════════════════════
62
+ class BaseStrategy(ABC):
63
+ """Classe de base pour toutes les stratégies."""
64
+
65
+ def __init__(self, config: BotConfig, clob_client: CLOBDataClient):
66
+ self.config = config
67
+ self.clob = clob_client
68
+
69
+ @abstractmethod
70
+ async def scan(self, markets: list[Market]) -> list[Signal]:
71
+ """Scanne les marchés et retourne des signaux."""
72
+ pass
73
+
74
+ @abstractmethod
75
+ async def execute(self, signal: Signal, engine: ExecutionEngine) -> Optional[Trade]:
76
+ """Exécute un signal."""
77
+ pass
78
+
79
+
80
+ # ══════════════════════════════════════════════════════════════════
81
+ # STRATÉGIE 1: ARBITRAGE INTRA-MARCHÉ
82
+ # ══════════════════════════════════════════════════════════════════
83
+ class ArbitrageStrategy(BaseStrategy):
84
+ """
85
+ Arbitrage sans risque: acheter YES + NO quand la somme < $1.
86
+ Basé sur arxiv:2508.03474 — $40M de profit réalisé sur Polymarket.
87
+
88
+ Logique:
89
+ - Détecter quand best_ask(YES) + best_ask(NO) < 1.0 - min_spread
90
+ - Acheter les deux côtés simultanément
91
+ - Profit garanti à la résolution ($1 par paire)
92
+ """
93
+
94
+ async def scan(self, markets: list[Market]) -> list[Signal]:
95
+ signals = []
96
+
97
+ for market in markets:
98
+ yes = market.yes_token
99
+ no = market.no_token
100
+
101
+ if not yes or not no:
102
+ continue
103
+
104
+ # Filtre: ignorer les marchés quasi-résolus
105
+ if yes.price > self.config.arb_max_price_filter or \
106
+ no.price > self.config.arb_max_price_filter:
107
+ continue
108
+
109
+ # Récupérer les carnets d'ordres
110
+ try:
111
+ yes_book, no_book = await asyncio.gather(
112
+ self.clob.get_order_book(yes.token_id),
113
+ self.clob.get_order_book(no.token_id),
114
+ )
115
+ except Exception as e:
116
+ logger.debug(f"Failed to get order books for {market.market_id}: {e}")
117
+ continue
118
+
119
+ if not yes_book or not no_book:
120
+ continue
121
+ if not yes_book.asks or not no_book.asks:
122
+ continue
123
+
124
+ best_yes_ask = yes_book.best_ask
125
+ best_no_ask = no_book.best_ask
126
+
127
+ if best_yes_ask is None or best_no_ask is None:
128
+ continue
129
+
130
+ combined = best_yes_ask + best_no_ask
131
+ spread = 1.0 - combined
132
+
133
+ # LONG ARBITRAGE: YES + NO < $1
134
+ if spread > self.config.arb_min_spread:
135
+ # Calculer la taille maximale (limitée par la liquidité)
136
+ max_yes_size = sum(l.size for l in yes_book.asks[:3])
137
+ max_no_size = sum(l.size for l in no_book.asks[:3])
138
+ max_size = min(max_yes_size, max_no_size)
139
+
140
+ # Limiter par capital
141
+ cost_per_share = combined
142
+ max_affordable = self.config.arb_max_position_usd / cost_per_share
143
+ size = min(max_size, max_affordable)
144
+
145
+ if size * spread > 1.0: # Min $1 de profit
146
+ expected_profit = size * spread
147
+ signals.append(Signal(
148
+ market_id=market.market_id,
149
+ strategy="arbitrage",
150
+ action="ARB_LONG",
151
+ confidence=min(spread / 0.10, 1.0),
152
+ expected_profit=expected_profit,
153
+ size_usd=size * cost_per_share,
154
+ metadata={
155
+ "yes_ask": best_yes_ask,
156
+ "no_ask": best_no_ask,
157
+ "combined": combined,
158
+ "spread": spread,
159
+ "size_shares": size,
160
+ "yes_token_id": yes.token_id,
161
+ "no_token_id": no.token_id,
162
+ "yes_depth": yes_book.total_ask_depth,
163
+ "no_depth": no_book.total_ask_depth,
164
+ "question": market.question,
165
+ }
166
+ ))
167
+
168
+ # SHORT ARBITRAGE: YES + NO > $1 (sell both)
169
+ if yes_book.bids and no_book.bids:
170
+ best_yes_bid = yes_book.best_bid
171
+ best_no_bid = no_book.best_bid
172
+ if best_yes_bid and best_no_bid:
173
+ combined_bid = best_yes_bid + best_no_bid
174
+ if combined_bid > 1.0 + self.config.arb_min_spread:
175
+ short_spread = combined_bid - 1.0
176
+ max_size = min(
177
+ sum(l.size for l in yes_book.bids[:3]),
178
+ sum(l.size for l in no_book.bids[:3]),
179
+ )
180
+ if max_size * short_spread > 1.0:
181
+ signals.append(Signal(
182
+ market_id=market.market_id,
183
+ strategy="arbitrage",
184
+ action="ARB_SHORT",
185
+ confidence=min(short_spread / 0.10, 1.0),
186
+ expected_profit=max_size * short_spread,
187
+ size_usd=max_size * combined_bid,
188
+ metadata={
189
+ "yes_bid": best_yes_bid,
190
+ "no_bid": best_no_bid,
191
+ "combined": combined_bid,
192
+ "spread": short_spread,
193
+ "size_shares": max_size,
194
+ "yes_token_id": yes.token_id,
195
+ "no_token_id": no.token_id,
196
+ "question": market.question,
197
+ }
198
+ ))
199
+
200
+ if signals:
201
+ logger.info(f"🔍 Arbitrage scan: {len(signals)} opportunities found")
202
+ return signals
203
+
204
+ async def execute(self, signal: Signal, engine: ExecutionEngine) -> Optional[Trade]:
205
+ meta = signal.metadata
206
+ if signal.action == "ARB_LONG":
207
+ yes_trade, no_trade = await engine.place_arb_pair(
208
+ market_id=signal.market_id,
209
+ yes_token_id=meta["yes_token_id"],
210
+ no_token_id=meta["no_token_id"],
211
+ yes_price=meta["yes_ask"],
212
+ no_price=meta["no_ask"],
213
+ size=meta["size_shares"],
214
+ )
215
+ return yes_trade
216
+ return None
217
+
218
+
219
+ # ══════════════════════════════════════════════════════════════════
220
+ # STRATÉGIE 2: VALUE BET
221
+ # ══════════════════════════════════════════════════════════════════
222
+ class ValueBetStrategy(BaseStrategy):
223
+ """
224
+ Value Bet: identifier les marchés où le prix ne reflète pas la vraie probabilité.
225
+ Basé sur arxiv:2604.14199 (PolyBench) — meilleurs résultats avec news-catalyst.
226
+
227
+ Utilise des heuristiques + optionnellement un LLM pour estimer la vraie probabilité.
228
+ Sans LLM, utilise des indicateurs de marché (volume, momentum, convergence).
229
+ """
230
+
231
+ def __init__(self, config: BotConfig, clob_client: CLOBDataClient):
232
+ super().__init__(config, clob_client)
233
+ self._price_history: dict = {} # token_id -> [prices]
234
+
235
+ def _update_price_history(self, token_id: str, price: float):
236
+ if token_id not in self._price_history:
237
+ self._price_history[token_id] = []
238
+ self._price_history[token_id].append((time.time(), price))
239
+ # Garder 1h d'historique max
240
+ cutoff = time.time() - 3600
241
+ self._price_history[token_id] = [
242
+ (t, p) for t, p in self._price_history[token_id] if t > cutoff
243
+ ]
244
+
245
+ def _calculate_momentum(self, token_id: str) -> Optional[float]:
246
+ """Calcule le momentum du prix (variation sur les N dernières minutes)."""
247
+ history = self._price_history.get(token_id, [])
248
+ if len(history) < 5:
249
+ return None
250
+
251
+ recent = [p for _, p in history[-5:]]
252
+ older = [p for _, p in history[:5]]
253
+ return sum(recent) / len(recent) - sum(older) / len(older)
254
+
255
+ def _calculate_volume_signal(self, market: Market) -> float:
256
+ """Signal basé sur le volume (marchés à fort volume = plus informatifs)."""
257
+ if market.volume > 100000:
258
+ return 0.8
259
+ elif market.volume > 50000:
260
+ return 0.6
261
+ elif market.volume > 10000:
262
+ return 0.4
263
+ return 0.2
264
+
265
+ def _estimate_edge(self, market: Market, book: OrderBook) -> tuple[float, str]:
266
+ """
267
+ Estime l'edge (avantage) sur le marché.
268
+ Retourne (edge, direction) où direction = "YES" ou "NO".
269
+
270
+ Heuristiques:
271
+ 1. Bid-ask asymétrie: si le bid depth >> ask depth, pression acheteuse
272
+ 2. Momentum: tendance récente du prix
273
+ 3. Convergence vers 0 ou 1: les marchés proches de la résolution convergent
274
+ """
275
+ yes = market.yes_token
276
+ if not yes or not book:
277
+ return 0.0, ""
278
+
279
+ self._update_price_history(yes.token_id, yes.price)
280
+
281
+ # Asymétrie du carnet
282
+ bid_depth = book.total_bid_depth
283
+ ask_depth = book.total_ask_depth
284
+ if bid_depth + ask_depth > 0:
285
+ depth_imbalance = (bid_depth - ask_depth) / (bid_depth + ask_depth)
286
+ else:
287
+ depth_imbalance = 0.0
288
+
289
+ # Momentum
290
+ momentum = self._calculate_momentum(yes.token_id) or 0.0
291
+
292
+ # Score composite
293
+ score = 0.3 * depth_imbalance + 0.5 * momentum * 10 + 0.2 * self._calculate_volume_signal(market)
294
+
295
+ if score > self.config.value_bet_min_edge:
296
+ return abs(score), "YES"
297
+ elif score < -self.config.value_bet_min_edge:
298
+ return abs(score), "NO"
299
+ return 0.0, ""
300
+
301
+ async def scan(self, markets: list[Market]) -> list[Signal]:
302
+ signals = []
303
+
304
+ for market in markets:
305
+ yes = market.yes_token
306
+ no = market.no_token
307
+ if not yes or not no:
308
+ continue
309
+
310
+ # Ignorer les marchés presque résolus
311
+ if yes.price > 0.90 or yes.price < 0.10:
312
+ continue
313
+
314
+ try:
315
+ yes_book = await self.clob.get_order_book(yes.token_id)
316
+ except Exception:
317
+ continue
318
+
319
+ if not yes_book:
320
+ continue
321
+
322
+ edge, direction = self._estimate_edge(market, yes_book)
323
+
324
+ if edge > self.config.value_bet_min_edge:
325
+ # Kelly sizing
326
+ p_true = yes.price + edge if direction == "YES" else yes.price - edge
327
+ p_true = max(0.05, min(0.95, p_true))
328
+ p_market = yes.price if direction == "YES" else (1 - yes.price)
329
+
330
+ frac = kelly_fraction(p_true, p_market, self.config.kelly_fraction)
331
+ if frac > 0:
332
+ size_usd = min(
333
+ frac * self.config.max_total_exposure_usd,
334
+ self.config.value_bet_max_position_usd,
335
+ )
336
+
337
+ token = yes if direction == "YES" else no
338
+ price = yes_book.best_ask if direction == "YES" else (1 - yes.price)
339
+
340
+ signals.append(Signal(
341
+ market_id=market.market_id,
342
+ strategy="value_bet",
343
+ action=f"BUY_{direction}",
344
+ confidence=min(edge * 5, 1.0),
345
+ expected_profit=size_usd * edge,
346
+ size_usd=size_usd,
347
+ metadata={
348
+ "token_id": token.token_id,
349
+ "outcome": direction,
350
+ "price": price,
351
+ "edge": edge,
352
+ "kelly_frac": frac,
353
+ "p_true": p_true,
354
+ "p_market": p_market,
355
+ "question": market.question,
356
+ }
357
+ ))
358
+
359
+ if signals:
360
+ logger.info(f"💡 Value Bet scan: {len(signals)} signals found")
361
+ return signals
362
+
363
+ async def execute(self, signal: Signal, engine: ExecutionEngine) -> Optional[Trade]:
364
+ meta = signal.metadata
365
+ size_shares = signal.size_usd / meta["price"] if meta["price"] > 0 else 0
366
+ if size_shares <= 0:
367
+ return None
368
+
369
+ return await engine.place_order(
370
+ token_id=meta["token_id"],
371
+ market_id=signal.market_id,
372
+ outcome=meta["outcome"],
373
+ side="BUY",
374
+ price=meta["price"],
375
+ size=size_shares,
376
+ strategy="value_bet",
377
+ order_type="GTC",
378
+ )
379
+
380
+
381
+ # ══════════════════════════════════════════════════════════════════
382
+ # STRATÉGIE 3: LEADER-FOLLOWER SÉMANTIQUE
383
+ # ══════════════════════════════════════════════════════════════════
384
+ class LeaderFollowerStrategy(BaseStrategy):
385
+ """
386
+ Stratégie Leader-Follower basée sur la similarité sémantique des marchés.
387
+ Basé sur arxiv:2512.02436 — 47.5% ROI mensuel (Juin 2025).
388
+
389
+ Logique:
390
+ 1. Embedder les questions des marchés
391
+ 2. Identifier les clusters de marchés corrélés
392
+ 3. Quand un "leader" bouge fortement, trader le "follower"
393
+ """
394
+
395
+ def __init__(self, config: BotConfig, clob_client: CLOBDataClient):
396
+ super().__init__(config, clob_client)
397
+ self._embeddings: dict = {}
398
+ self._clusters: dict = {}
399
+ self._model = None
400
+ self._price_snapshots: dict = {} # market_id -> (timestamp, yes_price)
401
+
402
+ def _ensure_model(self):
403
+ """Charge le modèle d'embedding si nécessaire."""
404
+ if self._model is None:
405
+ try:
406
+ from sentence_transformers import SentenceTransformer
407
+ self._model = SentenceTransformer("all-MiniLM-L6-v2")
408
+ logger.info("Sentence transformer loaded for Leader-Follower")
409
+ except Exception as e:
410
+ logger.error(f"Failed to load sentence transformer: {e}")
411
+
412
+ def _compute_similarity(self, q1: str, q2: str) -> float:
413
+ """Calcule la similarité cosinus entre deux questions."""
414
+ self._ensure_model()
415
+ if not self._model:
416
+ return 0.0
417
+
418
+ import numpy as np
419
+ embs = self._model.encode([q1, q2])
420
+ cos_sim = np.dot(embs[0], embs[1]) / (np.linalg.norm(embs[0]) * np.linalg.norm(embs[1]))
421
+ return float(cos_sim)
422
+
423
+ def _find_correlated_pairs(self, markets: list[Market]) -> list[tuple[Market, Market, float]]:
424
+ """Trouve les paires de marchés sémantiquement corrélés."""
425
+ self._ensure_model()
426
+ if not self._model:
427
+ return []
428
+
429
+ import numpy as np
430
+
431
+ questions = [m.question for m in markets]
432
+ if len(questions) < 2:
433
+ return []
434
+
435
+ embeddings = self._model.encode(questions)
436
+
437
+ # Matrice de similarité
438
+ norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
439
+ normalized = embeddings / (norms + 1e-8)
440
+ sim_matrix = normalized @ normalized.T
441
+
442
+ pairs = []
443
+ for i in range(len(markets)):
444
+ for j in range(i + 1, len(markets)):
445
+ sim = float(sim_matrix[i, j])
446
+ if sim > self.config.lf_similarity_threshold:
447
+ pairs.append((markets[i], markets[j], sim))
448
+
449
+ pairs.sort(key=lambda x: x[2], reverse=True)
450
+ return pairs[:20] # Top 20 paires
451
+
452
+ async def scan(self, markets: list[Market]) -> list[Signal]:
453
+ signals = []
454
+
455
+ # Trouver les paires corrélées
456
+ pairs = self._find_correlated_pairs(markets)
457
+
458
+ for leader, follower, similarity in pairs:
459
+ leader_yes = leader.yes_token
460
+ follower_yes = follower.yes_token
461
+ follower_no = follower.no_token
462
+
463
+ if not leader_yes or not follower_yes or not follower_no:
464
+ continue
465
+
466
+ # Vérifier si le leader a bougé significativement
467
+ prev = self._price_snapshots.get(leader.market_id)
468
+ current_price = leader_yes.price
469
+
470
+ if prev:
471
+ prev_time, prev_price = prev
472
+ price_change = current_price - prev_price
473
+ time_elapsed = time.time() - prev_time
474
+
475
+ # Signal si mouvement > 5% en moins de 10 min
476
+ if abs(price_change) > 0.05 and time_elapsed < 600:
477
+ # Le follower devrait bouger dans la même direction
478
+ direction = "YES" if price_change > 0 else "NO"
479
+ target_token = follower_yes if direction == "YES" else follower_no
480
+ target_price = follower_yes.price if direction == "YES" else (1 - follower_yes.price)
481
+
482
+ # Ne trader que si le follower n'a PAS encore bougé
483
+ follower_prev = self._price_snapshots.get(follower.market_id)
484
+ if follower_prev:
485
+ _, fp = follower_prev
486
+ follower_change = abs(follower_yes.price - fp)
487
+ if follower_change < abs(price_change) * 0.3: # Follower en retard
488
+ edge = abs(price_change) * similarity * 0.5
489
+ size_usd = min(
490
+ kelly_fraction(target_price + edge, target_price, self.config.kelly_fraction)
491
+ * self.config.max_total_exposure_usd,
492
+ self.config.lf_max_position_usd,
493
+ )
494
+
495
+ if size_usd > self.config.arb_min_position_usd:
496
+ signals.append(Signal(
497
+ market_id=follower.market_id,
498
+ strategy="leader_follower",
499
+ action=f"BUY_{direction}",
500
+ confidence=similarity * min(abs(price_change) * 10, 1.0),
501
+ expected_profit=size_usd * edge,
502
+ size_usd=size_usd,
503
+ metadata={
504
+ "token_id": target_token.token_id,
505
+ "outcome": direction,
506
+ "price": target_price,
507
+ "leader_question": leader.question,
508
+ "follower_question": follower.question,
509
+ "similarity": similarity,
510
+ "leader_move": price_change,
511
+ "edge": edge,
512
+ }
513
+ ))
514
+
515
+ # Update snapshot
516
+ self._price_snapshots[leader.market_id] = (time.time(), current_price)
517
+ if follower_yes:
518
+ self._price_snapshots[follower.market_id] = (time.time(), follower_yes.price)
519
+
520
+ if signals:
521
+ logger.info(f"🔗 Leader-Follower scan: {len(signals)} signals found")
522
+ return signals
523
+
524
+ async def execute(self, signal: Signal, engine: ExecutionEngine) -> Optional[Trade]:
525
+ meta = signal.metadata
526
+ size_shares = signal.size_usd / meta["price"] if meta["price"] > 0 else 0
527
+ if size_shares <= 0:
528
+ return None
529
+
530
+ return await engine.place_order(
531
+ token_id=meta["token_id"],
532
+ market_id=signal.market_id,
533
+ outcome=meta["outcome"],
534
+ side="BUY",
535
+ price=meta["price"],
536
+ size=size_shares,
537
+ strategy="leader_follower",
538
+ order_type="GTC",
539
+ )