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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6a03bbe2e5
commit
068b2adc75
+244
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analyze a Polymarket order book for a given token ID.
|
||||
|
||||
Calculates spread, depth, bid-ask imbalance, and classifies book thickness.
|
||||
|
||||
Requires: py-clob-client (pip install py-clob-client)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from py_clob_client.client import ClobClient
|
||||
|
||||
|
||||
CLOB_HOST = "https://clob.polymarket.com"
|
||||
|
||||
|
||||
def fetch_orderbook(token_id: str) -> object:
|
||||
"""Fetch order book from CLOB API."""
|
||||
client = ClobClient(CLOB_HOST)
|
||||
return client.get_order_book(token_id)
|
||||
|
||||
|
||||
def analyze(book, depth: int = 5) -> dict:
|
||||
"""Analyze an order book and return metrics."""
|
||||
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 [])]
|
||||
|
||||
# Sort: bids descending by price, asks ascending by price
|
||||
bids.sort(key=lambda x: x[0], reverse=True)
|
||||
asks.sort(key=lambda x: x[0])
|
||||
|
||||
result = {
|
||||
"token_id": book.asset_id,
|
||||
"total_bid_levels": len(bids),
|
||||
"total_ask_levels": len(asks),
|
||||
}
|
||||
|
||||
if not bids and not asks:
|
||||
result["status"] = "EMPTY_BOOK"
|
||||
return result
|
||||
|
||||
# Best bid / best ask
|
||||
best_bid = bids[0][0] if bids else 0.0
|
||||
best_ask = asks[0][0] if asks else 1.0
|
||||
spread = best_ask - best_bid
|
||||
mid_price = (best_bid + best_ask) / 2.0 if (bids and asks) else None
|
||||
|
||||
result["best_bid"] = best_bid
|
||||
result["best_ask"] = best_ask
|
||||
result["spread"] = round(spread, 6)
|
||||
result["spread_pct"] = round((spread / mid_price * 100) if mid_price else 0, 4)
|
||||
result["mid_price"] = round(mid_price, 6) if mid_price else None
|
||||
|
||||
# Depth at top N levels
|
||||
top_bids = bids[:depth]
|
||||
top_asks = asks[:depth]
|
||||
|
||||
bid_depth = sum(size for _, size in top_bids)
|
||||
ask_depth = sum(size for _, size in top_asks)
|
||||
total_depth = bid_depth + ask_depth
|
||||
|
||||
result["bid_depth"] = round(bid_depth, 2)
|
||||
result["ask_depth"] = round(ask_depth, 2)
|
||||
result["total_depth"] = round(total_depth, 2)
|
||||
result["depth_levels_used"] = depth
|
||||
|
||||
# Bid-ask imbalance ratio: positive = more bids (buying pressure)
|
||||
if total_depth > 0:
|
||||
imbalance = (bid_depth - ask_depth) / total_depth
|
||||
else:
|
||||
imbalance = 0.0
|
||||
result["imbalance_ratio"] = round(imbalance, 4)
|
||||
|
||||
# Classify the imbalance
|
||||
if imbalance > 0.3:
|
||||
result["imbalance_signal"] = "STRONG_BUY_PRESSURE"
|
||||
elif imbalance > 0.1:
|
||||
result["imbalance_signal"] = "MODERATE_BUY_PRESSURE"
|
||||
elif imbalance < -0.3:
|
||||
result["imbalance_signal"] = "STRONG_SELL_PRESSURE"
|
||||
elif imbalance < -0.1:
|
||||
result["imbalance_signal"] = "MODERATE_SELL_PRESSURE"
|
||||
else:
|
||||
result["imbalance_signal"] = "BALANCED"
|
||||
|
||||
# Book thickness classification
|
||||
if total_depth < 500:
|
||||
result["book_class"] = "THIN"
|
||||
result["book_note"] = "Easy to move price; high slippage risk"
|
||||
elif total_depth < 5000:
|
||||
result["book_class"] = "MODERATE"
|
||||
result["book_note"] = "Normal depth; moderate slippage on large orders"
|
||||
else:
|
||||
result["book_class"] = "THICK"
|
||||
result["book_note"] = "Stable book; low slippage for most order sizes"
|
||||
|
||||
# Bid levels detail
|
||||
result["bid_levels"] = [
|
||||
{"price": p, "size": round(s, 2), "cumulative": round(sum(sz for _, sz in top_bids[:i+1]), 2)}
|
||||
for i, (p, s) in enumerate(top_bids)
|
||||
]
|
||||
result["ask_levels"] = [
|
||||
{"price": p, "size": round(s, 2), "cumulative": round(sum(sz for _, sz in top_asks[:i+1]), 2)}
|
||||
for i, (p, s) in enumerate(top_asks)
|
||||
]
|
||||
|
||||
# Slippage estimate: cost to buy/sell $100 worth
|
||||
slippage_size = 100.0
|
||||
result["buy_slippage"] = _estimate_slippage(asks, slippage_size)
|
||||
result["sell_slippage"] = _estimate_slippage(
|
||||
[(p, s) for p, s in bids], slippage_size, selling=True
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _estimate_slippage(
|
||||
levels: list[tuple[float, float]], target_size: float, selling: bool = False
|
||||
) -> dict | None:
|
||||
"""Estimate average fill price and slippage for a target size."""
|
||||
if not levels:
|
||||
return None
|
||||
|
||||
filled = 0.0
|
||||
cost = 0.0
|
||||
|
||||
for price, size in levels:
|
||||
remaining = target_size - filled
|
||||
fill_qty = min(size, remaining)
|
||||
cost += fill_qty * price
|
||||
filled += fill_qty
|
||||
if filled >= target_size:
|
||||
break
|
||||
|
||||
if filled == 0:
|
||||
return None
|
||||
|
||||
avg_price = cost / filled
|
||||
best_price = levels[0][0]
|
||||
slippage = abs(avg_price - best_price)
|
||||
|
||||
return {
|
||||
"target_size": target_size,
|
||||
"filled": round(filled, 2),
|
||||
"avg_price": round(avg_price, 6),
|
||||
"best_price": best_price,
|
||||
"slippage": round(slippage, 6),
|
||||
"slippage_pct": round(slippage / best_price * 100 if best_price else 0, 4),
|
||||
"fully_filled": filled >= target_size,
|
||||
}
|
||||
|
||||
|
||||
def format_output(result: dict) -> str:
|
||||
"""Format analysis result for display."""
|
||||
lines = []
|
||||
lines.append(f"Order Book Analysis for {result['token_id'][:30]}...")
|
||||
lines.append("=" * 70)
|
||||
|
||||
if result.get("status") == "EMPTY_BOOK":
|
||||
lines.append("Order book is empty -- no bids or asks.")
|
||||
return "\n".join(lines)
|
||||
|
||||
lines.append(f" Best Bid: ${result['best_bid']:.4f}")
|
||||
lines.append(f" Best Ask: ${result['best_ask']:.4f}")
|
||||
lines.append(f" Mid Price: ${result['mid_price']:.4f}" if result['mid_price'] else " Mid Price: N/A")
|
||||
lines.append(f" Spread: ${result['spread']:.4f} ({result['spread_pct']:.2f}%)")
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"Depth (top {result['depth_levels_used']} levels):")
|
||||
lines.append(f" Bid Depth: {result['bid_depth']:,.2f} shares")
|
||||
lines.append(f" Ask Depth: {result['ask_depth']:,.2f} shares")
|
||||
lines.append(f" Total: {result['total_depth']:,.2f} shares")
|
||||
lines.append(f" Imbalance: {result['imbalance_ratio']:+.4f} ({result['imbalance_signal']})")
|
||||
lines.append(f" Book Class: {result['book_class']} -- {result['book_note']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("Bid Levels:")
|
||||
lines.append(f" {'Price':>8} {'Size':>10} {'Cumulative':>12}")
|
||||
for lvl in result.get("bid_levels", []):
|
||||
lines.append(f" ${lvl['price']:<7.4f} {lvl['size']:>10,.2f} {lvl['cumulative']:>12,.2f}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("Ask Levels:")
|
||||
lines.append(f" {'Price':>8} {'Size':>10} {'Cumulative':>12}")
|
||||
for lvl in result.get("ask_levels", []):
|
||||
lines.append(f" ${lvl['price']:<7.4f} {lvl['size']:>10,.2f} {lvl['cumulative']:>12,.2f}")
|
||||
|
||||
for label, key in [("Buy", "buy_slippage"), ("Sell", "sell_slippage")]:
|
||||
slip = result.get(key)
|
||||
lines.append("")
|
||||
if slip:
|
||||
status = "YES" if slip["fully_filled"] else "PARTIAL"
|
||||
lines.append(
|
||||
f"{label} Slippage ({slip['target_size']:.0f} shares): "
|
||||
f"avg ${slip['avg_price']:.4f}, "
|
||||
f"slippage ${slip['slippage']:.4f} ({slip['slippage_pct']:.2f}%), "
|
||||
f"filled: {status}"
|
||||
)
|
||||
else:
|
||||
lines.append(f"{label} Slippage: No liquidity on this side")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Analyze Polymarket order book"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--token-id",
|
||||
required=True,
|
||||
help="CLOB token ID to analyze",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--depth",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of price levels to analyze (default: 5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output results as JSON",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
book = fetch_orderbook(args.token_id)
|
||||
except Exception as e:
|
||||
print(f"Error fetching order book: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
result = analyze(book, depth=args.depth)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
print(format_output(result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+341
@@ -0,0 +1,341 @@
|
||||
#!/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()
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scan Polymarket for momentum signals: volume surges and price trends.
|
||||
|
||||
Detects:
|
||||
- Volume surges: 24h volume significantly exceeds 7-day daily average
|
||||
- Price momentum: markets with strong directional price movement
|
||||
- Liquidity anomalies: unusually high or low liquidity relative to volume
|
||||
|
||||
Uses Gamma API (no auth required).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
GAMMA_API = "https://gamma-api.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 compute_signals(market: dict) -> dict | None:
|
||||
"""Compute momentum signals for a single market."""
|
||||
vol_24h = float(market.get("volume24hr", 0) or 0)
|
||||
vol_1wk = float(market.get("volume1wk", 0) or 0)
|
||||
liquidity = float(market.get("liquidityNum", 0) or 0)
|
||||
|
||||
# Need at least some volume data
|
||||
if vol_24h <= 0 and vol_1wk <= 0:
|
||||
return None
|
||||
|
||||
# Parse prices
|
||||
raw_prices = market.get("outcomePrices")
|
||||
if not raw_prices:
|
||||
return None
|
||||
try:
|
||||
prices = json.loads(raw_prices)
|
||||
yes_price = float(prices[0])
|
||||
except (json.JSONDecodeError, ValueError, IndexError):
|
||||
return None
|
||||
|
||||
# Volume surge: compare 24h volume to 7-day daily average
|
||||
daily_avg_7d = vol_1wk / 7.0 if vol_1wk > 0 else 0
|
||||
if daily_avg_7d > 0:
|
||||
volume_ratio = vol_24h / daily_avg_7d
|
||||
else:
|
||||
volume_ratio = 0.0
|
||||
|
||||
# Price extremity: how far from 0.50 (max uncertainty)
|
||||
# Prices near 0 or 1 suggest strong directional conviction
|
||||
price_extremity = abs(yes_price - 0.5) * 2.0 # 0 at 0.50, 1 at 0 or 1
|
||||
|
||||
# Volume-to-liquidity ratio: high ratio suggests heavy activity relative to depth
|
||||
vol_liq_ratio = vol_24h / liquidity if liquidity > 0 else 0
|
||||
|
||||
# Composite momentum score
|
||||
# volume_ratio contributes most -- a surge is the primary signal
|
||||
score = 0.0
|
||||
if volume_ratio > 1.0:
|
||||
score += min((volume_ratio - 1.0) * 0.4, 2.0) # Cap contribution at 2.0
|
||||
if vol_liq_ratio > 1.0:
|
||||
score += min((vol_liq_ratio - 1.0) * 0.3, 1.5)
|
||||
# Extreme prices amplify the signal (market is moving toward resolution)
|
||||
if price_extremity > 0.6:
|
||||
score += (price_extremity - 0.6) * 0.3
|
||||
|
||||
if score <= 0:
|
||||
return None
|
||||
|
||||
# Classify the signal
|
||||
if volume_ratio >= 3.0:
|
||||
volume_signal = "VOLUME_SURGE"
|
||||
elif volume_ratio >= 1.5:
|
||||
volume_signal = "ELEVATED_VOLUME"
|
||||
else:
|
||||
volume_signal = "NORMAL_VOLUME"
|
||||
|
||||
if yes_price >= 0.85:
|
||||
direction = "STRONG_YES"
|
||||
elif yes_price >= 0.65:
|
||||
direction = "LEANING_YES"
|
||||
elif yes_price <= 0.15:
|
||||
direction = "STRONG_NO"
|
||||
elif yes_price <= 0.35:
|
||||
direction = "LEANING_NO"
|
||||
else:
|
||||
direction = "NEUTRAL"
|
||||
|
||||
return {
|
||||
"question": market.get("question", "Unknown"),
|
||||
"slug": market.get("slug", ""),
|
||||
"yes_price": yes_price,
|
||||
"direction": direction,
|
||||
"volume_24h": round(vol_24h, 2),
|
||||
"daily_avg_7d": round(daily_avg_7d, 2),
|
||||
"volume_ratio": round(volume_ratio, 2),
|
||||
"volume_signal": volume_signal,
|
||||
"liquidity": round(liquidity, 2),
|
||||
"vol_liq_ratio": round(vol_liq_ratio, 2),
|
||||
"momentum_score": round(score, 4),
|
||||
}
|
||||
|
||||
|
||||
def scan_momentum(
|
||||
max_markets: int = 300,
|
||||
min_volume: float = 1000.0,
|
||||
min_score: float = 0.1,
|
||||
) -> list[dict]:
|
||||
"""Scan markets and rank by momentum score."""
|
||||
signals = []
|
||||
offset = 0
|
||||
batch_size = 100
|
||||
fetched = 0
|
||||
|
||||
while fetched < max_markets:
|
||||
batch = fetch_markets(limit=batch_size, offset=offset)
|
||||
if not batch:
|
||||
break
|
||||
|
||||
for market in batch:
|
||||
vol_24h = float(market.get("volume24hr", 0) or 0)
|
||||
if vol_24h < min_volume:
|
||||
continue
|
||||
|
||||
sig = compute_signals(market)
|
||||
if sig and sig["momentum_score"] >= min_score:
|
||||
signals.append(sig)
|
||||
|
||||
fetched += len(batch)
|
||||
offset += batch_size
|
||||
|
||||
if len(batch) < batch_size:
|
||||
break
|
||||
|
||||
# Rank by momentum score descending
|
||||
signals.sort(key=lambda x: x["momentum_score"], reverse=True)
|
||||
return signals
|
||||
|
||||
|
||||
def format_output(signals: list[dict]) -> str:
|
||||
"""Format momentum signals for display."""
|
||||
if not signals:
|
||||
return "No momentum signals found matching criteria."
|
||||
|
||||
lines = []
|
||||
lines.append(f"Found {len(signals)} market(s) with momentum signals:\n")
|
||||
lines.append(
|
||||
f"{'Score':>6} {'YES':>5} {'Direction':<12} "
|
||||
f"{'VolRatio':>8} {'Signal':<16} "
|
||||
f"{'Vol24h':>12} {'Avg7d':>10} Question"
|
||||
)
|
||||
lines.append("-" * 120)
|
||||
|
||||
for s in signals:
|
||||
lines.append(
|
||||
f"{s['momentum_score']:>6.2f} "
|
||||
f"${s['yes_price']:<4.2f} "
|
||||
f"{s['direction']:<12} "
|
||||
f"{s['volume_ratio']:>7.1f}x "
|
||||
f"{s['volume_signal']:<16} "
|
||||
f"${s['volume_24h']:>11,.0f} "
|
||||
f"${s['daily_avg_7d']:>9,.0f} "
|
||||
f"{s['question'][:55]}"
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append("Score = composite of volume surge, vol/liquidity ratio, and price extremity.")
|
||||
lines.append("Volume Ratio = 24h volume / 7-day daily average (>3x = VOLUME_SURGE).")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Scan Polymarket for momentum signals"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-volume",
|
||||
type=float,
|
||||
default=1000,
|
||||
help="Minimum 24h volume to consider (default: $1,000)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-score",
|
||||
type=float,
|
||||
default=0.1,
|
||||
help="Minimum momentum score to report (default: 0.1)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=300,
|
||||
help="Maximum number of markets to scan (default: 300)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output results as JSON",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
signals = scan_momentum(
|
||||
max_markets=args.limit,
|
||||
min_volume=args.min_volume,
|
||||
min_score=args.min_score,
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
print(f"Error fetching data from Gamma API: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(signals, indent=2))
|
||||
else:
|
||||
print(format_output(signals))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user