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>
230 lines
6.6 KiB
Python
Executable File
230 lines
6.6 KiB
Python
Executable File
#!/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()
|