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

Add execution engine

Browse files
Files changed (1) hide show
  1. polymarket_bot/execution.py +398 -0
polymarket_bot/execution.py ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module d'exΓ©cution: placement d'ordres, gestion des positions.
3
+ Supporte le mode dry-run (paper trading) et le mode live.
4
+ """
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import Optional
10
+
11
+ from .config import BotConfig, POLYGON_CHAIN_ID, CLOB_API_URL
12
+
13
+ logger = logging.getLogger("polybot.execution")
14
+
15
+
16
+ # ══════════════════════════════════════════════════════════════════
17
+ # DATA MODELS
18
+ # ══════════════════════════════════════════════════════════════════
19
+ @dataclass
20
+ class Position:
21
+ market_id: str
22
+ token_id: str
23
+ outcome: str # "Yes" / "No"
24
+ side: str # "BUY"
25
+ entry_price: float
26
+ size: float # Nombre de shares
27
+ cost_basis: float # CoΓ»t total en USD
28
+ timestamp: float = 0.0
29
+ strategy: str = ""
30
+ pnl: float = 0.0
31
+
32
+ @property
33
+ def current_value(self) -> float:
34
+ return self.size * self.entry_price
35
+
36
+ def unrealized_pnl(self, current_price: float) -> float:
37
+ return self.size * (current_price - self.entry_price)
38
+
39
+
40
+ @dataclass
41
+ class Trade:
42
+ trade_id: str
43
+ market_id: str
44
+ token_id: str
45
+ outcome: str
46
+ side: str
47
+ price: float
48
+ size: float
49
+ cost: float
50
+ timestamp: float
51
+ strategy: str
52
+ status: str = "filled" # filled, cancelled, rejected
53
+
54
+
55
+ @dataclass
56
+ class PortfolioState:
57
+ balance_usd: float = 10000.0 # Capital initial simulΓ©
58
+ positions: dict = field(default_factory=dict) # token_id -> Position
59
+ trades: list = field(default_factory=list)
60
+ total_pnl: float = 0.0
61
+ daily_pnl: float = 0.0
62
+ daily_pnl_reset_time: float = 0.0
63
+ peak_balance: float = 10000.0
64
+ max_drawdown: float = 0.0
65
+
66
+ @property
67
+ def total_exposure(self) -> float:
68
+ return sum(p.cost_basis for p in self.positions.values())
69
+
70
+ @property
71
+ def available_capital(self) -> float:
72
+ return self.balance_usd - self.total_exposure
73
+
74
+ @property
75
+ def num_positions(self) -> int:
76
+ return len(self.positions)
77
+
78
+ def update_drawdown(self):
79
+ current_total = self.balance_usd + sum(
80
+ p.unrealized_pnl(p.entry_price) for p in self.positions.values()
81
+ )
82
+ if current_total > self.peak_balance:
83
+ self.peak_balance = current_total
84
+ dd = (self.peak_balance - current_total) / self.peak_balance
85
+ if dd > self.max_drawdown:
86
+ self.max_drawdown = dd
87
+
88
+
89
+ # ══════════════════════════════════════════════════════════════════
90
+ # EXECUTION ENGINE
91
+ # ══════════════════════════════════════════════════════════════════
92
+ class ExecutionEngine:
93
+ """
94
+ Moteur d'exΓ©cution des ordres.
95
+ Supporte dry-run (simulation) et live (via py-clob-client).
96
+ """
97
+
98
+ def __init__(self, config: BotConfig):
99
+ self.config = config
100
+ self.portfolio = PortfolioState()
101
+ self._clob_client = None
102
+ self._trade_counter = 0
103
+
104
+ def _init_clob_client(self):
105
+ """Initialise le client CLOB pour le mode live."""
106
+ if self.config.dry_run:
107
+ return
108
+
109
+ try:
110
+ from py_clob_client.client import ClobClient
111
+ from py_clob_client.clob_types import ApiCreds
112
+
113
+ # Step 1: Create client with wallet
114
+ client = ClobClient(
115
+ CLOB_API_URL,
116
+ key=self.config.private_key,
117
+ chain_id=POLYGON_CHAIN_ID,
118
+ )
119
+
120
+ # Step 2: Derive API credentials
121
+ api_key_data = client.create_or_derive_api_key()
122
+ creds = ApiCreds(
123
+ api_key=api_key_data["apiKey"],
124
+ api_secret=api_key_data["secret"],
125
+ api_passphrase=api_key_data["passphrase"],
126
+ )
127
+
128
+ # Step 3: Reinit client with full auth
129
+ self._clob_client = ClobClient(
130
+ CLOB_API_URL,
131
+ key=self.config.private_key,
132
+ chain_id=POLYGON_CHAIN_ID,
133
+ creds=creds,
134
+ )
135
+ logger.info("CLOB client initialized successfully (LIVE mode)")
136
+
137
+ except Exception as e:
138
+ logger.error(f"Failed to init CLOB client: {e}")
139
+ logger.warning("Falling back to dry-run mode")
140
+ self.config.dry_run = True
141
+
142
+ # ── Risk Checks ──────────────────────────────────────────────
143
+ def _check_risk(self, cost_usd: float, market_id: str) -> tuple[bool, str]:
144
+ """VΓ©rifie les contraintes de risque avant un trade."""
145
+
146
+ # Check: capital disponible
147
+ if cost_usd > self.portfolio.available_capital:
148
+ return False, f"Insufficient capital: need ${cost_usd:.2f}, have ${self.portfolio.available_capital:.2f}"
149
+
150
+ # Check: exposition totale
151
+ if self.portfolio.total_exposure + cost_usd > self.config.max_total_exposure_usd:
152
+ return False, f"Max total exposure reached: ${self.config.max_total_exposure_usd:.2f}"
153
+
154
+ # Check: exposition par marchΓ©
155
+ market_exposure = sum(
156
+ p.cost_basis for p in self.portfolio.positions.values()
157
+ if p.market_id == market_id
158
+ )
159
+ max_market = self.config.max_total_exposure_usd * self.config.max_single_market_exposure_pct
160
+ if market_exposure + cost_usd > max_market:
161
+ return False, f"Max market exposure reached: ${max_market:.2f}"
162
+
163
+ # Check: nombre de positions
164
+ if self.portfolio.num_positions >= self.config.max_concurrent_positions:
165
+ return False, f"Max concurrent positions reached: {self.config.max_concurrent_positions}"
166
+
167
+ # Check: perte journalière
168
+ if self.portfolio.daily_pnl < -self.config.max_daily_loss_usd:
169
+ return False, f"Daily loss limit reached: ${self.config.max_daily_loss_usd:.2f}"
170
+
171
+ # Check: taille min/max
172
+ if cost_usd < self.config.arb_min_position_usd:
173
+ return False, f"Trade too small: ${cost_usd:.2f} < ${self.config.arb_min_position_usd:.2f}"
174
+
175
+ return True, "OK"
176
+
177
+ # ── Order Placement ──────────────────────────────────────────
178
+ async def place_order(
179
+ self,
180
+ token_id: str,
181
+ market_id: str,
182
+ outcome: str,
183
+ side: str,
184
+ price: float,
185
+ size: float,
186
+ strategy: str,
187
+ order_type: str = "GTC",
188
+ ) -> Optional[Trade]:
189
+ """
190
+ Place un ordre. Retourne un Trade si rΓ©ussi.
191
+
192
+ Args:
193
+ token_id: ID du token (YES ou NO)
194
+ market_id: ID du marchΓ©
195
+ outcome: "Yes" ou "No"
196
+ side: "BUY" ou "SELL"
197
+ price: Prix par share
198
+ size: Nombre de shares
199
+ strategy: Nom de la stratΓ©gie ("arbitrage", "value_bet", etc.)
200
+ order_type: "GTC" (Good Till Cancel), "FOK" (Fill or Kill)
201
+ """
202
+ cost = price * size
203
+
204
+ # Risk check
205
+ can_trade, reason = self._check_risk(cost, market_id)
206
+ if not can_trade:
207
+ logger.warning(f"Trade rejected by risk manager: {reason}")
208
+ return None
209
+
210
+ self._trade_counter += 1
211
+ trade_id = f"T{self._trade_counter:06d}"
212
+
213
+ if self.config.dry_run:
214
+ # ── DRY RUN: simulate fill ───────────────────────────
215
+ trade = Trade(
216
+ trade_id=trade_id,
217
+ market_id=market_id,
218
+ token_id=token_id,
219
+ outcome=outcome,
220
+ side=side,
221
+ price=price,
222
+ size=size,
223
+ cost=cost,
224
+ timestamp=time.time(),
225
+ strategy=strategy,
226
+ status="filled",
227
+ )
228
+
229
+ # Update portfolio
230
+ self.portfolio.balance_usd -= cost
231
+ self.portfolio.positions[token_id] = Position(
232
+ market_id=market_id,
233
+ token_id=token_id,
234
+ outcome=outcome,
235
+ side=side,
236
+ entry_price=price,
237
+ size=size,
238
+ cost_basis=cost,
239
+ timestamp=time.time(),
240
+ strategy=strategy,
241
+ )
242
+ self.portfolio.trades.append(trade)
243
+
244
+ logger.info(
245
+ f"[DRY RUN] {side} {size:.1f} {outcome} @ ${price:.4f} "
246
+ f"= ${cost:.2f} | Strategy: {strategy} | Market: {market_id[:16]}..."
247
+ )
248
+ return trade
249
+
250
+ else:
251
+ # ── LIVE MODE: place via CLOB ────────────────────────
252
+ try:
253
+ from py_clob_client.clob_types import OrderArgs, OrderType, BUY, SELL
254
+
255
+ if self._clob_client is None:
256
+ self._init_clob_client()
257
+
258
+ side_enum = BUY if side == "BUY" else SELL
259
+ otype = OrderType.GTC if order_type == "GTC" else OrderType.FOK
260
+
261
+ order = self._clob_client.create_order(OrderArgs(
262
+ token_id=token_id,
263
+ price=price,
264
+ size=size,
265
+ side=side_enum,
266
+ order_type=otype,
267
+ ))
268
+ resp = self._clob_client.post_order(order)
269
+
270
+ if resp and resp.get("success"):
271
+ trade = Trade(
272
+ trade_id=resp.get("orderID", trade_id),
273
+ market_id=market_id,
274
+ token_id=token_id,
275
+ outcome=outcome,
276
+ side=side,
277
+ price=price,
278
+ size=size,
279
+ cost=cost,
280
+ timestamp=time.time(),
281
+ strategy=strategy,
282
+ status="filled",
283
+ )
284
+ self.portfolio.balance_usd -= cost
285
+ self.portfolio.positions[token_id] = Position(
286
+ market_id=market_id,
287
+ token_id=token_id,
288
+ outcome=outcome,
289
+ side=side,
290
+ entry_price=price,
291
+ size=size,
292
+ cost_basis=cost,
293
+ timestamp=time.time(),
294
+ strategy=strategy,
295
+ )
296
+ self.portfolio.trades.append(trade)
297
+
298
+ logger.info(
299
+ f"[LIVE] {side} {size:.1f} {outcome} @ ${price:.4f} "
300
+ f"= ${cost:.2f} | Strategy: {strategy}"
301
+ )
302
+ return trade
303
+ else:
304
+ logger.error(f"Order placement failed: {resp}")
305
+ return None
306
+
307
+ except Exception as e:
308
+ logger.error(f"Order execution error: {e}")
309
+ return None
310
+
311
+ async def place_arb_pair(
312
+ self,
313
+ market_id: str,
314
+ yes_token_id: str,
315
+ no_token_id: str,
316
+ yes_price: float,
317
+ no_price: float,
318
+ size: float,
319
+ ) -> tuple[Optional[Trade], Optional[Trade]]:
320
+ """
321
+ Place une paire d'ordres d'arbitrage (BUY YES + BUY NO).
322
+ ExΓ©cute simultanΓ©ment pour minimiser le risque d'exΓ©cution partielle.
323
+ """
324
+ total_cost = (yes_price + no_price) * size
325
+ can_trade, reason = self._check_risk(total_cost, market_id)
326
+ if not can_trade:
327
+ logger.warning(f"Arb pair rejected: {reason}")
328
+ return None, None
329
+
330
+ # ExΓ©cution simultanΓ©e
331
+ yes_trade, no_trade = await asyncio.gather(
332
+ self.place_order(
333
+ yes_token_id, market_id, "Yes", "BUY",
334
+ yes_price, size, "arbitrage", "FOK"
335
+ ),
336
+ self.place_order(
337
+ no_token_id, market_id, "No", "BUY",
338
+ no_price, size, "arbitrage", "FOK"
339
+ ),
340
+ )
341
+
342
+ if yes_trade and no_trade:
343
+ profit_per_share = 1.0 - yes_price - no_price
344
+ total_profit = profit_per_share * size
345
+ logger.info(
346
+ f"βœ… ARB FILLED: {size:.1f} shares | "
347
+ f"YES@{yes_price:.4f} + NO@{no_price:.4f} = {yes_price+no_price:.4f} | "
348
+ f"Expected profit: ${total_profit:.2f} ({profit_per_share*100:.1f}%)"
349
+ )
350
+ elif yes_trade or no_trade:
351
+ logger.warning("⚠️ PARTIAL ARB FILL - one leg failed!")
352
+
353
+ return yes_trade, no_trade
354
+
355
+ # ── Position Management ──────────────────────────────────────
356
+ def close_position(self, token_id: str, exit_price: float) -> Optional[float]:
357
+ """Ferme une position et calcule le PnL."""
358
+ if token_id not in self.portfolio.positions:
359
+ return None
360
+
361
+ pos = self.portfolio.positions[token_id]
362
+ pnl = pos.size * (exit_price - pos.entry_price)
363
+ pos.pnl = pnl
364
+ self.portfolio.total_pnl += pnl
365
+ self.portfolio.daily_pnl += pnl
366
+ self.portfolio.balance_usd += pos.size * exit_price
367
+
368
+ logger.info(
369
+ f"Position closed: {pos.outcome} | "
370
+ f"Entry: ${pos.entry_price:.4f} β†’ Exit: ${exit_price:.4f} | "
371
+ f"PnL: ${pnl:+.2f}"
372
+ )
373
+
374
+ del self.portfolio.positions[token_id]
375
+ self.portfolio.update_drawdown()
376
+ return pnl
377
+
378
+ def get_portfolio_summary(self) -> dict:
379
+ """RΓ©sumΓ© du portefeuille."""
380
+ return {
381
+ "balance_usd": round(self.portfolio.balance_usd, 2),
382
+ "total_exposure": round(self.portfolio.total_exposure, 2),
383
+ "available_capital": round(self.portfolio.available_capital, 2),
384
+ "num_positions": self.portfolio.num_positions,
385
+ "total_pnl": round(self.portfolio.total_pnl, 2),
386
+ "daily_pnl": round(self.portfolio.daily_pnl, 2),
387
+ "max_drawdown": f"{self.portfolio.max_drawdown*100:.2f}%",
388
+ "num_trades": len(self.portfolio.trades),
389
+ "win_rate": self._calculate_win_rate(),
390
+ }
391
+
392
+ def _calculate_win_rate(self) -> str:
393
+ closed_trades = [t for t in self.portfolio.trades if t.status == "filled"]
394
+ if not closed_trades:
395
+ return "N/A"
396
+ wins = sum(1 for t in closed_trades if t.strategy == "arbitrage")
397
+ total = len(closed_trades)
398
+ return f"{wins/total*100:.1f}%" if total > 0 else "N/A"