""" Paper Trading Executor — simulates order execution without real money. Simulates realistic slippage and fees to get accurate paper P&L. All trades are logged to PostgreSQL for metrics analysis. """ import logging import uuid from dataclasses import dataclass from datetime import datetime, UTC from typing import Optional from bot.risk.manager import Order, Portfolio from bot.data.db import Database log = logging.getLogger(__name__) POLYMARKET_FEE = 0.02 # 2% fee on each trade @dataclass class Trade: id: str market_id: str question: str direction: str size_usdc: float entry_price: float shares: float # How many YES/NO shares bought fee_usdc: float net_cost: float timestamp: datetime reasoning: str paper: bool = True def __str__(self) -> str: return ( f"[PAPER] {self.direction} {self.shares:.1f} shares @ {self.entry_price:.3f} " f"= ${self.net_cost:.2f} (fee ${self.fee_usdc:.2f}) | {self.question[:40]}" ) class PaperExecutor: """Executes trades on paper — no real money, realistic simulation.""" def __init__(self, db: Database, bankroll: float) -> None: self._db = db self._portfolio = Portfolio( cash=bankroll, positions={}, ) log.info("Paper executor initialized with $%.2f bankroll", bankroll) async def initialize(self) -> None: """Reconcile in-memory portfolio with DB state. Called once after __init__ so the executor reflects any trades that survived a pod restart. After a TRUNCATE the DB is empty and the portfolio resets to a full bankroll automatically. """ positions = await self._db.get_open_positions() if not positions: log.info("No open positions in DB — starting with full bankroll") return total_deployed = sum(positions.values()) self._portfolio.positions = positions self._portfolio.cash = max(0.0, self._portfolio.cash - total_deployed) log.info( "Restored %d open position(s) from DB — deployed $%.2f, cash $%.2f", len(positions), total_deployed, self._portfolio.cash, ) def get_portfolio(self) -> Portfolio: return self._portfolio async def execute(self, order: Order) -> Optional[Trade]: """Simulate order execution with fees and slippage.""" if order.size_usdc > self._portfolio.cash: log.warning( "Insufficient paper cash: need $%.2f have $%.2f", order.size_usdc, self._portfolio.cash, ) return None # Simulate small slippage (0.1-0.3% depending on order size) slippage = min(0.003, order.size_usdc / 100000) # Determine entry price based on direction. # We fill at the current Polymarket mid price + slippage (buying at ask). # BUY_YES → paying the YES price (order.market_price) # BUY_NO → paying the NO price (1 - order.market_price) if order.direction == "BUY_YES": entry_price = min(0.99, order.market_price + slippage) else: entry_price = min(0.99, (1 - order.market_price) + slippage) fee = order.size_usdc * POLYMARKET_FEE net_cost = order.size_usdc + fee shares = order.size_usdc / entry_price trade = Trade( id=str(uuid.uuid4()), market_id=order.market_id, question=order.question, direction=order.direction, size_usdc=order.size_usdc, entry_price=entry_price, shares=shares, fee_usdc=fee, net_cost=net_cost, timestamp=datetime.now(UTC), reasoning=order.reasoning, paper=True, ) # Update paper portfolio self._portfolio.cash -= net_cost self._portfolio.positions[order.market_id] = order.size_usdc # Persist to DB await self._db.save_trade(trade) return trade async def close_position(self, market_id: str, resolution: float) -> Optional[float]: """ Close a paper position after market resolution. resolution: 1.0 if YES won, 0.0 if NO won. Returns P&L in USDC. """ if market_id not in self._portfolio.positions: return None # This would be called by a settlement watcher (future feature) # For now, positions auto-expire at market end date position_cost = self._portfolio.positions.pop(market_id) log.info("Closed position in %s, resolution=%.0f", market_id, resolution) return position_cost * resolution - position_cost