#!/usr/bin/env python3 """ Polymarket 15-Min Crypto Arbitrage Scanner Scans active 15-minute crypto markets for arbitrage opportunities. Alerts via Telegram when combined Up+Down cost < $1.00 (after fees). Zero AI tokens — runs as pure Python via systemd timer. """ import json import os import sys import time import urllib.request from datetime import datetime, timezone, timedelta from pathlib import Path # Config DATA_DIR = Path(__file__).parent.parent / "data" / "arb-scanner" DATA_DIR.mkdir(parents=True, exist_ok=True) LOG_FILE = DATA_DIR / "scan_log.json" PAPER_TRADES_FILE = DATA_DIR / "paper_trades.json" TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "") TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6443752046") # Polymarket fee formula for 15-min markets def calc_taker_fee(shares, price): """Calculate taker fee in USDC.""" if price <= 0 or price >= 1: return 0 return shares * price * 0.25 * (price * (1 - price)) ** 2 def calc_fee_rate(price): """Effective fee rate at a given price.""" if price <= 0 or price >= 1: return 0 return 0.25 * (price * (1 - price)) ** 2 def get_active_15min_markets(): """Fetch active 15-minute crypto markets from Polymarket.""" markets = [] # 15-min markets are scattered across pagination — scan broadly for offset in range(0, 3000, 200): url = ( f"https://gamma-api.polymarket.com/markets?" f"active=true&closed=false&limit=200&offset={offset}" f"&order=volume&ascending=false" ) req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) try: resp = urllib.request.urlopen(req, timeout=15) batch = json.loads(resp.read()) for m in batch: q = m.get("question", "").lower() if "up or down" in q: markets.append(m) if len(batch) < 200: break except Exception as e: print(f"Error fetching markets (offset={offset}): {e}") break time.sleep(0.1) # Only keep markets ending within the next 4 hours (tradeable window) now = datetime.now(timezone.utc) tradeable = [] for m in markets: end_str = m.get("endDate", "") if not end_str: continue try: end_dt = datetime.fromisoformat(end_str.replace("Z", "+00:00")) hours_until = (end_dt - now).total_seconds() / 3600 if 0.25 < hours_until <= 24: # Skip markets < 15min to expiry (already resolved) m["_hours_until_end"] = round(hours_until, 2) tradeable.append(m) except: pass # Deduplicate seen = set() unique = [] for m in tradeable: cid = m.get("conditionId", m.get("id", "")) if cid not in seen: seen.add(cid) unique.append(m) return unique def get_orderbook_prices(token_id): """Get best bid/ask from the CLOB API.""" url = f"https://clob.polymarket.com/book?token_id={token_id}" req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) try: resp = urllib.request.urlopen(req, timeout=10) book = json.loads(resp.read()) bids = book.get("bids", []) asks = book.get("asks", []) best_bid = float(bids[0]["price"]) if bids else 0 best_ask = float(asks[0]["price"]) if asks else 1 bid_size = float(bids[0].get("size", 0)) if bids else 0 ask_size = float(asks[0].get("size", 0)) if asks else 0 return { "best_bid": best_bid, "best_ask": best_ask, "bid_size": bid_size, "ask_size": ask_size, "spread": best_ask - best_bid } except Exception as e: return None def scan_for_arbs(): """Scan all active 15-min markets for arbitrage opportunities.""" markets = get_active_15min_markets() print(f"Found {len(markets)} active 15-min crypto markets") opportunities = [] for market in markets: question = market.get("question", market.get("title", "")) hours_left = market.get("_hours_until_end", "?") # Get token IDs for both outcomes tokens = market.get("clobTokenIds", "") if isinstance(tokens, str): try: tokens = json.loads(tokens) if tokens.startswith("[") else tokens.split(",") except: tokens = [] if len(tokens) < 2: continue # Get orderbook for both tokens (ask = price to buy) book_up = get_orderbook_prices(tokens[0]) book_down = get_orderbook_prices(tokens[1]) time.sleep(0.15) if not book_up or not book_down: continue # For arb: we BUY both sides at the ASK price up_ask = book_up["best_ask"] down_ask = book_down["best_ask"] combined = up_ask + down_ask # Calculate fees on 100 shares fee_up = calc_taker_fee(100, up_ask) fee_down = calc_taker_fee(100, down_ask) total_cost_100 = (up_ask + down_ask) * 100 + fee_up + fee_down net_profit_100 = 100 - total_cost_100 net_profit_pct = net_profit_100 / total_cost_100 * 100 if total_cost_100 > 0 else 0 # Fillable size (limited by smaller side) fillable_size = min(book_up["ask_size"], book_down["ask_size"]) if fillable_size > 0: fill_fee_up = calc_taker_fee(fillable_size, up_ask) fill_fee_down = calc_taker_fee(fillable_size, down_ask) fill_cost = (up_ask + down_ask) * fillable_size + fill_fee_up + fill_fee_down fill_profit = fillable_size - fill_cost else: fill_profit = 0 opp = { "question": question, "hours_left": hours_left, "up_ask": up_ask, "down_ask": down_ask, "up_ask_size": book_up["ask_size"], "down_ask_size": book_down["ask_size"], "combined": round(combined, 4), "fee_up_per_100": round(fee_up, 4), "fee_down_per_100": round(fee_down, 4), "total_fees_per_100": round(fee_up + fee_down, 4), "net_profit_per_100": round(net_profit_100, 2), "net_profit_pct": round(net_profit_pct, 2), "fillable_shares": fillable_size, "fillable_profit": round(fill_profit, 2), "is_arb": net_profit_100 > 0, "timestamp": datetime.now(timezone.utc).isoformat(), } opportunities.append(opp) return opportunities def paper_trade(opp): """Record a paper trade for an arb opportunity.""" trades = [] if PAPER_TRADES_FILE.exists(): trades = json.loads(PAPER_TRADES_FILE.read_text()) trade = { "id": len(trades) + 1, "timestamp": opp["timestamp"], "question": opp["question"], "up_price": opp.get("up_ask", opp.get("up_price", 0)), "down_price": opp.get("down_ask", opp.get("down_price", 0)), "combined": opp["combined"], "fees_per_100": opp["total_fees_per_100"], "net_profit_per_100": opp["net_profit_per_100"], "net_profit_pct": opp["net_profit_pct"], "status": "open", # Will be "won" when market resolves (always wins if real arb) "paper_size_usd": 50, # Paper trade $50 per arb } expected_profit = 50 * opp["net_profit_pct"] / 100 trade["expected_profit_usd"] = round(expected_profit, 2) trades.append(trade) PAPER_TRADES_FILE.write_text(json.dumps(trades, indent=2)) return trade def send_telegram_alert(message): """Send alert via Telegram bot API (zero tokens).""" if not TELEGRAM_BOT_TOKEN: print(f"[ALERT] {message}") return url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage" data = json.dumps({ "chat_id": TELEGRAM_CHAT_ID, "text": message, "parse_mode": "HTML" }).encode() req = urllib.request.Request(url, data=data, headers={ "Content-Type": "application/json", "User-Agent": "Mozilla/5.0" }) try: urllib.request.urlopen(req, timeout=10) except Exception as e: print(f"Telegram alert failed: {e}") def main(): print(f"=== Polymarket 15-Min Arb Scanner ===") print(f"Time: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}") print() opps = scan_for_arbs() arbs = [o for o in opps if o["is_arb"]] non_arbs = [o for o in opps if not o["is_arb"]] print(f"\nResults: {len(opps)} markets scanned, {len(arbs)} arb opportunities\n") for o in sorted(opps, key=lambda x: x.get("net_profit_pct", 0), reverse=True): emoji = "āœ…" if o["is_arb"] else "āŒ" print(f"{emoji} {o['question'][:65]}") up = o.get('up_ask', o.get('up_price', '?')) down = o.get('down_ask', o.get('down_price', '?')) print(f" Up: ${up} | Down: ${down} | Combined: ${o['combined']}") print(f" Fees/100: ${o['total_fees_per_100']} | Net profit/100: ${o['net_profit_per_100']} ({o['net_profit_pct']}%)") if o.get('fillable_shares'): print(f" Fillable: {o['fillable_shares']:.0f} shares | Fillable profit: ${o.get('fillable_profit', '?')}") print() # Paper trade any arbs found for arb in arbs: trade = paper_trade(arb) print(f"šŸ“ Paper trade #{trade['id']}: {trade['question'][:50]} | Expected: +${trade['expected_profit_usd']}") # Send Telegram alert msg = ( f"šŸ”” Arb Found!\n\n" f"{arb['question']}\n" f"Up: ${arb.get('up_ask', arb.get('up_price', '?'))} | " f"Down: ${arb.get('down_ask', arb.get('down_price', '?'))}\n" f"Combined: ${arb['combined']} (after fees)\n" f"Net profit: {arb['net_profit_pct']}%\n\n" f"šŸ“ Paper traded $50 → expected +${trade['expected_profit_usd']}" ) send_telegram_alert(msg) # Save scan log log = [] if LOG_FILE.exists(): try: log = json.loads(LOG_FILE.read_text()) except: pass log.append({ "timestamp": datetime.now(timezone.utc).isoformat(), "markets_scanned": len(opps), "arbs_found": len(arbs), "opportunities": opps, }) # Keep last 1000 scans log = log[-1000:] LOG_FILE.write_text(json.dumps(log, indent=2)) # Summary of paper trades if PAPER_TRADES_FILE.exists(): trades = json.loads(PAPER_TRADES_FILE.read_text()) total_expected = sum(t.get("expected_profit_usd", 0) for t in trades) print(f"\nšŸ“Š Paper trade total: {len(trades)} trades, expected profit: ${total_expected:.2f}") if __name__ == "__main__": main()