9a5be27532
CI/CD / build-and-push (push) Successful in 1m47s
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>
200 lines
7.5 KiB
Python
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
|