From 125f51656de98ce509c2184c5c8d66c301c7d846 Mon Sep 17 00:00:00 2001 From: shoko <270575765+shokollm@users.noreply.github.com> Date: Tue, 24 Mar 2026 07:18:05 +0000 Subject: [PATCH] Add polymarket-browse skill Browse tradeable Polymarket events by game category. Supports Dota 2, Counter Strike, League of Legends, Valorant, NBA, NFL, UFC, Tennis. Features WIB timezone, compact 6-line format, exponential backoff rate limiting. --- .gitignore | 4 + .gitkeep | 0 skills/polymarket-browse/SKILL.md | 104 ++++ skills/polymarket-browse/scripts/browse.py | 549 +++++++++++++++++++++ 4 files changed, 657 insertions(+) create mode 100644 .gitignore create mode 100644 .gitkeep create mode 100644 skills/polymarket-browse/SKILL.md create mode 100644 skills/polymarket-browse/scripts/browse.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3b31bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.pyo +.DS_Store diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/skills/polymarket-browse/SKILL.md b/skills/polymarket-browse/SKILL.md new file mode 100644 index 0000000..de4a80e --- /dev/null +++ b/skills/polymarket-browse/SKILL.md @@ -0,0 +1,104 @@ +--- +name: polymarket-browse +category: research +description: Browse tradeable Polymarket events by game category. Shows active matches with ML odds (cents format), volume, tournament, and market URLs. Supports Counter Strike, League of Legends, Dota 2, Valorant, NBA, NFL, UFC, Tennis. +--- + +# Polymarket Event Browser + +Browse tradeable Polymarket prediction market events by game category. + +## Usage + +``` +polymarket-browse [--category "Counter Strike"] [--limit 5] [--detail N] +``` + +## Arguments + +- `--category` : Game category to browse. Options: All Esports, Counter Strike, League of Legends, Dota 2, Valorant, NBA, NFL, UFC, Tennis (default: Counter Strike) +- `--limit` : Max number of events to show (default: 5) +- `--detail` : Index of event (1-indexed) to show detailed markets for. Defaults to 1 (first event). Set to 0 to disable. +- `--list-categories` : List available game categories and exit + +## Output Format + +Each event displays in a **6-line format**: + +``` +=== DOTA 2 === +Query: 'Dota2' | Total API: 54 | Tradeable: 3 +Current time (WIB): 13:58 WIB | Mar 24, 2026 + +1. [Dota 2: Yakult Brothers vs BetBoom Team (BO2)](https://polymarket.com/market/...) + Mar 24, 19:00 WIB | In 5h + + Vol: $4,952 + Tournament: ESL One Birmingham Group A + Odds: Yakult Brothers 29c | 71c BetBoom Team + +2. [Dota 2: GamerLegion vs Team Yandex (BO2)](https://polymarket.com/market/...) + Mar 24, 19:00 WIB | In 5h + + Vol: $3,944 + Tournament: ESL One Birmingham Group A + Odds: GamerLegion 28c | 72c Team Yandex +``` + +Format per event: +- **Line 1**: `[number]. [Title](url)` — title without tournament name +- **Line 2**: `Date, Time WIB | Relative` (e.g., "Mar 24, 19:00 WIB | In 5h") +- **Line 3**: (empty separator) +- **Line 4**: `Vol: $XXX` +- **Line 5**: `Tournament: [tournament name]` +- **Line 6**: `Odds: [Team A] [Odds A] | [Odds B] [Team B]` + +Time shows in WIB (UTC+7 for Indonesian users). Relative time shows "In Xh", "Xd ago", or "LIVE". + +## Game Categories + +| Category | Search Term | +|---|---| +| All Esports | Esports | +| Counter Strike | Counter Strike | +| League of Legends | League of Legends | +| Dota 2 | Dota2 | +| Valorant | Valorant | +| NBA | NBA | +| NFL | Football | +| UFC | UFC | +| Tennis | Tennis | + +## Filters Applied + +Events are filtered to show only **tradeable** matches: +- Must have seriesSlug + gameId (actual match, not tournament future) +- ML volume > 0 +- acceptingOrders = true +- ML closed = false +- ML bestBid < 0.99 (market hasn't converged toward favorite) +- ML bestAsk > 0.01 (market hasn't converged toward underdog) +- BO2 matches that ended in a tie (1-1) are filtered out +- Event endDate has not passed (API doesn't always close ML market promptly) + +Markets are filtered to only tradeable ones: +- acceptingOrders = true +- bestBid < 0.99 +- bestAsk > 0.01 +- closed = false + +## Rate Limiting + +- Exponential backoff: 2s → 4s → 8s → 16s → 32s +- Max 5 retries before aborting + +## Known Issues + +- **BO2 matches that end in a tie (1-1)**: Filtered out by checking if all child_moneyline markets are closed. + +## Odds Format + +All odds are shown in **cents** format: +- `30c` = 0.30 probability +- `95c` = 0.95 probability +- `GamerLegion 28c | 72c Team Yandex` = GamerLegion at 28c, Team Yandex at 72c diff --git a/skills/polymarket-browse/scripts/browse.py b/skills/polymarket-browse/scripts/browse.py new file mode 100644 index 0000000..03de0ce --- /dev/null +++ b/skills/polymarket-browse/scripts/browse.py @@ -0,0 +1,549 @@ +#!/usr/bin/env python3 +""" +Polymarket Event Browser +Browse tradeable Polymarket events by game category. +""" + +import subprocess +import json +import time +import argparse +from datetime import datetime, timezone, timedelta + +# ============================================================ +# CONFIG +# ============================================================ + +FETCH_PAGES = 4 +PAGE_SIZE = 5 +DISPLAY_MAX = 10 +MAX_RETRIES = 5 +INITIAL_RETRY_DELAY = 2 # exponential backoff starts at 2s + +GAME_CATEGORIES = { + "All Esports": "Esports", + "Counter Strike": "Counter Strike", + "League of Legends": "League of Legends", + "Dota 2": "Dota2", + "Valorant": "Valorant", + "NBA": "NBA", + "NFL": "Football", + "UFC": "UFC", + "Tennis": "Tennis", +} + +# ============================================================ +# FETCH +# ============================================================ + +def fetch_page(q, page=1, max_retries=MAX_RETRIES, initial_delay=INITIAL_RETRY_DELAY): + base = "https://gamma-api.polymarket.com/public-search" + url = (f"{base}?q={q.replace(' ', '%20')}&limit={PAGE_SIZE}&page={page}" + f"&search_profiles=false&search_tags=false" + f"&keep_closed_markets=0&events_status=active&cache=false") + + delay = initial_delay + for attempt in range(max_retries): + time.sleep(delay) + r = subprocess.run( + ["curl", "-s", url, "--max-time", "10", "-H", "User-Agent: curl/7.88.1"], + capture_output=True + ) + + if r.returncode == 0 and len(r.stdout) > 0: + try: + return json.loads(r.stdout.decode('utf-8')) + except json.JSONDecodeError: + if attempt < max_retries - 1: + delay *= 2 # Exponential backoff + continue + return None + else: + # Rate limit or other error - exponential backoff + if attempt < max_retries - 1: + delay *= 2 + continue + return None + return None + +def fetch_all_pages(q, num_pages=FETCH_PAGES): + all_events = [] + total_raw = 0 + for page in range(1, num_pages + 1): + time.sleep(1) + data = fetch_page(q, page) + if data is None: + break + events = data.get("events", []) + total_raw = data.get("pagination", {}).get("totalResults", "?") + all_events.extend(events) + if len(events) < PAGE_SIZE: + break + return {"events": all_events, "total_raw": total_raw} + +# ============================================================ +# FILTERS +# ============================================================ + +def is_match_market(e): + return (e.get("seriesSlug") and e.get("gameId")) or " vs " in e.get("title", "") + +def get_ml_market(e): + for m in e.get("markets", []): + if m.get("sportsMarketType") == "moneyline": + return m + return None + +def get_ml_volume(e): + ml = get_ml_market(e) + return float(ml.get("volume", 0)) if ml else 0.0 + +def is_bo2_tie(e): + """ + Detect if this is a BO2 that ended in a tie (1-1). + Returns True if all child_moneyline markets are closed (match is over but tied). + """ + title = e.get("title", "") + if "BO2" not in title: + return False + + child_markets = [m for m in e.get("markets", []) if m.get("sportsMarketType") == "child_moneyline"] + if len(child_markets) != 2: + return False + + # If both child markets are closed, it's likely a finished BO2 + all_closed = all(m.get("closed", False) for m in child_markets) + return all_closed + +def is_tradeable_event(e): + ml = get_ml_market(e) + if not ml: + return False + best_bid = float(ml.get("bestBid", 0)) + best_ask = float(ml.get("bestAsk", 0)) + ml_vol = float(ml.get("volume", 0)) + accepting = ml.get("acceptingOrders", False) + ml_closed = ml.get("closed", True) + + # Filter: market has converged (bestBid >= 0.99 means near-resolved) + if best_bid >= 0.99: + return False + # Filter: market has converged the other way (bestAsk <= 0.01) + if best_ask <= 0.01: + return False + # Filter: no volume or not accepting orders or closed + if ml_vol <= 0: + return False + if not accepting: + return False + if ml_closed: + return False + # Filter: BO2 that ended in a tie (1-1) + if is_bo2_tie(e): + return False + # Filter: event has ended (check endDate - Polymarket API doesn't always close ML market promptly) + end_str = e.get("endDate", "") + if end_str: + try: + end_dt = datetime.fromisoformat(end_str.replace('Z', '+00:00')) + now = datetime.now(timezone.utc) + if end_dt < now: + return False + except: + pass + + return True + +def is_tradeable_market(m): + accepting = m.get("acceptingOrders", False) + closed = m.get("closed", True) + best_ask = float(m.get("bestAsk", 0)) + best_bid = float(m.get("bestBid", 0)) + + # Filter: market has converged (bestBid >= 0.99) + if best_bid >= 0.99: + return False + # Filter: market has converged the other way (bestAsk <= 0.01) + if best_ask <= 0.01: + return False + # Filter: not accepting orders or closed + if not accepting: + return False + if closed: + return False + + return True + +# ============================================================ +# FORMATTING +# ============================================================ + +def prob_to_cents(p): + return int(round(p * 100)) + +def format_odds(p): + return f"{prob_to_cents(p)}c" + +def format_spread(bid, ask): + spread = ask - bid + return f"{prob_to_cents(spread)}c" + +def get_match_time_status(e): + """ + Return a human-readable match time status. + Returns (status_str, urgency) where urgency is 0-3 (higher = more urgent/live). + Uses startTime for actual match start time. + Displays times in WIB (UTC+7 for Indonesian users). + """ + # Use startTime for actual match start, not startDate (which is market creation time) + start_str = e.get("startTime") or e.get("startDate", "") + + if not start_str: + return "TBD", 0 + + try: + start_dt = datetime.fromisoformat(start_str.replace('Z', '+00:00')) + now_utc = datetime.now(timezone.utc) + utc7 = timezone(timedelta(hours=7)) + now = now_utc.astimezone(utc7) + start_utc7 = start_dt.astimezone(utc7) + + delta = start_dt - now_utc + + if delta.total_seconds() < 0: + # Started already + hours_ago = abs(delta.total_seconds()) / 3600 + if hours_ago < 1: + return "LIVE", 3 + elif hours_ago < 4: + return f"LIVE {int(hours_ago)}h", 3 + elif hours_ago < 24: + return f"Started {int(hours_ago)}h ago", 1 + else: + days = int(hours_ago / 24) + return f"{days}d ago", 0 + else: + # Starts in future + hours_until = delta.total_seconds() / 3600 + if hours_until < 1: + mins = int(delta.total_seconds() / 60) + return f"In {mins}m", 3 + elif hours_until < 24: + return f"In {int(hours_until)}h", 2 + else: + days = int(hours_until / 24) + return f"In {days}d", 1 + except: + return "", 0 + +def get_match_time_str(e): + """ + Return just the time status string (e.g. 'LIVE', 'In 6h', 'In 1d'). + Uses startTime for actual match start time. + """ + start_str = e.get("startTime") or e.get("startDate", "") + if not start_str: + return "TBD" + try: + start_dt = datetime.fromisoformat(start_str.replace('Z', '+00:00')) + now_utc = datetime.now(timezone.utc) + delta = start_dt - now_utc + + if delta.total_seconds() < 0: + hours_ago = abs(delta.total_seconds()) / 3600 + if hours_ago < 1: + return "LIVE" + elif hours_ago < 4: + return f"LIVE {int(hours_ago)}h" + elif hours_ago < 24: + return f"{int(hours_ago)}h ago" + else: + days = int(hours_ago / 24) + return f"{days}d ago" + else: + hours_until = delta.total_seconds() / 3600 + if hours_until < 1: + mins = int(delta.total_seconds() / 60) + return f"In {mins}m" + elif hours_until < 24: + return f"In {int(hours_until)}h" + else: + days = int(hours_until / 24) + return f"In {days}d" + except: + return "" + +def filter_events(events): + return [e for e in events if is_match_market(e) and is_tradeable_event(e)] + +def sort_events(events): + return sorted(events, key=get_ml_volume, reverse=True) + +# ============================================================ +# BROWSE +# ============================================================ + +def browse_events(q, display_max=DISPLAY_MAX): + result = fetch_all_pages(q, FETCH_PAGES) + events = result["events"] + filtered = filter_events(events) + sorted_events = sort_events(filtered) + return { + "query": q, + "total_raw": result["total_raw"], + "total_filtered": len(filtered), + "events": sorted_events[:display_max], + } + +# ============================================================ +# FORMAT +# ============================================================ + +def format_event(e): + ml = get_ml_market(e) + outcomes = json.loads(ml.get("outcomes", "[]")) if ml else [] + prices = json.loads(ml.get("outcomePrices", "[]")) if ml else [] + best_bid = float(ml.get("bestBid", 0)) if ml else 0 + best_ask = float(ml.get("bestAsk", 0)) if ml else 0 + vol = get_ml_volume(e) + time_status, urgency = get_match_time_status(e) + + return { + "title": e.get("title", ""), + "time_status": time_status, + "time_urgency": urgency, + "url": f"https://polymarket.com/market/{e.get('slug')}", + "livestream": e.get("resolutionSource"), + "outcomes": outcomes, + "prices": prices, + "best_bid": best_bid, + "best_ask": best_ask, + "volume": vol, + } + +def format_detail_event(e): + ml = get_ml_market(e) + + active_markets = [ + m for m in e.get("markets", []) + if float(m.get("volume", 0)) > 0 and is_tradeable_market(m) + ] + active_markets = sorted(active_markets, key=lambda m: float(m.get("volume", 0)), reverse=True) + + time_status, urgency = get_match_time_status(e) + + return { + "title": e.get("title", ""), + "time_status": time_status, + "url": f"https://polymarket.com/market/{e.get('slug')}", + "livestream": e.get("resolutionSource"), + "outcomes": json.loads(ml.get("outcomes", "[]")) if ml else [], + "prices": json.loads(ml.get("outcomePrices", "[]")) if ml else [], + "best_bid": float(ml.get("bestBid", 0)) if ml else 0, + "best_ask": float(ml.get("bestAsk", 0)) if ml else 0, + "volume": get_ml_volume(e), + "markets": [ + { + "type": m.get("sportsMarketType"), + "question": m.get("question", ""), + "outcomes": json.loads(m.get("outcomes", "[]")), + "prices": json.loads(m.get("outcomePrices", "[]")), + "best_bid": float(m.get("bestBid", 0)), + "best_ask": float(m.get("bestAsk", 0)), + "volume": float(m.get("volume", 0)), + "url": f"https://polymarket.com/market/{m.get('slug')}", + } + for m in active_markets + ], + } + +# ============================================================ +# DISPLAY +# ============================================================ + +def get_start_time_wib(e): + """Return (date_time_str, relative_str) for display.""" + start_str = e.get("startTime") or e.get("startDate", "") + if not start_str: + return "TBD", "" + try: + start_dt = datetime.fromisoformat(start_str.replace('Z', '+00:00')) + now_utc = datetime.now(timezone.utc) + utc7 = timezone(timedelta(hours=7)) + start_utc7 = start_dt.astimezone(utc7) + + # Absolute: "Mar 25, 19:00 WIB" + abs_str = start_utc7.strftime("%b %d, %H:%M WIB") + + # Relative: "In 5h", "In 10h", "LIVE", etc. + delta = start_dt - now_utc + if delta.total_seconds() < 0: + hours_ago = abs(delta.total_seconds()) / 3600 + if hours_ago < 1: + rel_str = "LIVE" + elif hours_ago < 24: + rel_str = f"{int(hours_ago)}h ago" + else: + days = int(hours_ago / 24) + rel_str = f"{days}d ago" + else: + hours_until = delta.total_seconds() / 3600 + if hours_until < 1: + mins = int(delta.total_seconds() / 60) + rel_str = f"In {mins}m" + elif hours_until < 24: + rel_str = f"In {int(hours_until)}h" + else: + days = int(hours_until / 24) + rel_str = f"In {days}d" + + return abs_str, rel_str + except: + return "TBD", "" + +def get_header_date(): + """Return current date string like 'Mar 25, 2026'""" + now_utc = datetime.now(timezone.utc) + utc7 = timezone(timedelta(hours=7)) + now_utc7 = now_utc.astimezone(utc7) + return now_utc7.strftime("%b %d, %Y") + +def get_tournament(title): + """Extract tournament name from event title. Title format: 'Category: Team A vs Team B (BO/X) - Tournament Name'""" + if " - " in title: + parts = title.split(" - ") + if len(parts) > 1: + return " - ".join(parts[1:]).strip() + return "" + +def print_browse(events, category, total_raw, total_filtered): + from datetime import datetime, timezone, timedelta + now_utc = datetime.now(timezone.utc) + utc7 = timezone(timedelta(hours=7)) + now_utc7 = now_utc.astimezone(utc7) + header_date = get_header_date() + + print(f"\n{'='*60}") + print(f"=== {category.upper()} ===") + print(f"{'='*60}") + print(f"Query: '{GAME_CATEGORIES[category]}' | Total API: {total_raw} | Tradeable: {total_filtered}") + print(f"Current time (WIB): {now_utc7.strftime('%H:%M WIB')} | {header_date}") + + if not events: + print(" No tradeable events found.") + return + + for i, e in enumerate(events, 1): + f = format_event(e) + ml = get_ml_market(e) + outcomes = json.loads(ml.get("outcomes", "[]")) if ml else [] + prices = json.loads(ml.get("outcomePrices", "[]")) if ml else [] + vol = f["volume"] + title = f["title"] + url = f["url"] + start_time_wib, rel_time = get_start_time_wib(e) + + team_a = outcomes[0] if len(outcomes) > 0 else "?" + team_b = outcomes[1] if len(outcomes) > 1 else "?" + odds_a = format_odds(float(prices[0])) if len(prices) > 0 else "?" + odds_b = format_odds(float(prices[1])) if len(prices) > 1 else "?" + + # Extract title without tournament for line 1 + # Title format: "Category: TeamA vs TeamB (BO/X) - Tournament" + # We want: "Category: TeamA vs TeamB (BO/X)" + if " - " in title: + title_clean = title.split(" - ")[0].strip() + else: + title_clean = title + + tournament = get_tournament(title) + + # New format: + # 1. [Title](url) <- title without tournament + # 2. Date, Time WIB | Relative + # (empty) + # Vol: $XXX + # Tournament: XXXXX + # Odds: TeamA Xc | Xc TeamB + print(f"\n {i}. [{title_clean}]({url})") + print(f" {start_time_wib} | {rel_time}") + print(f"") + print(f" Vol: ${vol:,.0f}") + if tournament: + print(f" Tournament: {tournament}") + print(f" Odds: {team_a} {odds_a} | {odds_b} {team_b}") + +def print_detail(e, detail): + from datetime import datetime, timezone, timedelta + now_utc = datetime.now(timezone.utc) + utc7 = timezone(timedelta(hours=7)) + now_utc7 = now_utc.astimezone(utc7) + + print(f"\n{'='*60}") + print(f"=== DETAIL: {detail['title'][:60]} ===") + print(f"{'='*60}") + print(f"URL: {detail['url']}") + print(f"Livestream: {detail['livestream']}") + + spread_str = format_spread(detail["best_bid"], detail["best_ask"]) if detail["best_bid"] and detail["best_ask"] else "N/A" + time_str = get_match_time_str(e) + print(f"\n{detail['time_status']}") + print(f"ML: {detail['outcomes'][0]} {format_odds(float(detail['prices'][0]))} vs {detail['outcomes'][1]} {format_odds(float(detail['prices'][1]))}") + print(f"ML Vol: ${detail['volume']:,.0f} | {spread_str}") + + print(f"\nMarkets ({len(detail['markets'])}):") + for m in detail["markets"]: + spread_str = format_spread(m["best_bid"], m["best_ask"]) if m["best_bid"] and m["best_ask"] else "N/A" + print(f" [{m['type']}]") + print(f" {m['outcomes'][0]} {format_odds(float(m['prices'][0]))} vs {m['outcomes'][1]} {format_odds(float(m['prices'][1]))}") + print(f" Vol: ${m['volume']:,.0f} | {spread_str}") + print(f" URL: {m['url']}") + +# ============================================================ +# MAIN +# ============================================================ + +def main(): + parser = argparse.ArgumentParser(description="Browse Polymarket tradeable events by game category.") + parser.add_argument("--category", default="Counter Strike", + choices=list(GAME_CATEGORIES.keys()), + help="Game category to browse") + parser.add_argument("--limit", type=int, default=5, + help="Max events to show") + parser.add_argument("--list-categories", action="store_true", + help="List available game categories and exit") + parser.add_argument("--detail", type=int, default=1, + help="Index of event (1-indexed) to show detailed markets for. Default: 1. Set to 0 to disable.") + args = parser.parse_args() + + if args.list_categories: + print("Available categories:") + for name in GAME_CATEGORIES: + print(f" - {name}") + return + + search_term = GAME_CATEGORIES[args.category] + + print(f"\nFetching {args.category} events...") + + result = browse_events(search_term, display_max=args.limit) + + print_browse( + result["events"], + args.category, + result["total_raw"], + result["total_filtered"] + ) + + # Print detail for selected event if any + if result["events"] and args.detail > 0: + print("\n") + idx = args.detail - 1 + if idx < 0 or idx >= len(result["events"]): + idx = 0 + detail_event = result["events"][idx] + detail = format_detail_event(detail_event) + print_detail(detail_event, detail) + +if __name__ == "__main__": + main()