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:
Polymarket Skills Builder
2026-02-26 07:25:07 +00:00
co-authored by Claude Opus 4.6
parent 6a03bbe2e5
commit 068b2adc75
35 changed files with 7842 additions and 1 deletions
+85
View File
@@ -0,0 +1,85 @@
---
name: polymarket-analyzer
description: >
Use this skill whenever the user wants to find trading opportunities, detect arbitrage,
analyze a market, perform edge detection, find mispricing, do probability analysis,
evaluate orderbook depth, find momentum signals, or assess Polymarket market quality.
Triggers: "find opportunities", "detect arbitrage", "analyze market", "edge detection",
"mispricing", "probability analysis", "orderbook analysis", "momentum scanner",
"market inefficiency", "price gap", "volume surge", "trading edge", "market analysis".
---
# Polymarket Analyzer Skill
Detect trading edges and opportunities across Polymarket prediction markets using
real-time data from the Gamma and CLOB APIs. Zero authentication required -- all
analysis is read-only.
## Available Scripts
### 1. Find Arbitrage Edges (`scripts/find_edges.py`)
Scans all active markets for pricing inefficiencies:
- **Underpriced**: YES + NO < $1.00 (guaranteed profit if you buy both sides)
- **Overpriced**: YES + NO > $1.02 (sell opportunity)
- Calculates profit after fees for each opportunity
- Outputs market name, prices, sum, potential profit, and fee impact
```bash
python scripts/find_edges.py
python scripts/find_edges.py --min-edge 0.02 --limit 500
```
### 2. Analyze Order Book (`scripts/analyze_orderbook.py`)
Deep analysis of a single market's order book:
- Spread, mid-price, bid/ask depth (top N levels)
- Bid-ask imbalance ratio (signals directional pressure)
- Thin vs thick book classification
- Liquidity concentration analysis
```bash
python scripts/analyze_orderbook.py --token-id <TOKEN_ID>
python scripts/analyze_orderbook.py --token-id <TOKEN_ID> --depth 10
```
### 3. Momentum Scanner (`scripts/momentum_scanner.py`)
Detect markets with unusual activity:
- **Volume surges**: 24h volume significantly exceeds 7-day average
- **Price momentum**: recent price moves in one direction
- **Liquidity changes**: markets gaining or losing depth
- Ranked output by signal strength
```bash
python scripts/momentum_scanner.py
python scripts/momentum_scanner.py --min-volume 10000 --limit 300
```
## Workflow
1. Run `find_edges.py` to scan for arbitrage across all active markets
2. For interesting markets, run `analyze_orderbook.py` to check if the edge is executable
3. Run `momentum_scanner.py` to find markets with directional momentum
4. Combine findings to identify the best opportunities
## Fee Awareness
Most Polymarket markets are fee-free. Crypto 5-min/15-min markets have dynamic taker
fees: `fee = baseRate * min(price, 1 - price) * size`. See `references/fee-model.md`
for the full fee calculator and breakeven analysis.
## Strategy Reference
See `references/viable-strategies.md` for the four strategies that still work in 2026
with win rates, expected returns, and risk profiles.
## Important Disclaimers
- This skill performs read-only analysis only -- no trades are executed
- Past patterns do not guarantee future results
- Always verify opportunities manually before trading
- Not financial advice
+101
View File
@@ -0,0 +1,101 @@
# Polymarket Fee Model
## Overview
Most Polymarket markets are **fee-free**. Dynamic taker fees apply only to
short-duration crypto markets (5-minute and 15-minute expiry).
## Fee-Free Markets
The vast majority of markets on Polymarket -- political, sports, entertainment,
weather, and long-duration crypto markets -- charge **zero fees** for both makers
and takers. This makes arbitrage significantly more viable than on traditional
exchanges.
## Dynamic Taker Fees (Crypto Short-Duration Only)
For 5-minute and 15-minute crypto prediction markets, a dynamic taker fee applies:
```
feeQuote = baseRate * min(price, 1 - price) * size
```
Where:
- `baseRate` is set per market (typically 0.063 or 6.3%)
- `price` is the execution price (0 to 1)
- `size` is the number of shares
### Effective Fee Rate by Price
| Price | min(p, 1-p) | Effective Rate (baseRate=0.063) |
|-------|-------------|-------------------------------|
| 0.05 | 0.05 | 0.315% (0.063 * 0.05) |
| 0.10 | 0.10 | 0.630% |
| 0.20 | 0.20 | 1.260% |
| 0.30 | 0.30 | 1.890% |
| 0.40 | 0.40 | 2.520% |
| 0.50 | 0.50 | 3.150% (maximum) |
| 0.60 | 0.40 | 2.520% |
| 0.70 | 0.30 | 1.890% |
| 0.80 | 0.20 | 1.260% |
| 0.90 | 0.10 | 0.630% |
| 0.95 | 0.05 | 0.315% |
The fee is **parabolic**, peaking at p=0.50 and dropping sharply near the extremes.
This was explicitly designed to kill latency arbitrage on these fast markets.
### Fee Calculator
```python
def calculate_fee(price: float, size: float, base_rate: float = 0.063) -> dict:
"""Calculate dynamic taker fee for crypto short-duration markets."""
fee_rate = base_rate * min(price, 1 - price)
fee_amount = fee_rate * size
cost_basis = price * size
total_cost = cost_basis + fee_amount
effective_rate = fee_amount / cost_basis if cost_basis > 0 else 0
return {
"fee_rate": fee_rate,
"fee_amount": fee_amount,
"cost_basis": cost_basis,
"total_cost": total_cost,
"effective_rate_pct": effective_rate * 100,
}
```
### Breakeven Analysis for Arbitrage
For an arbitrage trade buying both YES and NO:
```python
def arbitrage_breakeven(yes_price, no_price, base_rate=0.063):
"""Calculate if arb is profitable after fees on fee-bearing markets."""
raw_sum = yes_price + no_price
raw_edge = 1.0 - raw_sum # Positive = underpriced
yes_fee = base_rate * min(yes_price, 1 - yes_price)
no_fee = base_rate * min(no_price, 1 - no_price)
total_fee_rate = yes_fee + no_fee
net_profit_per_share = raw_edge - total_fee_rate
return {
"raw_edge": raw_edge,
"total_fee_rate": total_fee_rate,
"net_profit_per_share": net_profit_per_share,
"profitable": net_profit_per_share > 0,
}
```
## Maker Rebates
Post-only limit orders (introduced January 2026) receive maker rebates on
qualifying markets. This creates a structural advantage for market-making
strategies that provide liquidity.
## Practical Implications
1. **Fee-free markets**: Arbitrage edges as small as $0.01 are worth capturing
2. **Fee-bearing markets**: Need at least 3-6% raw edge at mid-prices to break even
3. **Extreme prices** (< 0.10 or > 0.90): Fees are minimal even on fee-bearing markets
4. **Market making**: Maker rebates make spread-capture profitable on thin books
@@ -0,0 +1,118 @@
# Viable Polymarket Trading Strategies (2026)
On-chain analysis of 95 million transactions shows only 0.51% of Polymarket wallets
have profits exceeding $1,000. Four strategies remain viable for bot builders.
## 1. Market Making / Liquidity Provision
**Win Rate**: 78-85%
**Expected Monthly Return**: 1-3%
**Minimum Bankroll**: $5,000+
**Risk Level**: Medium
Place limit orders on both sides of a market, earning the bid-ask spread plus
Polymarket's liquidity reward program. Post-only orders (January 2026) and maker
rebates create structural advantages.
**How it works**:
- Quote both bid and ask around a fair-value estimate
- Earn the spread on each round-trip fill
- Collect maker rebates on qualifying markets
- Manage inventory risk by adjusting quotes based on position
**Key risks**:
- Adverse selection (informed traders pick you off)
- Inventory accumulation on one side
- Market resolution risk (holding when outcome becomes certain)
**Best for**: Larger bankrolls, markets with stable prices and consistent volume.
## 2. AI-Powered News Arbitrage
**Win Rate**: 65-75%
**Expected Monthly Return**: 3-8%
**Minimum Bankroll**: $1,000+
**Risk Level**: Medium-High
Exploit the 30-second to 5-minute window where Polymarket prices have not adjusted
to breaking news. One documented trade captured a 13 cent spread on a $2,000
position ($896 profit in under 10 minutes) after Trump legal news broke.
**How it works**:
- Monitor news feeds (RSS, Twitter, official sources) with LLM analysis
- Detect market-moving events before prices adjust
- Place aggressive market orders in the direction indicated by the news
- Exit once the market reaches new equilibrium
**Key risks**:
- Speed competition with sub-100ms bots
- False signals from ambiguous news
- Slippage on thin order books
**Best for**: LLM-based agents with fast news processing. Natural fit for AI agents.
## 3. Weather Market Exploitation
**Win Rate**: 33% (but asymmetric payoff)
**Expected Monthly Return**: Variable, potentially 10%+
**Minimum Bankroll**: $100+
**Risk Level**: Low-Medium
Buy outcomes priced at 0.1-10 cents where real probability (from NOAA or weather
models) is much higher. One bot turned $27 into $63,853 using Claude + NOAA APIs.
Despite low win rate, the asymmetric payoff structure drives consistent profits.
**How it works**:
- Compare Polymarket weather prices against NOAA/NWS forecast data
- Identify outcomes where market underestimates probability
- Buy cheap shares on near-certain weather outcomes
- Wait for resolution (typically 24-48 hours)
**Key risks**:
- Weather forecast uncertainty
- Low liquidity on niche weather markets
- Capital locked until resolution
**Best for**: Small bankrolls, patient traders. Good entry point for beginners.
## 4. Imbalance Arbitrage ("Gabagool")
**Win Rate**: ~100% (mechanical)
**Expected Monthly Return**: 0.5-2%
**Minimum Bankroll**: $500+
**Risk Level**: Very Low
Buy YES and NO tokens at different timestamps when their combined cost dips below
$1.00, guaranteeing profit regardless of outcome. Documented earning approximately
$58.52 per 15-minute window through mechanical dual-side buying.
**How it works**:
- Monitor YES + NO price sums across active markets
- When sum < $1.00, buy both sides
- Guaranteed $1.00 payout on resolution minus cost
- Profit = $1.00 - (YES cost + NO cost)
**Key risks**:
- Opportunities are rare and short-lived (2.7 seconds avg duration in 2026)
- Capital efficiency is low (money locked until resolution)
- Competition from sub-100ms bots has compressed most opportunities
- Transaction timing: prices may shift between placing YES and NO orders
**Best for**: Capital-rich, latency-sensitive setups. Less viable for LLM agents
due to speed requirements.
## Strategy Selection Guide
| Bankroll | Recommended Strategy | Expected Return |
|-------------|-------------------------------|-----------------|
| < $500 | Weather exploitation | Variable |
| $500-$2K | Weather + news arbitrage | 3-8%/month |
| $2K-$10K | News arbitrage + market making | 2-5%/month |
| > $10K | Market making (primary) | 1-3%/month |
## Key Insight for AI Agents
AI-powered news arbitrage is the natural fit for LLM-based trading agents. The
agent's ability to rapidly process and interpret news, assess probability shifts,
and generate trade signals creates a genuine edge. Market making and gabagool
require sub-second execution that is better suited to traditional bot architectures.
+244
View File
@@ -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()
+341
View File
@@ -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
View File
@@ -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()