Files
polymarket-bot/bot/executor/paper.py
T
chemavx 63d9f637ff
CI/CD / build-and-push (push) Successful in 2m30s
feat(bot): 5-phase strategy upgrade — edge neto, families, GNews priority, regimes
Phase 1 — Edge neto real (paper.py, bayesian.py, risk/manager.py, db.py):
- Trade records now store edge_gross, edge_net, prior_prob, final_prob,
  mid_price, spread_estimate, commission, family_key
- edge_net = edge_gross - SPREAD_ESTIMATE(0.02) - COMMISSION_RATE(0.02)
  NOTE: both constants are heuristics, not exact Polymarket exchange costs
- Execution gate changed from edge_gross > MIN_EDGE to edge_net > regime_min_edge

Phase 2 — Market families (polymarket.py):
- market_family_key(market) groups related markets:
    texas-republican-2026, fed-april-2026, openai-2026, etc.
- At most 1 trade per family per cycle; occupied_families propagated via main.py
- Family key logged on every TRADE and SKIP line

Phase 3 — GNews priority (news.py, bayesian.py, main.py):
- NewsClient.get_freshness() returns 1.0/0.75/0.40/0.10 by cache age
- gnews_priority(market, news) = uncertainty × volume_score × freshness
- Politics markets sorted by priority DESC before eval so best markets get
  the 5-query/cycle GNews budget first

Phase 4 — Regime min-edge by category/horizon (bayesian.py):
- politics >60d → 0.12, 30-60d → 0.10, <30d → 0.08
- tech / crypto/finance → 0.10
- All thresholds applied to edge_net (not edge_gross)

Phase 5 — Observability (bayesian.py, main.py):
- Structured skip labels: SKIP_UNSUPPORTED, SKIP_NO_SIGNALS,
  SKIP_PRIOR_EXTREME, SKIP_FAMILY, SKIP_GNEWS_PRIORITY, SKIP_EDGE_NET
- TRADE lines now include family_key, edge_gross, edge_net, regime_min, days
- schema.sql: 8 new cols on trades, 7 new cols on signals (via ALTER TABLE IF NOT EXISTS)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 15:34:46 +00:00

176 lines
6.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_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