- Signal parser for Telegram JSON exports - Price fetcher using Binance US API - Backtester with fee-aware simulation - Polymarket 15-min arb scanner with orderbook checking - Systemd timer every 2 min for arb alerts - Paper trade tracking - Investigation: polymarket-15min-arb.md
312 lines
11 KiB
Python
312 lines
11 KiB
Python
#!/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"🔔 <b>Arb Found!</b>\n\n"
|
|
f"<b>{arb['question']}</b>\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()
|