Add crypto signals pipeline + Polymarket arb scanner
- 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
This commit is contained in:
311
projects/crypto-signals/scripts/polymarket_arb_scanner.py
Normal file
311
projects/crypto-signals/scripts/polymarket_arb_scanner.py
Normal file
@ -0,0 +1,311 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user