Files
polymarket-skills/polymarket-paper-trader/scripts/paper_engine.py
T
Polymarket Skills BuilderandClaude Opus 4.6 068b2adc75 Add 6 Polymarket trading skills with paper trading engine
Composable Agent Skills (SKILL.md format) for Polymarket prediction market
trading. Includes scanner, analyzer, monitor, paper trader, strategy advisor,
and live executor. All tested against live Polymarket APIs. Security audited
with all HIGH/MEDIUM findings resolved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 07:25:07 +00:00

1076 lines
38 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Polymarket Paper Trading Engine
Simulates trades against live Polymarket data with zero financial risk.
Uses SQLite for persistent storage across agent sessions.
Fetches real prices from the CLOB and Gamma APIs.
"""
import argparse
import json
import os
import sqlite3
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.request import urlopen, Request
from urllib.error import URLError, HTTPError
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
DB_DIR = Path.home() / ".polymarket-paper"
DB_PATH = DB_DIR / "portfolio.db"
GAMMA_API = "https://gamma-api.polymarket.com"
CLOB_API = "https://clob.polymarket.com"
DEFAULT_BALANCE = 1000.0
# Risk defaults (overridable per-portfolio)
DEFAULT_RISK = {
"max_position_pct": 0.10, # 10% of bankroll per trade
"max_drawdown_pct": 0.30, # 30% total drawdown halts trading
"max_concurrent_positions": 5,
"daily_loss_limit_pct": 0.05, # 5% of starting bankroll
"max_single_market_pct": 0.20, # 20% portfolio in one market
"human_approval_pct": 0.15, # trades > 15% need human approval
}
# Polymarket fee tiers — most markets are fee-free.
# Crypto 5-min / 15-min markets use a dynamic maker/taker model.
# We model the common case (0%) and let callers override.
DEFAULT_FEE_RATE = 0.0
# Token ID format: numeric string, typically 50-100 digits
import re
_TOKEN_ID_RE = re.compile(r"^\d{20,120}$")
def _validate_token_id(token_id: str) -> str:
"""Validate a CLOB token ID before using it in URLs."""
if not isinstance(token_id, str) or not _TOKEN_ID_RE.match(token_id):
raise ValueError(
f"Invalid token ID format: must be 20-120 digits, got: {token_id!r}"
)
return token_id
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
def _api_get(url: str, timeout: int = 15) -> dict | list:
"""GET JSON from a URL. Returns parsed JSON."""
req = Request(url, headers={"User-Agent": "polymarket-paper-trader/1.0"})
try:
with urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode())
except (URLError, HTTPError) as exc:
raise RuntimeError(f"API request failed: {url}{exc}") from exc
def fetch_orderbook(token_id: str) -> dict:
"""Fetch the live order book for a CLOB token."""
_validate_token_id(token_id)
return _api_get(f"{CLOB_API}/book?token_id={token_id}")
def fetch_midpoint(token_id: str) -> float:
"""Fetch the midpoint price for a token."""
_validate_token_id(token_id)
data = _api_get(f"{CLOB_API}/midpoint?token_id={token_id}")
return float(data["mid"])
def fetch_price(token_id: str, side: str) -> float:
"""Fetch the best price for a side (buy/sell)."""
_validate_token_id(token_id)
data = _api_get(f"{CLOB_API}/price?token_id={token_id}&side={side}")
return float(data["price"])
def lookup_market(token_id: str) -> dict | None:
"""Look up market metadata by CLOB token ID via Gamma API."""
_validate_token_id(token_id)
data = _api_get(
f"{GAMMA_API}/markets?clob_token_ids={token_id}&limit=1"
)
if data and len(data) > 0:
return data[0]
return None
# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------
def _get_db() -> sqlite3.Connection:
"""Open (and possibly initialize) the SQLite database."""
DB_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
_init_schema(conn)
return conn
def _init_schema(conn: sqlite3.Connection):
conn.executescript("""
CREATE TABLE IF NOT EXISTS portfolios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT 'default',
starting_balance REAL NOT NULL,
cash_balance REAL NOT NULL,
peak_value REAL NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
risk_config TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
portfolio_id INTEGER NOT NULL REFERENCES portfolios(id),
token_id TEXT NOT NULL,
market_question TEXT,
side TEXT NOT NULL CHECK(side IN ('YES','NO')),
shares REAL NOT NULL DEFAULT 0,
avg_entry REAL NOT NULL DEFAULT 0,
current_price REAL NOT NULL DEFAULT 0,
opened_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
closed INTEGER NOT NULL DEFAULT 0,
closed_at TEXT,
UNIQUE(portfolio_id, token_id, side, closed)
);
CREATE TABLE IF NOT EXISTS trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
portfolio_id INTEGER NOT NULL REFERENCES portfolios(id),
token_id TEXT NOT NULL,
market_question TEXT,
side TEXT NOT NULL CHECK(side IN ('YES','NO')),
action TEXT NOT NULL CHECK(action IN ('BUY','SELL')),
shares REAL NOT NULL,
price REAL NOT NULL,
fee REAL NOT NULL DEFAULT 0,
total_cost REAL NOT NULL,
reasoning TEXT,
executed_at TEXT NOT NULL,
entry_avg REAL
);
CREATE TABLE IF NOT EXISTS daily_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
portfolio_id INTEGER NOT NULL REFERENCES portfolios(id),
date TEXT NOT NULL,
cash_balance REAL NOT NULL,
positions_value REAL NOT NULL,
total_value REAL NOT NULL,
daily_pnl REAL NOT NULL DEFAULT 0,
UNIQUE(portfolio_id, date)
);
""")
conn.commit()
# ---------------------------------------------------------------------------
# Portfolio operations
# ---------------------------------------------------------------------------
def init_portfolio(
starting_balance: float = DEFAULT_BALANCE,
name: str = "default",
risk_config: dict | None = None,
) -> dict:
"""Create a new paper-trading portfolio."""
if starting_balance <= 0:
raise ValueError("Starting balance must be positive")
risk = {**DEFAULT_RISK, **(risk_config or {})}
now = datetime.now(timezone.utc).isoformat()
conn = _get_db()
try:
# Deactivate existing portfolios with the same name
conn.execute(
"UPDATE portfolios SET active = 0 WHERE name = ? AND active = 1",
(name,),
)
cur = conn.execute(
"""INSERT INTO portfolios
(name, starting_balance, cash_balance, peak_value,
created_at, updated_at, risk_config, active)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)""",
(name, starting_balance, starting_balance, starting_balance,
now, now, json.dumps(risk)),
)
conn.commit()
pid = cur.lastrowid
finally:
conn.close()
return {
"portfolio_id": pid,
"name": name,
"starting_balance": starting_balance,
"cash_balance": starting_balance,
"positions": [],
"total_value": starting_balance,
"pnl": 0.0,
"pnl_pct": 0.0,
"created_at": now,
}
def _active_portfolio(conn: sqlite3.Connection, name: str = "default") -> dict:
"""Fetch the active portfolio row or raise."""
row = conn.execute(
"SELECT * FROM portfolios WHERE name = ? AND active = 1 ORDER BY id DESC LIMIT 1",
(name,),
).fetchone()
if not row:
raise RuntimeError(
f"No active portfolio '{name}'. Run: python paper_engine.py --action init"
)
return dict(row)
def get_portfolio(name: str = "default", refresh_prices: bool = True) -> dict:
"""Return the current portfolio state with live-priced positions."""
conn = _get_db()
try:
pf = _active_portfolio(conn, name)
pid = pf["id"]
positions = conn.execute(
"SELECT * FROM positions WHERE portfolio_id = ? AND closed = 0",
(pid,),
).fetchall()
pos_list = []
positions_value = 0.0
for p in positions:
p = dict(p)
if refresh_prices:
try:
p["current_price"] = fetch_midpoint(p["token_id"])
conn.execute(
"UPDATE positions SET current_price = ?, updated_at = ? WHERE id = ?",
(p["current_price"],
datetime.now(timezone.utc).isoformat(), p["id"]),
)
except Exception:
pass # keep stale price
value = p["shares"] * p["current_price"]
unrealized_pnl = (p["current_price"] - p["avg_entry"]) * p["shares"]
pos_list.append({
"token_id": p["token_id"],
"market_question": p["market_question"],
"side": p["side"],
"shares": p["shares"],
"avg_entry": p["avg_entry"],
"current_price": p["current_price"],
"value": round(value, 4),
"unrealized_pnl": round(unrealized_pnl, 4),
"opened_at": p["opened_at"],
})
positions_value += value
total_value = pf["cash_balance"] + positions_value
starting = pf["starting_balance"]
pnl = total_value - starting
# Update peak
if total_value > pf["peak_value"]:
conn.execute(
"UPDATE portfolios SET peak_value = ?, updated_at = ? WHERE id = ?",
(total_value, datetime.now(timezone.utc).isoformat(), pid),
)
conn.commit()
return {
"portfolio_id": pid,
"name": pf["name"],
"starting_balance": starting,
"cash_balance": round(pf["cash_balance"], 4),
"positions_value": round(positions_value, 4),
"total_value": round(total_value, 4),
"pnl": round(pnl, 4),
"pnl_pct": round(pnl / starting * 100, 2) if starting else 0,
"peak_value": round(max(pf["peak_value"], total_value), 4),
"drawdown_pct": round(
(max(pf["peak_value"], total_value) - total_value)
/ max(pf["peak_value"], total_value) * 100, 2
) if max(pf["peak_value"], total_value) > 0 else 0,
"positions": pos_list,
"num_open_positions": len(pos_list),
"created_at": pf["created_at"],
}
finally:
conn.close()
# ---------------------------------------------------------------------------
# Order book fill simulation
# ---------------------------------------------------------------------------
def _simulate_fill(
orderbook: dict,
side: str,
size_usd: float,
fee_rate: float = DEFAULT_FEE_RATE,
) -> dict:
"""
Walk the order book to simulate a realistic fill.
For a BUY: we consume asks (ascending price).
For a SELL: we consume bids (descending price).
Returns: {avg_price, shares_filled, total_cost, fee}
"""
if side == "BUY":
levels = orderbook.get("asks", [])
# asks are already sorted ascending by CLOB
levels = sorted(levels, key=lambda x: float(x["price"]))
else:
levels = orderbook.get("bids", [])
levels = sorted(levels, key=lambda x: float(x["price"]), reverse=True)
if not levels:
raise RuntimeError(
f"No {'asks' if side == 'BUY' else 'bids'} in order book — "
"market may be illiquid or closed"
)
remaining_usd = size_usd
total_shares = 0.0
total_spent = 0.0
for level in levels:
price = float(level["price"])
available_shares = float(level["size"])
if price <= 0:
continue
# How many shares can we buy/sell at this level with remaining USD?
max_shares_at_level = remaining_usd / price
fill_shares = min(available_shares, max_shares_at_level)
fill_cost = fill_shares * price
total_shares += fill_shares
total_spent += fill_cost
remaining_usd -= fill_cost
if remaining_usd < 0.001: # close enough to zero
break
if total_shares == 0:
raise RuntimeError("Could not fill any shares — check order size and book depth")
avg_price = total_spent / total_shares
fee = total_spent * fee_rate
return {
"avg_price": round(avg_price, 6),
"shares_filled": round(total_shares, 4),
"total_cost": round(total_spent + fee, 4),
"fee": round(fee, 4),
"levels_consumed": min(len(levels), 10), # info only
}
# ---------------------------------------------------------------------------
# Risk validation
# ---------------------------------------------------------------------------
def _validate_risk(
portfolio: dict,
risk_config: dict,
side: str,
size_usd: float,
token_id: str,
) -> tuple[bool, str]:
"""Check trade against risk rules. Returns (ok, reason)."""
total_value = portfolio["total_value"]
starting = portfolio["starting_balance"]
if total_value <= 0:
return False, "Portfolio value is zero or negative"
# Max position size
max_pos = total_value * risk_config.get("max_position_pct", 0.10)
if size_usd > max_pos:
return False, (
f"Trade size ${size_usd:.2f} exceeds max position "
f"${max_pos:.2f} ({risk_config['max_position_pct']*100:.0f}% of portfolio)"
)
# Max drawdown
peak = portfolio.get("peak_value", starting)
if peak > 0:
current_dd = (peak - total_value) / peak
if current_dd >= risk_config.get("max_drawdown_pct", 0.30):
return False, (
f"Max drawdown exceeded: {current_dd*100:.1f}% "
f"(limit {risk_config['max_drawdown_pct']*100:.0f}%)"
)
# Max concurrent positions (only for new positions)
if side == "BUY":
max_conc = risk_config.get("max_concurrent_positions", 5)
if portfolio["num_open_positions"] >= max_conc:
# Check if this is adding to an existing position
existing = [p for p in portfolio["positions"]
if p["token_id"] == token_id]
if not existing:
return False, (
f"Max concurrent positions reached: "
f"{portfolio['num_open_positions']}/{max_conc}"
)
# Single market exposure
existing_value = sum(
p["value"] for p in portfolio["positions"]
if p["token_id"] == token_id
)
new_exposure = existing_value + size_usd
max_market = total_value * risk_config.get("max_single_market_pct", 0.20)
if new_exposure > max_market:
return False, (
f"Single market exposure ${new_exposure:.2f} exceeds limit "
f"${max_market:.2f} ({risk_config['max_single_market_pct']*100:.0f}%)"
)
# Human approval threshold
approval_pct = risk_config.get("human_approval_pct", 0.15)
if size_usd > total_value * approval_pct:
return False, (
f"Trade size ${size_usd:.2f} exceeds human approval threshold "
f"({approval_pct*100:.0f}% of portfolio = ${total_value*approval_pct:.2f}). "
f"Reduce size or set force=True to override."
)
return True, "OK"
def _check_daily_loss(
conn: sqlite3.Connection,
pid: int,
starting_balance: float,
risk_config: dict,
) -> tuple[bool, str]:
"""Check if daily loss limit has been exceeded."""
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
# Sum today's realized losses from SELL trades using the entry_avg
# snapshot recorded at trade time (not the current positions table).
row = conn.execute(
"""SELECT COALESCE(SUM(
CASE WHEN action='SELL' AND entry_avg IS NOT NULL
THEN (price - entry_avg) * shares
ELSE 0 END
), 0) as daily_realized
FROM trades
WHERE portfolio_id = ? AND date(executed_at) = ?""",
(pid, today),
).fetchone()
daily_loss = abs(min(0, row["daily_realized"])) if row else 0
limit = starting_balance * risk_config.get("daily_loss_limit_pct", 0.05)
if daily_loss >= limit:
return False, (
f"Daily loss limit exceeded: ${daily_loss:.2f} "
f"(limit ${limit:.2f} = {risk_config['daily_loss_limit_pct']*100:.0f}% "
f"of starting balance)"
)
return True, "OK"
# ---------------------------------------------------------------------------
# Trade execution
# ---------------------------------------------------------------------------
def place_order(
token_id: str,
side: str,
size: float,
price: float | None = None,
reasoning: str = "",
portfolio_name: str = "default",
fee_rate: float = DEFAULT_FEE_RATE,
force: bool = False,
) -> dict:
"""
Place a paper trade.
Args:
token_id: CLOB token ID
side: 'YES' or 'NO'
size: Amount in USD to spend
price: Limit price (None = market order using live book)
reasoning: Why this trade was made
portfolio_name: Which portfolio to trade in
fee_rate: Fee rate override (default 0 for most markets)
force: Skip risk checks (except balance)
Returns: Trade execution result dict.
"""
side = side.upper()
if side not in ("YES", "NO"):
raise ValueError(f"Side must be YES or NO, got: {side}")
if size <= 0:
raise ValueError("Size must be positive")
# Fetch market data and simulate fill BEFORE acquiring the write lock
# so we don't hold the lock during network I/O.
market_info = lookup_market(token_id)
market_question = market_info["question"] if market_info else "Unknown market"
if price is not None:
# Limit order: fill at specified price
shares = size / price
fee = size * fee_rate
fill = {
"avg_price": price,
"shares_filled": round(shares, 4),
"total_cost": round(size + fee, 4),
"fee": round(fee, 4),
}
else:
# Market order: walk the real order book
orderbook = fetch_orderbook(token_id)
fill = _simulate_fill(orderbook, "BUY", size, fee_rate)
# Get portfolio state for risk checks (also does network I/O)
portfolio_state = get_portfolio(portfolio_name, refresh_prices=True)
conn = _get_db()
try:
# Acquire exclusive write lock for atomic balance check + debit
conn.execute("BEGIN IMMEDIATE")
pf = _active_portfolio(conn, portfolio_name)
pid = pf["id"]
risk_config = json.loads(pf["risk_config"])
# Balance check (always enforced) — re-read inside transaction
if size > pf["cash_balance"]:
conn.rollback()
raise RuntimeError(
f"Insufficient balance: need ${size:.2f}, "
f"have ${pf['cash_balance']:.2f}"
)
# Risk validation
if not force:
ok, reason = _validate_risk(
portfolio_state, risk_config, "BUY", size, token_id
)
if not ok:
conn.rollback()
raise RuntimeError(f"Risk check failed: {reason}")
ok, reason = _check_daily_loss(
conn, pid, pf["starting_balance"], risk_config
)
if not ok:
conn.rollback()
raise RuntimeError(f"Risk check failed: {reason}")
now = datetime.now(timezone.utc).isoformat()
# Update or create position
existing = conn.execute(
"""SELECT * FROM positions
WHERE portfolio_id = ? AND token_id = ? AND side = ? AND closed = 0""",
(pid, token_id, side),
).fetchone()
if existing:
existing = dict(existing)
old_shares = existing["shares"]
old_avg = existing["avg_entry"]
new_shares = old_shares + fill["shares_filled"]
# Weighted average entry
new_avg = (
(old_avg * old_shares + fill["avg_price"] * fill["shares_filled"])
/ new_shares
)
conn.execute(
"""UPDATE positions
SET shares = ?, avg_entry = ?, current_price = ?,
updated_at = ?
WHERE id = ?""",
(round(new_shares, 4), round(new_avg, 6),
fill["avg_price"], now, existing["id"]),
)
else:
conn.execute(
"""INSERT INTO positions
(portfolio_id, token_id, market_question, side, shares,
avg_entry, current_price, opened_at, updated_at, closed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)""",
(pid, token_id, market_question, side,
fill["shares_filled"], fill["avg_price"],
fill["avg_price"], now, now),
)
# Deduct from balance
new_balance = pf["cash_balance"] - fill["total_cost"]
conn.execute(
"UPDATE portfolios SET cash_balance = ?, updated_at = ? WHERE id = ?",
(round(new_balance, 4), now, pid),
)
# Compute avg entry at trade time for accurate daily loss tracking
if existing:
existing = dict(existing) if not isinstance(existing, dict) else existing
trade_entry_avg = existing["avg_entry"]
else:
trade_entry_avg = fill["avg_price"]
# Record trade (includes entry_avg snapshot for daily loss calculation)
conn.execute(
"""INSERT INTO trades
(portfolio_id, token_id, market_question, side, action,
shares, price, fee, total_cost, reasoning, executed_at,
entry_avg)
VALUES (?, ?, ?, ?, 'BUY', ?, ?, ?, ?, ?, ?, ?)""",
(pid, token_id, market_question, side,
fill["shares_filled"], fill["avg_price"], fill["fee"],
fill["total_cost"], reasoning, now, fill["avg_price"]),
)
conn.commit()
return {
"status": "filled",
"action": "BUY",
"side": side,
"token_id": token_id,
"market": market_question,
"shares": fill["shares_filled"],
"avg_price": fill["avg_price"],
"fee": fill["fee"],
"total_cost": fill["total_cost"],
"new_balance": round(new_balance, 4),
"reasoning": reasoning,
"executed_at": now,
}
except Exception:
conn.rollback()
raise
finally:
conn.close()
def close_position(
token_id: str,
side: str | None = None,
portfolio_name: str = "default",
fee_rate: float = DEFAULT_FEE_RATE,
reasoning: str = "",
) -> dict:
"""
Close an open position at current market price.
Args:
token_id: The CLOB token to close
side: YES or NO (auto-detected if only one position exists)
portfolio_name: Which portfolio
fee_rate: Override fee rate
reasoning: Why closing
Returns: Close execution result.
"""
# Fetch order book BEFORE acquiring write lock (network I/O)
orderbook = fetch_orderbook(token_id)
# Walk bids to simulate sell fill
bids = sorted(
orderbook.get("bids", []),
key=lambda x: float(x["price"]),
reverse=True,
)
if not bids:
raise RuntimeError("No bids in order book — cannot close position")
conn = _get_db()
try:
# Acquire exclusive write lock for atomic credit
conn.execute("BEGIN IMMEDIATE")
pf = _active_portfolio(conn, portfolio_name)
pid = pf["id"]
if side:
side = side.upper()
positions = conn.execute(
"""SELECT * FROM positions
WHERE portfolio_id = ? AND token_id = ? AND side = ? AND closed = 0""",
(pid, token_id, side),
).fetchall()
else:
positions = conn.execute(
"""SELECT * FROM positions
WHERE portfolio_id = ? AND token_id = ? AND closed = 0""",
(pid, token_id),
).fetchall()
if not positions:
conn.rollback()
raise RuntimeError(
f"No open position for token {token_id}"
+ (f" side={side}" if side else "")
)
results = []
for pos in positions:
pos = dict(pos)
remaining_shares = pos["shares"]
total_proceeds = 0.0
for level in bids:
lvl_price = float(level["price"])
lvl_size = float(level["size"])
sell_shares = min(remaining_shares, lvl_size)
total_proceeds += sell_shares * lvl_price
remaining_shares -= sell_shares
if remaining_shares < 0.0001:
break
shares_sold = pos["shares"] - remaining_shares
if shares_sold <= 0:
conn.rollback()
raise RuntimeError("Could not sell any shares at current bids")
avg_sell_price = total_proceeds / shares_sold if shares_sold > 0 else 0
fee = total_proceeds * fee_rate
net_proceeds = total_proceeds - fee
pnl = (avg_sell_price - pos["avg_entry"]) * shares_sold - fee
now = datetime.now(timezone.utc).isoformat()
# Mark position closed
conn.execute(
"UPDATE positions SET closed = 1, closed_at = ?, updated_at = ? WHERE id = ?",
(now, now, pos["id"]),
)
# Credit proceeds to balance
new_balance = pf["cash_balance"] + net_proceeds
conn.execute(
"UPDATE portfolios SET cash_balance = ?, updated_at = ? WHERE id = ?",
(round(new_balance, 4), now, pid),
)
pf["cash_balance"] = new_balance
# Record trade with entry_avg snapshot for daily loss tracking
conn.execute(
"""INSERT INTO trades
(portfolio_id, token_id, market_question, side, action,
shares, price, fee, total_cost, reasoning, executed_at,
entry_avg)
VALUES (?, ?, ?, ?, 'SELL', ?, ?, ?, ?, ?, ?, ?)""",
(pid, token_id, pos["market_question"], pos["side"],
round(shares_sold, 4), round(avg_sell_price, 6),
round(fee, 4), round(net_proceeds, 4), reasoning, now,
pos["avg_entry"]),
)
results.append({
"status": "closed",
"action": "SELL",
"side": pos["side"],
"token_id": token_id,
"market": pos["market_question"],
"shares_sold": round(shares_sold, 4),
"avg_sell_price": round(avg_sell_price, 6),
"avg_entry_price": pos["avg_entry"],
"fee": round(fee, 4),
"net_proceeds": round(net_proceeds, 4),
"realized_pnl": round(pnl, 4),
"new_balance": round(new_balance, 4),
"executed_at": now,
})
conn.commit()
return results[0] if len(results) == 1 else results
except Exception:
conn.rollback()
raise
finally:
conn.close()
def get_trades(
portfolio_name: str = "default",
limit: int = 50,
) -> list[dict]:
"""Return trade history, most recent first."""
conn = _get_db()
try:
pf = _active_portfolio(conn, portfolio_name)
rows = conn.execute(
"""SELECT * FROM trades
WHERE portfolio_id = ?
ORDER BY executed_at DESC
LIMIT ?""",
(pf["id"], limit),
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
# ---------------------------------------------------------------------------
# Daily snapshot
# ---------------------------------------------------------------------------
def take_snapshot(portfolio_name: str = "default") -> dict:
"""Record a daily portfolio snapshot for performance tracking."""
state = get_portfolio(portfolio_name, refresh_prices=True)
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
conn = _get_db()
try:
pid = state["portfolio_id"]
# Get yesterday's snapshot for daily P&L
prev = conn.execute(
"""SELECT total_value FROM daily_snapshots
WHERE portfolio_id = ? AND date < ?
ORDER BY date DESC LIMIT 1""",
(pid, today),
).fetchone()
prev_value = prev["total_value"] if prev else state["starting_balance"]
daily_pnl = state["total_value"] - prev_value
conn.execute(
"""INSERT OR REPLACE INTO daily_snapshots
(portfolio_id, date, cash_balance, positions_value,
total_value, daily_pnl)
VALUES (?, ?, ?, ?, ?, ?)""",
(pid, today, state["cash_balance"], state["positions_value"],
state["total_value"], round(daily_pnl, 4)),
)
conn.commit()
return {
"date": today,
"total_value": state["total_value"],
"daily_pnl": round(daily_pnl, 4),
"cash": state["cash_balance"],
"positions_value": state["positions_value"],
}
finally:
conn.close()
# ---------------------------------------------------------------------------
# Formatting helpers
# ---------------------------------------------------------------------------
def _format_portfolio(pf: dict) -> str:
"""Format portfolio state for human-readable output."""
lines = [
f"=== Portfolio: {pf['name']} ===",
f"Starting Balance: ${pf['starting_balance']:>10,.2f}",
f"Cash Balance: ${pf['cash_balance']:>10,.2f}",
f"Positions Value: ${pf['positions_value']:>10,.2f}",
f"Total Value: ${pf['total_value']:>10,.2f}",
f"P&L: ${pf['pnl']:>10,.2f} ({pf['pnl_pct']:+.2f}%)",
f"Peak Value: ${pf['peak_value']:>10,.2f}",
f"Drawdown: {pf['drawdown_pct']:>10.2f}%",
f"Open Positions: {pf['num_open_positions']:>10d}",
f"Created: {pf['created_at']}",
]
if pf["positions"]:
lines.append("\n--- Open Positions ---")
for p in pf["positions"]:
pnl_str = f"${p['unrealized_pnl']:+,.2f}"
lines.append(
f" {p['side']:>3} {p['shares']:>8.2f} shares @ "
f"${p['avg_entry']:.4f} -> ${p['current_price']:.4f} "
f"P&L: {pnl_str}"
)
if p["market_question"]:
lines.append(f" {p['market_question'][:70]}")
return "\n".join(lines)
def _format_trades(trades: list[dict]) -> str:
"""Format trade list for human-readable output."""
if not trades:
return "No trades recorded."
lines = ["=== Trade History ==="]
for t in trades:
lines.append(
f" [{t['executed_at'][:19]}] {t['action']:>4} {t['side']:>3} "
f"{t['shares']:>8.2f} @ ${t['price']:.4f} "
f"(cost: ${t['total_cost']:.2f}, fee: ${t['fee']:.2f})"
)
if t.get("market_question"):
lines.append(f" {t['market_question'][:70]}")
if t.get("reasoning"):
lines.append(f" Reason: {t['reasoning'][:70]}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Polymarket Paper Trading Engine",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --action init --balance 1000
%(prog)s --action buy --token TOKEN_ID --side YES --size 50
%(prog)s --action sell --token TOKEN_ID --side YES --size 50
%(prog)s --action close --token TOKEN_ID
%(prog)s --action portfolio
%(prog)s --action trades
%(prog)s --action snapshot
""",
)
parser.add_argument("--action", required=True,
choices=["init", "buy", "sell", "close",
"portfolio", "trades", "snapshot"],
help="Action to perform")
parser.add_argument("--balance", type=float, default=DEFAULT_BALANCE,
help="Starting balance (init only)")
parser.add_argument("--name", default="default",
help="Portfolio name")
parser.add_argument("--token", help="CLOB token ID")
parser.add_argument("--side", choices=["YES", "NO", "yes", "no"],
help="Trade side")
parser.add_argument("--size", type=float, help="Trade size in USD")
parser.add_argument("--price", type=float, default=None,
help="Limit price (omit for market order)")
parser.add_argument("--reason", default="", help="Trade reasoning")
parser.add_argument("--fee-rate", type=float, default=DEFAULT_FEE_RATE,
help="Fee rate override")
parser.add_argument("--force", action="store_true",
help="Skip risk checks")
parser.add_argument("--json", action="store_true",
help="Output as JSON")
parser.add_argument("--limit", type=int, default=50,
help="Max trades to show")
args = parser.parse_args()
try:
if args.action == "init":
result = init_portfolio(args.balance, args.name)
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"Portfolio '{result['name']}' initialized with "
f"${result['starting_balance']:,.2f}")
elif args.action in ("buy", "sell"):
if not args.token:
parser.error("--token is required for buy/sell")
if not args.side:
parser.error("--side is required for buy/sell")
if not args.size:
parser.error("--size is required for buy/sell")
result = place_order(
token_id=args.token,
side=args.side.upper(),
size=args.size,
price=args.price,
reasoning=args.reason,
portfolio_name=args.name,
fee_rate=args.fee_rate,
force=args.force,
)
if args.json:
print(json.dumps(result, indent=2))
else:
print(
f"{result['action']} {result['side']} "
f"{result['shares']:.2f} shares @ "
f"${result['avg_price']:.4f}\n"
f"Market: {result['market']}\n"
f"Total cost: ${result['total_cost']:.2f} "
f"(fee: ${result['fee']:.2f})\n"
f"New balance: ${result['new_balance']:.2f}"
)
elif args.action == "close":
if not args.token:
parser.error("--token is required for close")
result = close_position(
token_id=args.token,
side=args.side.upper() if args.side else None,
portfolio_name=args.name,
fee_rate=args.fee_rate,
reasoning=args.reason,
)
if args.json:
print(json.dumps(result, indent=2))
else:
if isinstance(result, list):
for r in result:
print(
f"Closed {r['side']} position: "
f"{r['shares_sold']:.2f} shares @ "
f"${r['avg_sell_price']:.4f}\n"
f"Realized P&L: ${r['realized_pnl']:+,.2f}\n"
f"New balance: ${r['new_balance']:.2f}"
)
else:
print(
f"Closed {result['side']} position: "
f"{result['shares_sold']:.2f} shares @ "
f"${result['avg_sell_price']:.4f}\n"
f"Realized P&L: ${result['realized_pnl']:+,.2f}\n"
f"New balance: ${result['new_balance']:.2f}"
)
elif args.action == "portfolio":
result = get_portfolio(args.name, refresh_prices=True)
if args.json:
print(json.dumps(result, indent=2))
else:
print(_format_portfolio(result))
elif args.action == "trades":
result = get_trades(args.name, args.limit)
if args.json:
print(json.dumps(result, indent=2, default=str))
else:
print(_format_trades(result))
elif args.action == "snapshot":
result = take_snapshot(args.name)
if args.json:
print(json.dumps(result, indent=2))
else:
print(
f"Snapshot for {result['date']}: "
f"${result['total_value']:,.2f} "
f"(daily P&L: ${result['daily_pnl']:+,.2f})"
)
except (RuntimeError, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()