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:
2026-02-09 14:31:51 -06:00
parent b24d0e87de
commit be0315894e
8 changed files with 925 additions and 2 deletions

View 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()