文件预览

polymarket.py

查看 Polymarket 技能包中的文件内容。

文件内容

scripts/polymarket.py

#!/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 <slug>")
            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 <slug> <amount>")
        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()