""" 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, field 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 taker fee used for paper simulation. # Also stored as commission in each Trade for audit purposes. # NOTE: this is a heuristic — see COMMISSION_RATE in bayesian.py for context. POLYMARKET_FEE = 0.02 # 2% @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 # ── Phase 1: edge neto audit fields ────────────────────────────────────── # edge_gross: raw model edge before any cost deductions # edge_net: edge_gross - spread_estimate - commission/size_usdc # Both are heuristic estimates — see schema.sql comment for details. edge_gross: float = 0.0 edge_net: float = 0.0 prior_prob: float = 0.0 # market.yes_price clamped, before Bayesian update final_prob: float = 0.0 # estimated probability after all signals # mid_price: order-book midpoint when available; falls back to market.yes_price mid_price: float = 0.0 spread_estimate: float = 0.02 commission: float = 0.0 # = POLYMARKET_FEE * size_usdc # ── Phase 2: market family ──────────────────────────────────────────────── family_key: str = "" 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}) " f"edge_net={self.edge_net:+.3f} family={self.family_key} " f"| {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 # commission mirrors the heuristic COMMISSION_RATE applied in bayesian.py # when computing edge_net. Stored for audit: confirms cost assumption held. commission = order.size_usdc * POLYMARKET_FEE # = fee_usdc at current rate 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, # Phase 1 audit fields edge_gross=order.edge_gross, edge_net=order.edge_net, prior_prob=order.prior_prob, final_prob=order.final_prob, mid_price=order.mid_price, spread_estimate=order.spread_estimate, commission=commission, # Phase 2 family family_key=order.family_key, ) # 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_legacy_position(self, market_id: str, reason: str) -> float: """ Close a paper position flagged by the legacy scan. Returns the capital recovered to cash (cost basis, assuming break-even exit — exact P&L would require the live exit price which isn't available at scan time). """ cost = self._portfolio.positions.pop(market_id, 0.0) self._portfolio.cash += cost # return capital at break-even await self._db.close_paper_position(market_id, reason) log.warning( "LEGACY_CLOSE market=%s | returned $%.2f to cash | %s", market_id, cost, reason[:80], ) return cost 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. Persists resolution and close_pnl to DB (computed via SQL from stored entry_price and shares). Returns approximate P&L for logging. """ if market_id not in self._portfolio.positions: return None position_cost = self._portfolio.positions.pop(market_id) self._portfolio.cash += position_cost * resolution # pay out winnings await self._db.close_paper_position( market_id, reason=f"market_resolved resolution={resolution:.1f}", resolution=resolution, ) log.info("Closed position in %s, resolution=%.1f", market_id, resolution) # Approximate PnL: settlement value minus cost. Exact value is in close_pnl. return position_cost * resolution - position_cost