Files
polymarket-bot/bot/executor/paper.py
T
chemavx 9a5be27532
CI/CD / build-and-push (push) Successful in 1m47s
feat(metrics): Fix 3 — DB-computed metrics, stateless tracker, resolution tracking
schema.sql
  trades:        + close_pnl, resolution (market outcome storage)
  metrics_daily: + unrealized_pnl_est, realized_pnl, open/closed/resolved_count

db.py
  close_paper_position(): accepts resolution; computes close_pnl in SQL
    BUY_YES: (resolution − entry_price) × shares
    BUY_NO:  ((1 − resolution) − entry_price) × shares
  save_daily_metrics(): persists new columns
  compute_metrics_from_db(): single DB query for all metrics; no in-memory state

tracker.py — complete rewrite (stateless)
  Removed self._trades, self._daily_returns, compute_metrics(), _compute_sharpe(),
  check_promotion_thresholds(), _empty_metrics()
  update_daily_summary() now reads compute_metrics_from_db() every cycle
  Safe across pod restarts: always reflects full DB history

paper.py
  close_position(): passes resolution to close_paper_position()

api/main.py  /api/summary
  Added unrealized_pnl_est (estimated, open trades) and realized_pnl (exact,
  closed+resolved) as separate fields alongside total_pnl
  win_rate: null if < 5 resolved trades (was proxy on entry_price < 0.5)
  calibration_score: Brier-based, null if < 10 resolved trades
  resolved_count exposed as field
  Each field annotated with: exact/estimated, source, null conditions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:34:48 +00:00

200 lines
7.5 KiB
Python

"""
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