068b2adc75
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>
342 lines
12 KiB
Python
Executable File
342 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Scan active Polymarket markets for arbitrage edges.
|
|
|
|
Detects:
|
|
- Underpriced markets: best-ask YES + best-ask NO < $1.00 (buy both for profit)
|
|
- Overpriced markets: best-bid YES + best-bid NO > $1.00 (sell both for profit)
|
|
- Wide spreads: markets where bid-ask spread creates opportunity
|
|
|
|
Uses Gamma API for market discovery and CLOB API for real order book prices.
|
|
Gamma mid-prices always sum to $1.00 by construction, so order book prices are
|
|
needed to find real executable edges.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import time
|
|
|
|
import requests
|
|
from py_clob_client.client import ClobClient
|
|
|
|
|
|
GAMMA_API = "https://gamma-api.polymarket.com"
|
|
CLOB_HOST = "https://clob.polymarket.com"
|
|
|
|
|
|
def fetch_markets(limit: int = 100, offset: int = 0) -> list[dict]:
|
|
"""Fetch active markets from Gamma API."""
|
|
url = (
|
|
f"{GAMMA_API}/markets"
|
|
f"?limit={limit}&offset={offset}&active=true&closed=false"
|
|
)
|
|
resp = requests.get(url, timeout=15)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
def parse_token_ids(market: dict) -> tuple[str, str] | None:
|
|
"""Extract YES and NO token IDs from a market dict."""
|
|
raw = market.get("clobTokenIds")
|
|
if not raw:
|
|
return None
|
|
try:
|
|
ids = json.loads(raw)
|
|
if len(ids) < 2:
|
|
return None
|
|
return ids[0], ids[1]
|
|
except (json.JSONDecodeError, ValueError, IndexError):
|
|
return None
|
|
|
|
|
|
def parse_mid_prices(market: dict) -> tuple[float, float] | None:
|
|
"""Extract mid-prices from Gamma API (for display context)."""
|
|
raw = market.get("outcomePrices")
|
|
if not raw:
|
|
return None
|
|
try:
|
|
prices = json.loads(raw)
|
|
if len(prices) < 2:
|
|
return None
|
|
return float(prices[0]), float(prices[1])
|
|
except (json.JSONDecodeError, ValueError, IndexError):
|
|
return None
|
|
|
|
|
|
def calculate_fee(price: float, base_rate: float = 0.063) -> float:
|
|
"""Calculate dynamic taker fee rate for fee-bearing markets."""
|
|
return base_rate * min(price, 1.0 - price)
|
|
|
|
|
|
def get_book_prices(client: ClobClient, token_id: str) -> tuple[float, float] | None:
|
|
"""Get best bid and best ask for a token. Returns (best_bid, best_ask) or None."""
|
|
try:
|
|
book = client.get_order_book(token_id)
|
|
except Exception:
|
|
return None
|
|
|
|
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])
|
|
|
|
best_bid = bids[0][0] if bids else None
|
|
best_ask = asks[0][0] if asks else None
|
|
|
|
if best_bid is None or best_ask is None:
|
|
return None
|
|
return best_bid, best_ask
|
|
|
|
|
|
def scan_edges(
|
|
max_markets: int = 200,
|
|
min_edge: float = 0.005,
|
|
check_orderbooks: bool = True,
|
|
) -> list[dict]:
|
|
"""Scan markets for pricing edges.
|
|
|
|
Two modes:
|
|
1. Fast scan (check_orderbooks=False): Uses Gamma mid-prices (always sum to 1.0,
|
|
so only finds spread-based opportunities via CLOB spot check)
|
|
2. Deep scan (check_orderbooks=True): Fetches actual order book for each market
|
|
to find real executable edges (slower, rate-limited)
|
|
"""
|
|
client = ClobClient(CLOB_HOST) if check_orderbooks else None
|
|
edges = []
|
|
offset = 0
|
|
batch_size = 100
|
|
fetched = 0
|
|
checked_books = 0
|
|
|
|
while fetched < max_markets:
|
|
batch = fetch_markets(limit=batch_size, offset=offset)
|
|
if not batch:
|
|
break
|
|
|
|
for market in batch:
|
|
token_ids = parse_token_ids(market)
|
|
mid_prices = parse_mid_prices(market)
|
|
|
|
if token_ids is None or mid_prices is None:
|
|
continue
|
|
|
|
yes_token_id, no_token_id = token_ids
|
|
yes_mid, no_mid = mid_prices
|
|
|
|
# Skip very low-liquidity markets
|
|
liquidity = float(market.get("liquidityNum", 0) or 0)
|
|
if liquidity < 100:
|
|
continue
|
|
|
|
if not check_orderbooks:
|
|
continue
|
|
|
|
# Fetch real order book prices
|
|
yes_book = get_book_prices(client, yes_token_id)
|
|
no_book = get_book_prices(client, no_token_id)
|
|
checked_books += 1
|
|
|
|
if yes_book is None or no_book is None:
|
|
continue
|
|
|
|
yes_bid, yes_ask = yes_book
|
|
no_bid, no_ask = no_book
|
|
|
|
# Check underpriced: buy YES at ask + buy NO at ask < $1.00
|
|
buy_both_cost = yes_ask + no_ask
|
|
if buy_both_cost < (1.0 - min_edge):
|
|
raw_edge = 1.0 - buy_both_cost
|
|
yes_fee = calculate_fee(yes_ask)
|
|
no_fee = calculate_fee(no_ask)
|
|
total_fee = yes_fee + no_fee
|
|
net = raw_edge - total_fee
|
|
|
|
edges.append({
|
|
"question": market.get("question", "Unknown"),
|
|
"slug": market.get("slug", ""),
|
|
"type": "UNDERPRICED",
|
|
"yes_ask": yes_ask,
|
|
"no_ask": no_ask,
|
|
"cost_sum": round(buy_both_cost, 6),
|
|
"raw_edge": round(raw_edge, 6),
|
|
"fee_impact": round(total_fee, 6),
|
|
"net_profit_per_share": round(net, 6),
|
|
"profitable_after_fees": net > 0,
|
|
"yes_mid": yes_mid,
|
|
"no_mid": no_mid,
|
|
"volume_24h": market.get("volume24hr", 0) or 0,
|
|
"liquidity": liquidity,
|
|
})
|
|
|
|
# Check overpriced: sell YES at bid + sell NO at bid > $1.00
|
|
sell_both_value = yes_bid + no_bid
|
|
if sell_both_value > (1.0 + max(min_edge, 0.005)):
|
|
raw_edge = sell_both_value - 1.0
|
|
yes_fee = calculate_fee(yes_bid)
|
|
no_fee = calculate_fee(no_bid)
|
|
total_fee = yes_fee + no_fee
|
|
net = raw_edge - total_fee
|
|
|
|
edges.append({
|
|
"question": market.get("question", "Unknown"),
|
|
"slug": market.get("slug", ""),
|
|
"type": "OVERPRICED",
|
|
"yes_bid": yes_bid,
|
|
"no_bid": no_bid,
|
|
"cost_sum": round(sell_both_value, 6),
|
|
"raw_edge": round(raw_edge, 6),
|
|
"fee_impact": round(total_fee, 6),
|
|
"net_profit_per_share": round(net, 6),
|
|
"profitable_after_fees": net > 0,
|
|
"yes_mid": yes_mid,
|
|
"no_mid": no_mid,
|
|
"volume_24h": market.get("volume24hr", 0) or 0,
|
|
"liquidity": liquidity,
|
|
})
|
|
|
|
# Also report wide spreads (opportunity for market making)
|
|
yes_spread = yes_ask - yes_bid
|
|
no_spread = no_ask - no_bid
|
|
max_spread = max(yes_spread, no_spread)
|
|
if max_spread >= 0.03: # 3 cent spread or wider
|
|
edges.append({
|
|
"question": market.get("question", "Unknown"),
|
|
"slug": market.get("slug", ""),
|
|
"type": "WIDE_SPREAD",
|
|
"yes_bid": yes_bid,
|
|
"yes_ask": yes_ask,
|
|
"yes_spread": round(yes_spread, 6),
|
|
"no_bid": no_bid,
|
|
"no_ask": no_ask,
|
|
"no_spread": round(no_spread, 6),
|
|
"max_spread": round(max_spread, 6),
|
|
"raw_edge": round(max_spread, 6),
|
|
"fee_impact": 0.0,
|
|
"net_profit_per_share": round(max_spread, 6),
|
|
"profitable_after_fees": True,
|
|
"yes_mid": yes_mid,
|
|
"no_mid": no_mid,
|
|
"volume_24h": market.get("volume24hr", 0) or 0,
|
|
"liquidity": liquidity,
|
|
})
|
|
|
|
# Rate limit: avoid hammering the CLOB API
|
|
if checked_books % 5 == 0:
|
|
time.sleep(0.2)
|
|
|
|
fetched += len(batch)
|
|
offset += batch_size
|
|
|
|
if len(batch) < batch_size:
|
|
break
|
|
|
|
# Sort by raw edge descending
|
|
edges.sort(key=lambda x: x["raw_edge"], reverse=True)
|
|
return edges
|
|
|
|
|
|
def format_output(edges: list[dict]) -> str:
|
|
"""Format edges for display."""
|
|
if not edges:
|
|
return (
|
|
"No arbitrage edges found in current markets.\n"
|
|
"This is normal -- Polymarket is well-arbitraged, with most\n"
|
|
"opportunities lasting only ~2.7 seconds (median) in 2026."
|
|
)
|
|
|
|
lines = []
|
|
|
|
# Group by type
|
|
underpriced = [e for e in edges if e["type"] == "UNDERPRICED"]
|
|
overpriced = [e for e in edges if e["type"] == "OVERPRICED"]
|
|
wide_spread = [e for e in edges if e["type"] == "WIDE_SPREAD"]
|
|
|
|
if underpriced:
|
|
lines.append(f"\n=== UNDERPRICED ({len(underpriced)}) - Buy both sides for guaranteed profit ===\n")
|
|
lines.append(f" {'YES ask':>8} {'NO ask':>8} {'Sum':>8} {'Edge':>7} {'Net':>7} {'Vol24h':>10} Question")
|
|
lines.append(" " + "-" * 100)
|
|
for e in underpriced:
|
|
marker = " *" if e["profitable_after_fees"] else ""
|
|
lines.append(
|
|
f" ${e['yes_ask']:<7.4f} ${e['no_ask']:<7.4f} "
|
|
f"${e['cost_sum']:<7.4f} ${e['raw_edge']:<6.4f} "
|
|
f"${e['net_profit_per_share']:<+6.4f}{marker} "
|
|
f"${e['volume_24h']:>9,.0f} {e['question'][:55]}"
|
|
)
|
|
|
|
if overpriced:
|
|
lines.append(f"\n=== OVERPRICED ({len(overpriced)}) - Sell both sides ===\n")
|
|
lines.append(f" {'YES bid':>8} {'NO bid':>8} {'Sum':>8} {'Edge':>7} {'Net':>7} {'Vol24h':>10} Question")
|
|
lines.append(" " + "-" * 100)
|
|
for e in overpriced:
|
|
marker = " *" if e["profitable_after_fees"] else ""
|
|
lines.append(
|
|
f" ${e['yes_bid']:<7.4f} ${e['no_bid']:<7.4f} "
|
|
f"${e['cost_sum']:<7.4f} ${e['raw_edge']:<6.4f} "
|
|
f"${e['net_profit_per_share']:<+6.4f}{marker} "
|
|
f"${e['volume_24h']:>9,.0f} {e['question'][:55]}"
|
|
)
|
|
|
|
if wide_spread:
|
|
lines.append(f"\n=== WIDE SPREADS ({len(wide_spread)}) - Market-making opportunities ===\n")
|
|
lines.append(f" {'Y Spread':>8} {'N Spread':>8} {'Max':>7} {'Vol24h':>10} {'Liq':>10} Question")
|
|
lines.append(" " + "-" * 100)
|
|
for e in wide_spread:
|
|
lines.append(
|
|
f" ${e.get('yes_spread', 0):<7.4f} ${e.get('no_spread', 0):<7.4f} "
|
|
f"${e.get('max_spread', 0):<6.4f} "
|
|
f"${e['volume_24h']:>9,.0f} "
|
|
f"${e['liquidity']:>9,.0f} "
|
|
f"{e['question'][:55]}"
|
|
)
|
|
|
|
lines.append("")
|
|
lines.append("* = profitable even on fee-bearing markets (most markets are fee-free)")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Scan Polymarket for arbitrage edges using real order book data"
|
|
)
|
|
parser.add_argument(
|
|
"--min-edge",
|
|
type=float,
|
|
default=0.005,
|
|
help="Minimum edge to report (default: 0.005 = $0.005/share)",
|
|
)
|
|
parser.add_argument(
|
|
"--limit",
|
|
type=int,
|
|
default=200,
|
|
help="Maximum markets to scan (default: 200, each requires 2 API calls)",
|
|
)
|
|
parser.add_argument(
|
|
"--json",
|
|
action="store_true",
|
|
help="Output results as JSON",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
print(f"Scanning up to {args.limit} markets (2 order book lookups each)...",
|
|
file=sys.stderr)
|
|
|
|
try:
|
|
edges = scan_edges(
|
|
max_markets=args.limit,
|
|
min_edge=args.min_edge,
|
|
)
|
|
except requests.RequestException as e:
|
|
print(f"Error fetching data: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if args.json:
|
|
print(json.dumps(edges, indent=2))
|
|
else:
|
|
print(format_output(edges))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|