391 lines
12 KiB
Python
391 lines
12 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Execute live trades on Polymarket with mandatory human confirmation.
|
||
|
|
|
||
|
|
SAFETY: Every trade requires interactive confirmation. No autonomous execution.
|
||
|
|
|
||
|
|
Requires environment variables:
|
||
|
|
POLYMARKET_PRIVATE_KEY - Burner wallet private key (NEVER main wallet)
|
||
|
|
POLYMARKET_CONFIRM=true - Safety gate (must be exactly "true")
|
||
|
|
|
||
|
|
Optional environment variables:
|
||
|
|
POLYMARKET_MAX_SIZE - Max $ per trade (default: 10)
|
||
|
|
POLYMARKET_DAILY_LOSS_LIMIT - Max daily loss in $ (default: 50)
|
||
|
|
|
||
|
|
All trades are logged to ~/.polymarket-live/trades.log
|
||
|
|
"""
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import sys
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from py_clob_client.client import ClobClient
|
||
|
|
from py_clob_client.clob_types import (
|
||
|
|
ApiCreds,
|
||
|
|
OrderArgs,
|
||
|
|
MarketOrderArgs,
|
||
|
|
OrderType,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
CLOB_HOST = "https://clob.polymarket.com"
|
||
|
|
CHAIN_ID = 137 # Polygon mainnet
|
||
|
|
LOG_DIR = Path.home() / ".polymarket-live"
|
||
|
|
LOG_FILE = LOG_DIR / "trades.log"
|
||
|
|
|
||
|
|
|
||
|
|
def check_safety_gates() -> tuple[bool, str]:
|
||
|
|
"""Verify all safety gates are in place. Returns (ok, message)."""
|
||
|
|
key = os.environ.get("POLYMARKET_PRIVATE_KEY", "")
|
||
|
|
if not key:
|
||
|
|
return False, (
|
||
|
|
"POLYMARKET_PRIVATE_KEY not set.\n"
|
||
|
|
"Set it to your BURNER wallet private key (never your main wallet).\n"
|
||
|
|
"See references/security.md for setup instructions."
|
||
|
|
)
|
||
|
|
|
||
|
|
confirm = os.environ.get("POLYMARKET_CONFIRM", "")
|
||
|
|
if confirm != "true":
|
||
|
|
return False, (
|
||
|
|
"POLYMARKET_CONFIRM is not set to 'true'.\n"
|
||
|
|
"This safety gate prevents accidental trade execution.\n"
|
||
|
|
"Set POLYMARKET_CONFIRM=true when you are ready for live trading."
|
||
|
|
)
|
||
|
|
|
||
|
|
return True, "OK"
|
||
|
|
|
||
|
|
|
||
|
|
def get_max_size() -> float:
|
||
|
|
"""Get maximum position size from env or default."""
|
||
|
|
try:
|
||
|
|
return float(os.environ.get("POLYMARKET_MAX_SIZE", "10"))
|
||
|
|
except ValueError:
|
||
|
|
return 10.0
|
||
|
|
|
||
|
|
|
||
|
|
def get_daily_loss_limit() -> float:
|
||
|
|
"""Get daily loss limit from env or default."""
|
||
|
|
try:
|
||
|
|
return float(os.environ.get("POLYMARKET_DAILY_LOSS_LIMIT", "50"))
|
||
|
|
except ValueError:
|
||
|
|
return 50.0
|
||
|
|
|
||
|
|
|
||
|
|
def get_daily_spending() -> float:
|
||
|
|
"""Calculate total spending today from the trade log."""
|
||
|
|
if not LOG_FILE.exists():
|
||
|
|
return 0.0
|
||
|
|
|
||
|
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||
|
|
total = 0.0
|
||
|
|
|
||
|
|
for line in LOG_FILE.read_text().splitlines():
|
||
|
|
try:
|
||
|
|
entry = json.loads(line)
|
||
|
|
if entry.get("timestamp", "").startswith(today) and entry.get("status") == "EXECUTED":
|
||
|
|
total += float(entry.get("cost_usd", 0))
|
||
|
|
except (json.JSONDecodeError, ValueError):
|
||
|
|
continue
|
||
|
|
|
||
|
|
return total
|
||
|
|
|
||
|
|
|
||
|
|
def log_trade(entry: dict):
|
||
|
|
"""Append a trade entry to the log file."""
|
||
|
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
|
with open(LOG_FILE, "a") as f:
|
||
|
|
f.write(json.dumps(entry) + "\n")
|
||
|
|
|
||
|
|
|
||
|
|
def create_authenticated_client() -> ClobClient:
|
||
|
|
"""Create an L2-authenticated ClobClient."""
|
||
|
|
key = os.environ["POLYMARKET_PRIVATE_KEY"]
|
||
|
|
|
||
|
|
# Initialize L1 client
|
||
|
|
client = ClobClient(CLOB_HOST, chain_id=CHAIN_ID, key=key)
|
||
|
|
|
||
|
|
# Derive L2 API credentials
|
||
|
|
creds = client.create_or_derive_api_creds()
|
||
|
|
client.set_api_creds(creds)
|
||
|
|
|
||
|
|
return client
|
||
|
|
|
||
|
|
|
||
|
|
def get_orderbook_context(client: ClobClient, token_id: str) -> dict:
|
||
|
|
"""Fetch order book context for display."""
|
||
|
|
try:
|
||
|
|
book = client.get_order_book(token_id)
|
||
|
|
bids = [(float(b.price), float(b.size)) for b in (book.bids or [])]
|
||
|
|
asks = [(float(a.price), float(a.size)) for a in (book.asks or [])]
|
||
|
|
bids.sort(key=lambda x: x[0], reverse=True)
|
||
|
|
asks.sort(key=lambda x: x[0])
|
||
|
|
|
||
|
|
return {
|
||
|
|
"best_bid": bids[0] if bids else None,
|
||
|
|
"best_ask": asks[0] if asks else None,
|
||
|
|
"bid_depth_5": sum(s for _, s in bids[:5]),
|
||
|
|
"ask_depth_5": sum(s for _, s in asks[:5]),
|
||
|
|
"spread": (asks[0][0] - bids[0][0]) if (bids and asks) else None,
|
||
|
|
}
|
||
|
|
except Exception as e:
|
||
|
|
return {"error": str(e)}
|
||
|
|
|
||
|
|
|
||
|
|
def display_trade_confirmation(
|
||
|
|
side: str,
|
||
|
|
token_id: str,
|
||
|
|
size: float | None,
|
||
|
|
amount: float | None,
|
||
|
|
price: float | None,
|
||
|
|
is_market: bool,
|
||
|
|
context: dict,
|
||
|
|
max_size: float,
|
||
|
|
daily_spent: float,
|
||
|
|
daily_limit: float,
|
||
|
|
) -> str:
|
||
|
|
"""Display full trade details and return confirmation prompt text."""
|
||
|
|
lines = []
|
||
|
|
lines.append("")
|
||
|
|
lines.append("=" * 60)
|
||
|
|
lines.append(" LIVE TRADE CONFIRMATION REQUIRED")
|
||
|
|
lines.append("=" * 60)
|
||
|
|
lines.append("")
|
||
|
|
lines.append(f" Side: {side}")
|
||
|
|
lines.append(f" Token ID: {token_id[:40]}...")
|
||
|
|
|
||
|
|
if is_market:
|
||
|
|
lines.append(f" Order Type: MARKET (Fill-or-Kill)")
|
||
|
|
lines.append(f" Amount: ${amount:.2f} USD")
|
||
|
|
else:
|
||
|
|
lines.append(f" Order Type: LIMIT (Good-til-Cancelled)")
|
||
|
|
lines.append(f" Size: {size:.2f} shares")
|
||
|
|
lines.append(f" Price: ${price:.4f} per share")
|
||
|
|
est_cost = size * price
|
||
|
|
lines.append(f" Est. Cost: ${est_cost:.2f} USD")
|
||
|
|
|
||
|
|
lines.append("")
|
||
|
|
lines.append(" Order Book Context:")
|
||
|
|
if "error" in context:
|
||
|
|
lines.append(f" ERROR: {context['error']}")
|
||
|
|
else:
|
||
|
|
bb = context.get("best_bid")
|
||
|
|
ba = context.get("best_ask")
|
||
|
|
lines.append(f" Best Bid: ${bb[0]:.4f} ({bb[1]:.0f} shares)" if bb else " Best Bid: N/A")
|
||
|
|
lines.append(f" Best Ask: ${ba[0]:.4f} ({ba[1]:.0f} shares)" if ba else " Best Ask: N/A")
|
||
|
|
spread = context.get("spread")
|
||
|
|
lines.append(f" Spread: ${spread:.4f}" if spread is not None else " Spread: N/A")
|
||
|
|
lines.append(f" Bid Depth (5 lvls): {context.get('bid_depth_5', 0):,.0f} shares")
|
||
|
|
lines.append(f" Ask Depth (5 lvls): {context.get('ask_depth_5', 0):,.0f} shares")
|
||
|
|
|
||
|
|
lines.append("")
|
||
|
|
lines.append(" Risk Controls:")
|
||
|
|
lines.append(f" Max trade size: ${max_size:.2f}")
|
||
|
|
lines.append(f" Daily spent: ${daily_spent:.2f} / ${daily_limit:.2f}")
|
||
|
|
|
||
|
|
remaining = daily_limit - daily_spent
|
||
|
|
trade_cost = amount if is_market else (size * price if size and price else 0)
|
||
|
|
if trade_cost > remaining:
|
||
|
|
lines.append(f" WARNING: Trade (${trade_cost:.2f}) exceeds remaining daily budget (${remaining:.2f})")
|
||
|
|
|
||
|
|
lines.append("")
|
||
|
|
lines.append("=" * 60)
|
||
|
|
|
||
|
|
return "\n".join(lines)
|
||
|
|
|
||
|
|
|
||
|
|
def execute_limit_order(
|
||
|
|
client: ClobClient,
|
||
|
|
token_id: str,
|
||
|
|
side: str,
|
||
|
|
size: float,
|
||
|
|
price: float,
|
||
|
|
) -> dict:
|
||
|
|
"""Create and post a limit order."""
|
||
|
|
order_args = OrderArgs(
|
||
|
|
token_id=token_id,
|
||
|
|
price=price,
|
||
|
|
size=size,
|
||
|
|
side=side,
|
||
|
|
)
|
||
|
|
signed_order = client.create_order(order_args)
|
||
|
|
result = client.post_order(signed_order, orderType=OrderType.GTC)
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def execute_market_order(
|
||
|
|
client: ClobClient,
|
||
|
|
token_id: str,
|
||
|
|
side: str,
|
||
|
|
amount: float,
|
||
|
|
) -> dict:
|
||
|
|
"""Create and post a market order."""
|
||
|
|
order_args = MarketOrderArgs(
|
||
|
|
token_id=token_id,
|
||
|
|
amount=amount,
|
||
|
|
side=side,
|
||
|
|
)
|
||
|
|
signed_order = client.create_market_order(order_args)
|
||
|
|
result = client.post_order(signed_order, orderType=OrderType.FOK)
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
parser = argparse.ArgumentParser(
|
||
|
|
description="Execute a live trade on Polymarket (requires confirmation)"
|
||
|
|
)
|
||
|
|
parser.add_argument("--token-id", required=True, help="CLOB token ID")
|
||
|
|
parser.add_argument(
|
||
|
|
"--side", required=True, choices=["BUY", "SELL"],
|
||
|
|
help="Trade side: BUY or SELL"
|
||
|
|
)
|
||
|
|
parser.add_argument("--size", type=float, help="Number of shares (for limit orders)")
|
||
|
|
parser.add_argument("--price", type=float, help="Limit price per share")
|
||
|
|
parser.add_argument("--amount", type=float, help="USD amount (for market orders)")
|
||
|
|
parser.add_argument("--market", action="store_true", help="Market order (FOK)")
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
# Validate arguments
|
||
|
|
if args.market:
|
||
|
|
if not args.amount:
|
||
|
|
print("ERROR: --amount required for market orders", file=sys.stderr)
|
||
|
|
sys.exit(1)
|
||
|
|
else:
|
||
|
|
if not args.size or not args.price:
|
||
|
|
print("ERROR: --size and --price required for limit orders", file=sys.stderr)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
# Check safety gates
|
||
|
|
ok, msg = check_safety_gates()
|
||
|
|
if not ok:
|
||
|
|
print(f"SAFETY GATE FAILED:\n{msg}", file=sys.stderr)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
# Check position size limits
|
||
|
|
max_size = get_max_size()
|
||
|
|
trade_cost = args.amount if args.market else (args.size * args.price)
|
||
|
|
|
||
|
|
if trade_cost > max_size:
|
||
|
|
print(
|
||
|
|
f"BLOCKED: Trade cost ${trade_cost:.2f} exceeds max size ${max_size:.2f}.\n"
|
||
|
|
f"Increase POLYMARKET_MAX_SIZE if intentional.",
|
||
|
|
file=sys.stderr,
|
||
|
|
)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
# Check daily loss limit
|
||
|
|
daily_limit = get_daily_loss_limit()
|
||
|
|
daily_spent = get_daily_spending()
|
||
|
|
if daily_spent + trade_cost > daily_limit:
|
||
|
|
print(
|
||
|
|
f"BLOCKED: Daily spending ${daily_spent:.2f} + this trade ${trade_cost:.2f} "
|
||
|
|
f"= ${daily_spent + trade_cost:.2f} exceeds daily limit ${daily_limit:.2f}.\n"
|
||
|
|
f"Increase POLYMARKET_DAILY_LOSS_LIMIT or wait until tomorrow.",
|
||
|
|
file=sys.stderr,
|
||
|
|
)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
# Create authenticated client
|
||
|
|
try:
|
||
|
|
client = create_authenticated_client()
|
||
|
|
except Exception as e:
|
||
|
|
print(f"ERROR creating authenticated client: {e}", file=sys.stderr)
|
||
|
|
print("Check POLYMARKET_PRIVATE_KEY and network connectivity.", file=sys.stderr)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
# Get order book context
|
||
|
|
context = get_orderbook_context(client, args.token_id)
|
||
|
|
|
||
|
|
# Display confirmation
|
||
|
|
confirmation = display_trade_confirmation(
|
||
|
|
side=args.side,
|
||
|
|
token_id=args.token_id,
|
||
|
|
size=args.size,
|
||
|
|
amount=args.amount,
|
||
|
|
price=args.price,
|
||
|
|
is_market=args.market,
|
||
|
|
context=context,
|
||
|
|
max_size=max_size,
|
||
|
|
daily_spent=daily_spent,
|
||
|
|
daily_limit=daily_limit,
|
||
|
|
)
|
||
|
|
print(confirmation)
|
||
|
|
|
||
|
|
# Ask for confirmation
|
||
|
|
try:
|
||
|
|
response = input("\n Type 'yes' to execute this trade: ").strip().lower()
|
||
|
|
except (EOFError, KeyboardInterrupt):
|
||
|
|
print("\nTrade cancelled.")
|
||
|
|
log_trade({
|
||
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||
|
|
"status": "CANCELLED",
|
||
|
|
"reason": "User did not confirm",
|
||
|
|
"token_id": args.token_id,
|
||
|
|
"side": args.side,
|
||
|
|
"size": args.size,
|
||
|
|
"price": args.price,
|
||
|
|
"amount": args.amount,
|
||
|
|
"is_market": args.market,
|
||
|
|
})
|
||
|
|
sys.exit(0)
|
||
|
|
|
||
|
|
if response != "yes":
|
||
|
|
print("Trade cancelled. You must type exactly 'yes' to confirm.")
|
||
|
|
log_trade({
|
||
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||
|
|
"status": "CANCELLED",
|
||
|
|
"reason": f"User typed: {response!r}",
|
||
|
|
"token_id": args.token_id,
|
||
|
|
"side": args.side,
|
||
|
|
"size": args.size,
|
||
|
|
"price": args.price,
|
||
|
|
"amount": args.amount,
|
||
|
|
"is_market": args.market,
|
||
|
|
})
|
||
|
|
sys.exit(0)
|
||
|
|
|
||
|
|
# Execute the trade
|
||
|
|
print("\nExecuting trade...")
|
||
|
|
try:
|
||
|
|
if args.market:
|
||
|
|
result = execute_market_order(client, args.token_id, args.side, args.amount)
|
||
|
|
else:
|
||
|
|
result = execute_limit_order(client, args.token_id, args.side, args.size, args.price)
|
||
|
|
|
||
|
|
print(f"\nTrade submitted successfully!")
|
||
|
|
print(json.dumps(result, indent=2, default=str))
|
||
|
|
|
||
|
|
log_trade({
|
||
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||
|
|
"status": "EXECUTED",
|
||
|
|
"token_id": args.token_id,
|
||
|
|
"side": args.side,
|
||
|
|
"size": args.size,
|
||
|
|
"price": args.price,
|
||
|
|
"amount": args.amount,
|
||
|
|
"is_market": args.market,
|
||
|
|
"cost_usd": trade_cost,
|
||
|
|
"result": result,
|
||
|
|
})
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
print(f"\nTrade FAILED: {e}", file=sys.stderr)
|
||
|
|
log_trade({
|
||
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||
|
|
"status": "FAILED",
|
||
|
|
"error": str(e),
|
||
|
|
"token_id": args.token_id,
|
||
|
|
"side": args.side,
|
||
|
|
"size": args.size,
|
||
|
|
"price": args.price,
|
||
|
|
"amount": args.amount,
|
||
|
|
"is_market": args.market,
|
||
|
|
"cost_usd": trade_cost,
|
||
|
|
})
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|