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>
1076 lines
38 KiB
Python
Executable File
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()
|