From 20167c4f45b22aa119967f25f554c2aa38b448b1 Mon Sep 17 00:00:00 2001 From: zlei9 Date: Sun, 29 Mar 2026 10:19:10 +0800 Subject: [PATCH] Initial commit with translated description --- SKILL.md | 251 ++++++++ _meta.json | 6 + scripts/polymarket.py | 1276 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1533 insertions(+) create mode 100644 SKILL.md create mode 100644 _meta.json create mode 100644 scripts/polymarket.py diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..2a5ff61 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,251 @@ +--- +name: polymarket +description: "查询Polymarket预测市场-检查赔率、趋势市场、搜索事件、跟踪价格和动量。包含观察列表警报、解决日历、动量扫描器和模拟交易。" +homepage: https://polymarket.com +user-invocable: true +disable-model-invocation: true +metadata: + openclaw: + emoji: "📊" + requires: + bins: [python3] +--- +`READ BEFORE INSTALL` +# [How to use](https://telegra.ph/How-Building-a-Weather-Polymarket-Bot-with-OpenClaw-Skill-and-turn-100--8000-Step-by-Step-Guide-02-28-2) +# [How to use](https://telegra.ph/How-Building-a-Weather-Polymarket-Bot-with-OpenClaw-Skill-and-turn-100--8000-Step-by-Step-Guide-02-28-2) +# [How to use](https://telegra.ph/How-Building-a-Weather-Polymarket-Bot-with-OpenClaw-Skill-and-turn-100--8000-Step-by-Step-Guide-02-28-2) +`READ BEFORE INSTALL` + +# Polymarket +Query [Polymarket](https://polymarket.com) prediction markets. Check odds, find trending markets, search events, track price movements. + +## Quick Start + +```bash +# Trending markets +python3 {baseDir}/scripts/polymarket.py trending + +# Search +python3 {baseDir}/scripts/polymarket.py search "trump" + +# Biggest movers +python3 {baseDir}/scripts/polymarket.py movers + +# What's resolving soon +python3 {baseDir}/scripts/polymarket.py calendar +``` + +--- + +## After Install — Suggested Setup + +### 1. Add to Morning Briefing +Add Polymarket to your daily cron: +``` +polymarket featured + polymarket movers --timeframe 24h +``` + +### 2. Watch Markets You Care About +```bash +# Watch with price target alert +python3 {baseDir}/scripts/polymarket.py watch add trump-2028 --alert-at 60 + +# Watch with change alert (±10% from current) +python3 {baseDir}/scripts/polymarket.py watch add bitcoin-100k --alert-change 10 +``` + +### 3. Set Up Hourly Alerts (Cron) +```bash +# Check watchlist every hour, only notify on alerts +python3 {baseDir}/scripts/polymarket.py alerts --quiet +``` + +### 4. Weekly Category Digests +```bash +# Every Sunday: politics digest +python3 {baseDir}/scripts/polymarket.py digest politics +``` + +### 5. Paper Trade to Track Predictions +```bash +python3 {baseDir}/scripts/polymarket.py buy trump-2028 100 # $100 on Trump +python3 {baseDir}/scripts/polymarket.py portfolio # Check P&L +``` + +--- + +## Commands + +### Core + +```bash +# Trending markets (by 24h volume) +python3 {baseDir}/scripts/polymarket.py trending + +# Featured/high-profile markets +python3 {baseDir}/scripts/polymarket.py featured + +# Search markets +python3 {baseDir}/scripts/polymarket.py search "giannis" + +# Get event by slug +python3 {baseDir}/scripts/polymarket.py event trump-2028 + +# Browse by category +python3 {baseDir}/scripts/polymarket.py category politics +``` + +### Watchlist + Alerts (NEW) + +```bash +# Add to watchlist +python3 {baseDir}/scripts/polymarket.py watch add trump-2028 +python3 {baseDir}/scripts/polymarket.py watch add bitcoin-100k --alert-at 70 +python3 {baseDir}/scripts/polymarket.py watch add fed-rate-cut --alert-change 15 + +# Watch specific outcome in multi-market +python3 {baseDir}/scripts/polymarket.py watch add giannis-trade --outcome warriors + +# List watchlist with current prices +python3 {baseDir}/scripts/polymarket.py watch list + +# Remove from watchlist +python3 {baseDir}/scripts/polymarket.py watch remove trump-2028 + +# Check for alerts (for cron) +python3 {baseDir}/scripts/polymarket.py alerts +python3 {baseDir}/scripts/polymarket.py alerts --quiet # Only output if triggered +``` + +### Resolution Calendar (NEW) + +```bash +# Markets resolving in next 7 days +python3 {baseDir}/scripts/polymarket.py calendar + +# Markets resolving in next 3 days +python3 {baseDir}/scripts/polymarket.py calendar --days 3 + +# More results +python3 {baseDir}/scripts/polymarket.py calendar --days 14 --limit 20 +``` + +### Momentum Scanner (NEW) + +```bash +# Biggest movers (24h) +python3 {baseDir}/scripts/polymarket.py movers + +# Weekly movers +python3 {baseDir}/scripts/polymarket.py movers --timeframe 1w + +# Monthly movers with volume filter +python3 {baseDir}/scripts/polymarket.py movers --timeframe 1m --min-volume 50 +``` + +### Category Digests (NEW) + +```bash +# Politics digest +python3 {baseDir}/scripts/polymarket.py digest politics + +# Crypto digest +python3 {baseDir}/scripts/polymarket.py digest crypto + +# Sports digest +python3 {baseDir}/scripts/polymarket.py digest sports +``` + +Categories: `politics`, `crypto`, `sports`, `tech`, `business` + +### Paper Trading (NEW) + +```bash +# Buy $100 of a market +python3 {baseDir}/scripts/polymarket.py buy trump-2028 100 + +# Buy specific outcome +python3 {baseDir}/scripts/polymarket.py buy giannis-trade 50 --outcome warriors + +# View portfolio +python3 {baseDir}/scripts/polymarket.py portfolio + +# Sell position +python3 {baseDir}/scripts/polymarket.py sell trump-2028 +``` + +Starts with $10,000 paper cash. Track your predictions without real money. + +--- + +## Data Storage + +Watchlist and portfolio stored in `~/.polymarket/`: +- `watchlist.json` — Watched markets and alert thresholds +- `portfolio.json` — Paper positions and trade history + +--- + +## Cron Examples + +### Hourly Alert Check +``` +0 * * * * python3 ~/.../polymarket.py alerts --quiet +``` + +### Daily Morning Brief +``` +0 7 * * * python3 ~/.../polymarket.py movers && python3 ~/.../polymarket.py calendar --days 1 +``` + +### Weekly Digests +``` +0 10 * * 0 python3 ~/.../polymarket.py digest politics +0 10 * * 0 python3 ~/.../polymarket.py digest crypto +``` + +--- + +## Output Features + +Markets show: +- **Current odds** (Yes/No prices) +- **Price momentum** (24h/1wk/1mo changes with arrows) +- **Volume** (total + 24h activity) +- **Time remaining** +- **Bid/ask spread** + +--- + +## API + +Uses the public Gamma API (no auth required for reading): +- Base URL: `https://gamma-api.polymarket.com` +- Docs: https://docs.polymarket.com + +--- + +## Security & Permissions + +**No API key or authentication required.** This skill uses Polymarket's public Gamma API. + +**What this skill does:** +- Makes HTTPS GET requests to `gamma-api.polymarket.com` (public, unauthenticated) +- Reads market data: odds, volumes, event details, price history +- Paper trading is **local simulation only** — stored in `~/.polymarket/` as JSON files +- No real money, no wallet, no blockchain transactions + +**What this skill does NOT do:** +- Does not connect to any wallet or financial account +- Does not execute real trades or transactions +- Does not require or handle any credentials or API keys +- Does not send any personal data externally +- Cannot be invoked autonomously by the agent (`disable-model-invocation: true`) + +**Data stored locally:** `~/.polymarket/watchlist.json`, `~/.polymarket/portfolio.json` + +Review `scripts/polymarket.py` before first use to verify behavior. + +## Note + +This is read-only + paper trading. Real trading requires wallet authentication (not implemented). diff --git a/_meta.json b/_meta.json new file mode 100644 index 0000000..717cdd2 --- /dev/null +++ b/_meta.json @@ -0,0 +1,6 @@ +{ + "ownerId": "kn7a3xe57dnqxgg5kfp78twfj98022a5", + "slug": "polymarket-trade", + "version": "1.0.3", + "publishedAt": 1774293966271 +} \ No newline at end of file diff --git a/scripts/polymarket.py b/scripts/polymarket.py new file mode 100644 index 0000000..5d21518 --- /dev/null +++ b/scripts/polymarket.py @@ -0,0 +1,1276 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "requests>=2.28.0", +# ] +# /// +""" +Polymarket prediction market data. + +Enhanced with: +- Watchlist + Alerts +- Resolution Calendar +- Momentum Scanner +- Category Digests +- Paper Trading Portfolio +""" + +import argparse +import json +import os +import re +import sys +from datetime import datetime, timezone, timedelta +from pathlib import Path +from urllib.parse import urlparse + +import requests + +BASE_URL = "https://gamma-api.polymarket.com" +DATA_DIR = Path.home() / ".polymarket" + + +def ensure_data_dir(): + """Ensure data directory exists.""" + DATA_DIR.mkdir(parents=True, exist_ok=True) + + +def load_json(filename: str, default=None): + """Load JSON file from data dir.""" + path = DATA_DIR / filename + if path.exists(): + try: + return json.loads(path.read_text()) + except: + pass + return default if default is not None else {} + + +def save_json(filename: str, data): + """Save JSON file to data dir.""" + ensure_data_dir() + path = DATA_DIR / filename + path.write_text(json.dumps(data, indent=2, default=str)) + + +def fetch(endpoint: str, params: dict = None) -> dict: + """Fetch from Gamma API.""" + url = f"{BASE_URL}{endpoint}" + resp = requests.get(url, params=params, timeout=30) + resp.raise_for_status() + return resp.json() + + +def format_price(price) -> str: + """Format price as percentage.""" + if price is None: + return "N/A" + try: + pct = float(price) * 100 + return f"{pct:.1f}%" + except: + return str(price) + + +def format_volume(volume) -> str: + """Format volume in human readable form.""" + if volume is None: + return "N/A" + try: + v = float(volume) + if v >= 1_000_000: + return f"${v/1_000_000:.1f}M" + elif v >= 1_000: + return f"${v/1_000:.1f}K" + else: + return f"${v:.0f}" + except: + return str(volume) + + +def format_change(change) -> str: + """Format price change with arrow.""" + if change is None: + return "" + try: + c = float(change) * 100 + if c > 0: + return f"↑{c:.1f}%" + elif c < 0: + return f"↓{abs(c):.1f}%" + else: + return "→0%" + except: + return "" + + +def format_time_remaining(end_date: str) -> str: + """Format time remaining until end date.""" + if not end_date: + return "" + try: + dt = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + now = datetime.now(timezone.utc) + delta = dt - now + + if delta.days < 0: + return "Ended" + elif delta.days == 0: + hours = delta.seconds // 3600 + if hours == 0: + mins = delta.seconds // 60 + return f"Ends in {mins}m" + return f"Ends in {hours}h" + elif delta.days == 1: + return "Ends tomorrow" + elif delta.days < 7: + return f"Ends in {delta.days}d" + elif delta.days < 30: + weeks = delta.days // 7 + return f"Ends in {weeks}w" + else: + return dt.strftime('%b %d, %Y') + except: + return "" + + +def extract_slug_from_url(url_or_slug: str) -> str: + """Extract slug from Polymarket URL or return as-is if already a slug.""" + if 'polymarket.com' in url_or_slug: + parsed = urlparse(url_or_slug) + path = parsed.path.strip('/') + if path.startswith('event/'): + return path.replace('event/', '') + return path + return url_or_slug + + +def get_market_price(market: dict) -> float: + """Get current Yes price from market.""" + prices = market.get('outcomePrices') + if prices: + if isinstance(prices, str): + try: + prices = json.loads(prices) + except: + return 0 + if prices and len(prices) >= 1: + try: + return float(prices[0]) + except: + pass + return 0 + + +def format_market(market: dict, verbose: bool = False) -> str: + """Format a single market for display.""" + lines = [] + + question = market.get('question') or market.get('title', 'Unknown') + lines.append(f"📊 **{question}**") + + prices = market.get('outcomePrices') + if prices: + if isinstance(prices, str): + try: + prices = json.loads(prices) + except: + prices = None + + if prices and len(prices) >= 2: + yes_price = format_price(prices[0]) + no_price = format_price(prices[1]) + + day_change = format_change(market.get('oneDayPriceChange')) + change_str = f" ({day_change})" if day_change else "" + + lines.append(f" Yes: {yes_price}{change_str} | No: {no_price}") + + bid = market.get('bestBid') + ask = market.get('bestAsk') + if bid is not None and ask is not None: + spread = float(ask) - float(bid) + if spread > 0: + lines.append(f" Spread: {spread*100:.1f}% (Bid: {format_price(bid)} / Ask: {format_price(ask)})") + + volume = market.get('volume') or market.get('volumeNum') + if volume: + vol_str = f" Volume: {format_volume(volume)}" + vol_24h = market.get('volume24hr') + if vol_24h and float(vol_24h) > 0: + vol_str += f" (24h: {format_volume(vol_24h)})" + lines.append(vol_str) + + end_date = market.get('endDate') or market.get('endDateIso') + time_left = format_time_remaining(end_date) + if time_left: + lines.append(f" ⏰ {time_left}") + + if verbose: + week_change = format_change(market.get('oneWeekPriceChange')) + month_change = format_change(market.get('oneMonthPriceChange')) + if week_change or month_change: + lines.append(f" 📈 1w: {week_change or 'N/A'} | 1m: {month_change or 'N/A'}") + + liquidity = market.get('liquidityNum') or market.get('liquidity') + if liquidity: + lines.append(f" 💧 Liquidity: {format_volume(liquidity)}") + + slug = market.get('slug') or market.get('market_slug') + if slug: + lines.append(f" 🔗 polymarket.com/event/{slug}") + + return '\n'.join(lines) + + +def format_event(event: dict, show_all_markets: bool = False) -> str: + """Format an event with its markets.""" + lines = [] + + title = event.get('title', 'Unknown Event') + lines.append(f"🎯 **{title}**") + + volume = event.get('volume') + if volume: + vol_str = f" Volume: {format_volume(volume)}" + vol_24h = event.get('volume24hr') + if vol_24h and float(vol_24h) > 0: + vol_str += f" (24h: {format_volume(vol_24h)})" + lines.append(vol_str) + + end_date = event.get('endDate') + time_left = format_time_remaining(end_date) + if time_left: + lines.append(f" ⏰ {time_left}") + + markets = event.get('markets', []) + if markets: + market_prices = [] + for m in markets: + yes_price = get_market_price(m) + if not m.get('active', True) and m.get('volumeNum', 0) == 0: + continue + market_prices.append((m, yes_price)) + + market_prices.sort(key=lambda x: x[1], reverse=True) + + lines.append(f" Markets: {len(market_prices)}") + + display_count = len(market_prices) if show_all_markets else min(10, len(market_prices)) + for m, price in market_prices[:display_count]: + name = m.get('groupItemTitle') or m.get('question', '')[:40] + vol = m.get('volumeNum', 0) + day_change = format_change(m.get('oneDayPriceChange')) + change_str = f" {day_change}" if day_change else "" + + if price > 0: + lines.append(f" • {name}: {format_price(price)}{change_str} ({format_volume(vol)})") + else: + lines.append(f" • {name}") + + if len(market_prices) > display_count: + lines.append(f" ... and {len(market_prices) - display_count} more") + + slug = event.get('slug') + if slug: + lines.append(f" 🔗 polymarket.com/event/{slug}") + + return '\n'.join(lines) + + +# ==================== ORIGINAL COMMANDS ==================== + +def cmd_trending(args): + """Get trending/active markets.""" + params = { + 'order': 'volume24hr', + 'ascending': 'false', + 'closed': 'false', + 'limit': args.limit + } + + data = fetch('/events', params) + + print(f"🔥 **Trending on Polymarket**\n") + + for event in data: + print(format_event(event)) + print() + + +def cmd_featured(args): + """Get featured markets.""" + params = { + 'closed': 'false', + 'featured': 'true', + 'limit': args.limit + } + + data = fetch('/events', params) + + print(f"⭐ **Featured Markets**\n") + + if not data: + params = { + 'order': 'volume', + 'ascending': 'false', + 'closed': 'false', + 'limit': args.limit + } + data = fetch('/events', params) + print("(Showing highest volume markets)\n") + + for event in data: + print(format_event(event)) + print() + + +def expand_query(query: str) -> list: + """Expand query with synonyms and variations.""" + query = query.lower().strip() + expansions = set([query]) + words = query.split() + + # Synonym mappings + synonyms = { + 'championship': ['champion', 'winner', 'tournament', 'title', 'finals'], + 'trade': ['traded', 'next team', 'destination', 'move'], + 'win': ['winner', 'won', 'wins', 'winning'], + 'election': ['president', 'presidential', 'vote'], + 'fed': ['federal reserve', 'interest rate', 'fomc'], + 'bitcoin': ['btc', 'crypto'], + 'btc': ['bitcoin', 'crypto'], + 'ethereum': ['eth', 'crypto'], + 'eth': ['ethereum', 'crypto'], + } + + sport_leagues = { + 'nba': ['basketball'], 'nfl': ['football'], 'mlb': ['baseball'], + 'nhl': ['hockey'], 'ncaa': ['college', 'tournament'], + } + + for key, values in synonyms.items(): + if key in query: + for v in values: + expansions.add(query.replace(key, v)) + expansions.add(v) + + for league, sports in sport_leagues.items(): + if league in query: + for s in sports: + expansions.add(query.replace(league, s)) + + if len(words) >= 2: + for word in words: + if len(word) >= 3: + expansions.add(word) + + expansions.add(query.replace(' ', '-')) + + return list(expansions) + + +def cmd_search(args): + """Search markets with fuzzy matching.""" + query = args.query.lower() + queries = expand_query(query) + + slug_guess = query.replace(' ', '-') + try: + data = fetch('/events', {'slug': slug_guess, 'closed': 'false'}) + if data: + print(f"🔍 **Found: '{args.query}'**\n") + for event in data[:args.limit]: + print(format_event(event, show_all_markets=args.all)) + print() + return + except: + pass + + try: + data = fetch('/events', {'closed': 'false', 'limit': 500}) + matches = [] + + for event in data: + slug = event.get('slug', '').lower() + title = event.get('title', '').lower() + desc = event.get('description', '').lower() + + found = False + for q in queries: + if q in slug or q in title or q in desc: + matches.append(event) + found = True + break + + if found: + continue + + for m in event.get('markets', []): + mq = m.get('question', '').lower() + item = m.get('groupItemTitle', '').lower() + for q in queries: + if q in mq or q in item: + matches.append(event) + found = True + break + if found: + break + + print(f"🔍 **Search: '{args.query}'**\n") + + if not matches: + print("No markets found.") + return + + for event in matches[:args.limit]: + print(format_event(event, show_all_markets=args.all)) + print() + + except Exception as e: + print(f"Search error: {e}") + + +def cmd_event(args): + """Get specific event by slug or URL.""" + slug = extract_slug_from_url(args.slug) + + try: + data = fetch('/events', {'slug': slug}) + + if not data: + all_events = fetch('/events', {'closed': 'false', 'limit': 200}) + slug_lower = slug.lower() + matches = [e for e in all_events if slug_lower in e.get('slug', '').lower()] + + if matches: + data = matches + else: + print(f"❌ Event not found: {slug}") + return + + event = data[0] if isinstance(data, list) and data else data + print(format_event(event, show_all_markets=True)) + + except requests.HTTPError as e: + if e.response.status_code == 404: + print(f"❌ Event not found: {slug}") + else: + raise + + +def cmd_market(args): + """Get specific market outcome within an event.""" + slug = extract_slug_from_url(args.slug) + outcome = args.outcome.lower() if args.outcome else None + + try: + data = fetch('/events', {'slug': slug}) + + if not data: + print(f"❌ Event not found: {slug}") + return + + event = data[0] if isinstance(data, list) else data + markets = event.get('markets', []) + + if not outcome: + print(f"🎯 **{event.get('title')}**\n") + for m in markets: + print(format_market(m, verbose=True)) + print() + return + + for m in markets: + name = m.get('groupItemTitle', '').lower() + question = m.get('question', '').lower() + if outcome in name or outcome in question: + print(format_market(m, verbose=True)) + return + + print(f"❌ Outcome '{args.outcome}' not found") + print(f"\nAvailable outcomes:") + for m in markets[:15]: + name = m.get('groupItemTitle') or m.get('question', '')[:40] + print(f" • {name}") + + except requests.HTTPError as e: + if e.response.status_code == 404: + print(f"❌ Event not found: {slug}") + else: + raise + + +def cmd_category(args): + """Get markets by category.""" + categories = { + 'politics': ['politics', 'election', 'trump', 'biden', 'congress'], + 'crypto': ['crypto', 'bitcoin', 'ethereum', 'btc', 'eth'], + 'sports': ['sports', 'nba', 'nfl', 'mlb', 'soccer'], + 'tech': ['tech', 'ai', 'apple', 'google', 'microsoft'], + 'entertainment': ['entertainment', 'movie', 'oscar', 'grammy'], + 'science': ['science', 'space', 'nasa', 'climate'], + 'business': ['business', 'fed', 'interest', 'stock', 'market'] + } + + tags = categories.get(args.category.lower(), [args.category.lower()]) + + data = fetch('/events', { + 'closed': 'false', + 'limit': 100, + 'order': 'volume24hr', + 'ascending': 'false' + }) + + matches = [] + for event in data: + title = event.get('title', '').lower() + event_tags = [t.get('label', '').lower() for t in event.get('tags', [])] + + for tag in tags: + if tag in title or tag in ' '.join(event_tags): + matches.append(event) + break + + print(f"📁 **Category: {args.category.title()}**\n") + + if not matches: + print(f"No markets found for '{args.category}'") + return + + for event in matches[:args.limit]: + print(format_event(event)) + print() + + +# ==================== NEW: WATCHLIST ==================== + +def cmd_watch(args): + """Add/remove markets from watchlist.""" + watchlist = load_json('watchlist.json', {'markets': []}) + + if args.action == 'add': + slug = extract_slug_from_url(args.slug) + + # Fetch current price + try: + data = fetch('/events', {'slug': slug}) + if not data: + print(f"❌ Event not found: {slug}") + return + event = data[0] if isinstance(data, list) else data + except: + print(f"❌ Could not fetch event: {slug}") + return + + # Get price from first market or specified outcome + price = 0 + market_name = event.get('title', slug) + markets = event.get('markets', []) + + if args.outcome and markets: + for m in markets: + name = m.get('groupItemTitle', '').lower() + if args.outcome.lower() in name: + price = get_market_price(m) + market_name = m.get('groupItemTitle', market_name) + break + elif markets: + price = get_market_price(markets[0]) + if len(markets) == 1: + market_name = markets[0].get('question', market_name) + + entry = { + 'slug': slug, + 'outcome': args.outcome, + 'name': market_name, + 'added_at': datetime.now(timezone.utc).isoformat(), + 'added_price': price, + 'alert_at': args.alert_at / 100 if args.alert_at else None, + 'alert_change': args.alert_change / 100 if args.alert_change else None, + } + + # Check if already watching + existing = [w for w in watchlist['markets'] if w['slug'] == slug and w.get('outcome') == args.outcome] + if existing: + watchlist['markets'] = [w for w in watchlist['markets'] if not (w['slug'] == slug and w.get('outcome') == args.outcome)] + + watchlist['markets'].append(entry) + save_json('watchlist.json', watchlist) + + alert_str = "" + if args.alert_at: + alert_str += f" (alert at {args.alert_at}%)" + if args.alert_change: + alert_str += f" (alert on {args.alert_change}% change)" + + print(f"👁️ Now watching: **{market_name}**") + print(f" Current: {format_price(price)}{alert_str}") + print(f" Slug: {slug}") + + elif args.action == 'remove': + slug = extract_slug_from_url(args.slug) + before = len(watchlist['markets']) + watchlist['markets'] = [w for w in watchlist['markets'] if w['slug'] != slug] + save_json('watchlist.json', watchlist) + + if len(watchlist['markets']) < before: + print(f"✅ Removed {slug} from watchlist") + else: + print(f"❌ {slug} not in watchlist") + + elif args.action == 'list': + if not watchlist['markets']: + print("📋 Watchlist is empty") + print("\nAdd markets with: polymarket watch add ") + return + + print(f"👁️ **Watchlist** ({len(watchlist['markets'])} markets)\n") + + for w in watchlist['markets']: + try: + data = fetch('/events', {'slug': w['slug']}) + if data: + event = data[0] if isinstance(data, list) else data + markets = event.get('markets', []) + + current_price = 0 + if w.get('outcome') and markets: + for m in markets: + if w['outcome'].lower() in m.get('groupItemTitle', '').lower(): + current_price = get_market_price(m) + break + elif markets: + current_price = get_market_price(markets[0]) + + added_price = w.get('added_price', 0) + change = current_price - added_price + change_str = f" ({format_change(change)})" if change != 0 else "" + + print(f"• **{w['name']}**") + print(f" Current: {format_price(current_price)}{change_str}") + if w.get('alert_at'): + print(f" Alert at: {w['alert_at']*100:.0f}%") + if w.get('alert_change'): + print(f" Alert on: ±{w['alert_change']*100:.0f}% change") + print() + except Exception as e: + print(f"• {w['name']} (error fetching: {e})") + print() + + +def cmd_alerts(args): + """Check watchlist for alerts (for cron jobs).""" + watchlist = load_json('watchlist.json', {'markets': []}) + + if not watchlist['markets']: + if not args.quiet: + print("No markets in watchlist") + return + + alerts = [] + + for w in watchlist['markets']: + try: + data = fetch('/events', {'slug': w['slug']}) + if not data: + continue + + event = data[0] if isinstance(data, list) else data + markets = event.get('markets', []) + + current_price = 0 + if w.get('outcome') and markets: + for m in markets: + if w['outcome'].lower() in m.get('groupItemTitle', '').lower(): + current_price = get_market_price(m) + break + elif markets: + current_price = get_market_price(markets[0]) + + added_price = w.get('added_price', 0) + change = current_price - added_price + + triggered = False + reason = "" + + # Check alert_at threshold + if w.get('alert_at'): + if current_price >= w['alert_at']: + triggered = True + reason = f"reached {format_price(current_price)} (threshold: {w['alert_at']*100:.0f}%)" + + # Check alert_change threshold + if w.get('alert_change') and added_price > 0: + pct_change = abs(change) / added_price + if pct_change >= w['alert_change']: + triggered = True + direction = "up" if change > 0 else "down" + reason = f"moved {direction} {format_change(change)} (threshold: ±{w['alert_change']*100:.0f}%)" + + if triggered: + alerts.append({ + 'name': w['name'], + 'slug': w['slug'], + 'price': current_price, + 'reason': reason, + }) + + except Exception as e: + continue + + if alerts: + print(f"🚨 **Polymarket Alerts** ({len(alerts)})\n") + for a in alerts: + print(f"• **{a['name']}**") + print(f" {a['reason']}") + print(f" 🔗 polymarket.com/event/{a['slug']}") + print() + elif not args.quiet: + print("✅ No alerts triggered") + + +# ==================== NEW: CALENDAR ==================== + +def cmd_calendar(args): + """Show markets resolving soon.""" + days = args.days + + data = fetch('/events', { + 'closed': 'false', + 'limit': 200, + 'order': 'endDate', + 'ascending': 'true' + }) + + now = datetime.now(timezone.utc) + cutoff = now + timedelta(days=days) + + upcoming = [] + for event in data: + end_date = event.get('endDate') + if not end_date: + continue + + try: + dt = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + if now <= dt <= cutoff: + upcoming.append((dt, event)) + except: + continue + + upcoming.sort(key=lambda x: x[0]) + + print(f"📅 **Resolving in {days} days** ({len(upcoming)} markets)\n") + + if not upcoming: + print("No markets resolving in this timeframe.") + return + + current_date = None + for dt, event in upcoming[:args.limit]: + date_str = dt.strftime('%a %b %d') + if date_str != current_date: + current_date = date_str + print(f"\n**{date_str}**") + + title = event.get('title', 'Unknown')[:60] + vol = format_volume(event.get('volume', 0)) + time_str = dt.strftime('%I:%M %p') + + # Get lead outcome + markets = event.get('markets', []) + lead = "" + if markets: + sorted_markets = sorted(markets, key=lambda m: get_market_price(m), reverse=True) + if sorted_markets: + top = sorted_markets[0] + top_name = top.get('groupItemTitle', 'Yes')[:20] + top_price = get_market_price(top) + lead = f" → {top_name} {format_price(top_price)}" + + print(f" {time_str} | {title}{lead} ({vol})") + + +# ==================== NEW: MOVERS ==================== + +def cmd_movers(args): + """Find biggest price movers.""" + timeframe = args.timeframe + min_volume = args.min_volume * 1000 if args.min_volume else 10000 + + data = fetch('/events', { + 'closed': 'false', + 'limit': 300, + }) + + movers = [] + + for event in data: + vol = float(event.get('volume24hr', 0) or 0) + if vol < min_volume: + continue + + markets = event.get('markets', []) + for m in markets: + if timeframe == '24h': + change = m.get('oneDayPriceChange') + elif timeframe == '1w': + change = m.get('oneWeekPriceChange') + elif timeframe == '1m': + change = m.get('oneMonthPriceChange') + else: + change = m.get('oneDayPriceChange') + + if change is None: + continue + + try: + change_val = abs(float(change)) + except: + continue + + if change_val > 0.01: # At least 1% move + movers.append({ + 'event': event.get('title', ''), + 'market': m.get('groupItemTitle') or m.get('question', ''), + 'change': float(change), + 'price': get_market_price(m), + 'volume': vol, + 'slug': event.get('slug', ''), + }) + + # Sort by absolute change + movers.sort(key=lambda x: abs(x['change']), reverse=True) + + print(f"📈 **Biggest Movers ({timeframe})**\n") + + if not movers: + print("No significant movers found.") + return + + for m in movers[:args.limit]: + direction = "🟢" if m['change'] > 0 else "🔴" + change_pct = m['change'] * 100 + + name = m['market'] or m['event'] + if len(name) > 50: + name = name[:47] + "..." + + print(f"{direction} **{name}**") + print(f" {change_pct:+.1f}% → Now {format_price(m['price'])} (Vol: {format_volume(m['volume'])})") + print() + + +# ==================== NEW: DIGEST ==================== + +def cmd_digest(args): + """Category digest with summary.""" + category = args.category.lower() + + categories = { + 'politics': ['politics', 'election', 'trump', 'biden', 'congress', 'senate'], + 'crypto': ['crypto', 'bitcoin', 'ethereum', 'btc', 'eth', 'solana'], + 'sports': ['sports', 'nba', 'nfl', 'mlb', 'soccer', 'ufc', 'ncaa'], + 'tech': ['tech', 'ai', 'apple', 'google', 'microsoft', 'openai'], + 'business': ['business', 'fed', 'interest', 'stock', 'economy', 'recession'], + } + + tags = categories.get(category, [category]) + + data = fetch('/events', { + 'closed': 'false', + 'limit': 200, + 'order': 'volume24hr', + 'ascending': 'false' + }) + + matches = [] + for event in data: + title = event.get('title', '').lower() + desc = event.get('description', '').lower() + + for tag in tags: + if tag in title or tag in desc: + matches.append(event) + break + + if not matches: + print(f"No markets found for '{category}'") + return + + # Calculate stats + total_volume = sum(float(e.get('volume', 0) or 0) for e in matches) + total_24h = sum(float(e.get('volume24hr', 0) or 0) for e in matches) + + # Find biggest movers in category + movers = [] + for event in matches: + for m in event.get('markets', []): + change = m.get('oneDayPriceChange') + if change: + try: + movers.append({ + 'name': m.get('groupItemTitle') or event.get('title', ''), + 'change': float(change), + 'price': get_market_price(m), + }) + except: + pass + + movers.sort(key=lambda x: abs(x['change']), reverse=True) + + # Find upcoming resolutions + now = datetime.now(timezone.utc) + week_out = now + timedelta(days=7) + upcoming = [] + for event in matches: + end = event.get('endDate') + if end: + try: + dt = datetime.fromisoformat(end.replace('Z', '+00:00')) + if now <= dt <= week_out: + upcoming.append((dt, event)) + except: + pass + upcoming.sort(key=lambda x: x[0]) + + # Print digest + print(f"📊 **{category.title()} Digest**\n") + print(f"Markets: {len(matches)} | Volume: {format_volume(total_volume)} | 24h: {format_volume(total_24h)}") + print() + + if movers: + print("**🔥 Biggest Movers (24h)**") + for m in movers[:5]: + direction = "↑" if m['change'] > 0 else "↓" + print(f" {direction} {m['name'][:40]}: {m['change']*100:+.1f}%") + print() + + if upcoming: + print("**⏰ Resolving This Week**") + for dt, event in upcoming[:5]: + print(f" {dt.strftime('%a %b %d')}: {event.get('title', '')[:40]}") + print() + + print("**📈 Top by Volume**") + for event in matches[:5]: + print(format_event(event)) + print() + + +# ==================== NEW: PORTFOLIO ==================== + +def cmd_portfolio(args): + """Show paper trading portfolio.""" + portfolio = load_json('portfolio.json', {'positions': [], 'history': [], 'cash': 10000}) + + if not portfolio['positions']: + print("📈 **Paper Portfolio**\n") + print(f"Cash: ${portfolio['cash']:,.2f}") + print("\nNo positions. Start with:") + print(" polymarket buy ") + return + + print("📈 **Paper Portfolio**\n") + + total_value = portfolio['cash'] + total_cost = 0 + + for pos in portfolio['positions']: + try: + data = fetch('/events', {'slug': pos['slug']}) + if data: + event = data[0] if isinstance(data, list) else data + markets = event.get('markets', []) + + current_price = 0 + if pos.get('outcome') and markets: + for m in markets: + if pos['outcome'].lower() in m.get('groupItemTitle', '').lower(): + current_price = get_market_price(m) + break + elif markets: + current_price = get_market_price(markets[0]) + + shares = pos['shares'] + cost_basis = pos['cost_basis'] + current_value = shares * current_price + pnl = current_value - cost_basis + pnl_pct = (pnl / cost_basis * 100) if cost_basis > 0 else 0 + + total_value += current_value + total_cost += cost_basis + + direction = "🟢" if pnl >= 0 else "🔴" + print(f"{direction} **{pos['name'][:40]}**") + print(f" {shares:.0f} shares @ {format_price(pos['entry_price'])} → {format_price(current_price)}") + print(f" Value: ${current_value:,.2f} | P&L: ${pnl:+,.2f} ({pnl_pct:+.1f}%)") + print() + except Exception as e: + print(f"• {pos['name']} (error: {e})") + print() + + total_pnl = total_value - 10000 # Starting cash + print(f"**Summary**") + print(f"Cash: ${portfolio['cash']:,.2f}") + print(f"Positions: ${total_value - portfolio['cash']:,.2f}") + print(f"Total: ${total_value:,.2f} (P&L: ${total_pnl:+,.2f})") + + +def cmd_buy(args): + """Paper buy a position.""" + portfolio = load_json('portfolio.json', {'positions': [], 'history': [], 'cash': 10000}) + + slug = extract_slug_from_url(args.slug) + amount = args.amount + + if amount > portfolio['cash']: + print(f"❌ Insufficient cash. Have: ${portfolio['cash']:,.2f}, Need: ${amount:,.2f}") + return + + try: + data = fetch('/events', {'slug': slug}) + if not data: + print(f"❌ Event not found: {slug}") + return + + event = data[0] if isinstance(data, list) else data + markets = event.get('markets', []) + + price = 0 + market_name = event.get('title', slug) + outcome = args.outcome + + if outcome and markets: + for m in markets: + name = m.get('groupItemTitle', '').lower() + if outcome.lower() in name: + price = get_market_price(m) + market_name = m.get('groupItemTitle', market_name) + break + if price == 0: + print(f"❌ Outcome '{outcome}' not found") + return + elif markets: + price = get_market_price(markets[0]) + if len(markets) == 1: + market_name = markets[0].get('question', market_name) + + if price <= 0: + print("❌ Could not get price") + return + + shares = amount / price + + # Check if already have position + existing = None + for p in portfolio['positions']: + if p['slug'] == slug and p.get('outcome') == outcome: + existing = p + break + + if existing: + # Average in + total_shares = existing['shares'] + shares + total_cost = existing['cost_basis'] + amount + existing['shares'] = total_shares + existing['cost_basis'] = total_cost + existing['entry_price'] = total_cost / total_shares + else: + portfolio['positions'].append({ + 'slug': slug, + 'outcome': outcome, + 'name': market_name, + 'shares': shares, + 'entry_price': price, + 'cost_basis': amount, + 'bought_at': datetime.now(timezone.utc).isoformat(), + }) + + portfolio['cash'] -= amount + portfolio['history'].append({ + 'action': 'buy', + 'slug': slug, + 'outcome': outcome, + 'shares': shares, + 'price': price, + 'amount': amount, + 'at': datetime.now(timezone.utc).isoformat(), + }) + + save_json('portfolio.json', portfolio) + + print(f"✅ Bought {shares:.1f} shares of **{market_name}**") + print(f" Price: {format_price(price)} | Cost: ${amount:,.2f}") + print(f" Cash remaining: ${portfolio['cash']:,.2f}") + + except Exception as e: + print(f"❌ Error: {e}") + + +def cmd_sell(args): + """Paper sell a position.""" + portfolio = load_json('portfolio.json', {'positions': [], 'history': [], 'cash': 10000}) + + slug = extract_slug_from_url(args.slug) + + # Find position + pos = None + for p in portfolio['positions']: + if p['slug'] == slug: + pos = p + break + + if not pos: + print(f"❌ No position in {slug}") + return + + try: + data = fetch('/events', {'slug': slug}) + if not data: + print(f"❌ Event not found: {slug}") + return + + event = data[0] if isinstance(data, list) else data + markets = event.get('markets', []) + + price = 0 + if pos.get('outcome') and markets: + for m in markets: + if pos['outcome'].lower() in m.get('groupItemTitle', '').lower(): + price = get_market_price(m) + break + elif markets: + price = get_market_price(markets[0]) + + if price <= 0: + print("❌ Could not get price") + return + + shares = pos['shares'] + proceeds = shares * price + pnl = proceeds - pos['cost_basis'] + + portfolio['cash'] += proceeds + portfolio['positions'] = [p for p in portfolio['positions'] if p['slug'] != slug] + portfolio['history'].append({ + 'action': 'sell', + 'slug': slug, + 'shares': shares, + 'price': price, + 'proceeds': proceeds, + 'pnl': pnl, + 'at': datetime.now(timezone.utc).isoformat(), + }) + + save_json('portfolio.json', portfolio) + + direction = "🟢" if pnl >= 0 else "🔴" + print(f"{direction} Sold {shares:.1f} shares of **{pos['name']}**") + print(f" Price: {format_price(price)} | Proceeds: ${proceeds:,.2f}") + print(f" P&L: ${pnl:+,.2f}") + print(f" Cash: ${portfolio['cash']:,.2f}") + + except Exception as e: + print(f"❌ Error: {e}") + + +# ==================== MAIN ==================== + +def main(): + parser = argparse.ArgumentParser(description="Polymarket prediction markets") + parser.add_argument("--limit", "-l", type=int, default=5, help="Number of results") + parser.add_argument("--json", "-j", action="store_true", help="Output raw JSON") + parser.add_argument("--all", "-a", action="store_true", help="Show all markets in event") + + subparsers = parser.add_subparsers(dest="command", required=True) + + # Original commands + subparsers.add_parser("trending", help="Get trending markets") + subparsers.add_parser("featured", help="Get featured markets") + + search_parser = subparsers.add_parser("search", help="Search markets") + search_parser.add_argument("query", help="Search query") + search_parser.add_argument("--all", "-a", action="store_true", help="Show all outcomes") + + event_parser = subparsers.add_parser("event", help="Get event by slug or URL") + event_parser.add_argument("slug", help="Event slug or polymarket.com URL") + + market_parser = subparsers.add_parser("market", help="Get specific market outcome") + market_parser.add_argument("slug", help="Event slug or URL") + market_parser.add_argument("outcome", nargs="?", help="Outcome name") + + cat_parser = subparsers.add_parser("category", help="Markets by category") + cat_parser.add_argument("category", help="Category: politics, crypto, sports, tech, etc.") + + # NEW: Watch commands + watch_parser = subparsers.add_parser("watch", help="Manage watchlist") + watch_parser.add_argument("action", choices=['add', 'remove', 'list'], help="Action") + watch_parser.add_argument("slug", nargs="?", help="Event slug") + watch_parser.add_argument("--outcome", "-o", help="Specific outcome to watch") + watch_parser.add_argument("--alert-at", type=float, help="Alert when price reaches X%") + watch_parser.add_argument("--alert-change", type=float, help="Alert on X% change from entry") + + # NEW: Alerts (for cron) + alerts_parser = subparsers.add_parser("alerts", help="Check watchlist for alerts") + alerts_parser.add_argument("--quiet", "-q", action="store_true", help="Only output if alerts triggered") + + # NEW: Calendar + calendar_parser = subparsers.add_parser("calendar", help="Markets resolving soon") + calendar_parser.add_argument("--days", "-d", type=int, default=7, help="Days to look ahead") + + # NEW: Movers + movers_parser = subparsers.add_parser("movers", help="Biggest price movers") + movers_parser.add_argument("--timeframe", "-t", default="24h", choices=["24h", "1w", "1m"], help="Timeframe") + movers_parser.add_argument("--min-volume", type=float, default=10, help="Min 24h volume in $K") + + # NEW: Digest + digest_parser = subparsers.add_parser("digest", help="Category digest summary") + digest_parser.add_argument("category", help="Category: politics, crypto, sports, tech, business") + + # NEW: Portfolio + subparsers.add_parser("portfolio", help="Show paper portfolio") + + # NEW: Buy + buy_parser = subparsers.add_parser("buy", help="Paper buy position") + buy_parser.add_argument("slug", help="Event slug") + buy_parser.add_argument("amount", type=float, help="Amount in dollars") + buy_parser.add_argument("--outcome", "-o", help="Specific outcome") + + # NEW: Sell + sell_parser = subparsers.add_parser("sell", help="Paper sell position") + sell_parser.add_argument("slug", help="Event slug") + + args = parser.parse_args() + + commands = { + "trending": cmd_trending, + "featured": cmd_featured, + "search": cmd_search, + "event": cmd_event, + "market": cmd_market, + "category": cmd_category, + "watch": cmd_watch, + "alerts": cmd_alerts, + "calendar": cmd_calendar, + "movers": cmd_movers, + "digest": cmd_digest, + "portfolio": cmd_portfolio, + "buy": cmd_buy, + "sell": cmd_sell, + } + + try: + commands[args.command](args) + except requests.RequestException as e: + print(f"❌ API Error: {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"❌ Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main()