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