Files
polymarket_arbitrage/docs/assets/demo.js
T

485 lines
18 KiB
JavaScript
Raw Normal View History

2026-07-22 18:53:44 +08:00
/*
* Live Polymarket arbitrage scanner — portfolio demo.
*
* What this does, in plain English:
* - Loads a list of 68 currently-active categorical Polymarket events
* from a static JSON file that a GitHub Action refreshes every hour.
* - Opens a WebSocket to Polymarket's public CLOB and subscribes to every
* outcome token of those events.
* - Maintains order book state per outcome and recomputes "basket cost" —
* the total cost of buying one share of every answer — on every update.
* - If that basket cost drops below $1.00, it's a risk-free arbitrage and
* the page flashes green.
*
* This is a browser-only port of the Python engine in the repo. No backend,
* no keys, no orders submitted.
*/
const EVENTS_URL = "./data/events.json";
const WS_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/market";
const MAX_EVENTS_TO_WATCH = 6;
const PING_INTERVAL_MS = 10_000;
const NEAR_ARB_THRESHOLD = 0.01; // $0.01 above $1 counts as "near miss"
// --- DOM shortcuts ---------------------------------------------------------
const $ = (sel) => document.querySelector(sel);
const el = {
connDot: $("#conn-dot"),
connLabel: $("#conn-label"),
aLine: $("#a-line"),
aSub: $("#a-sub"),
watchCount: $("#watch-count"),
eventList: $("#event-list"),
eventTitle: $("#event-title"),
eventSub: $("#event-subtitle"),
whyBtn: $("#why-this-event"),
basketCost: $("#basket-cost"),
basketCostSub:$("#basket-cost-sub"),
basketPnl: $("#basket-pnl"),
basketPnlSub: $("#basket-pnl-sub"),
basketStatus: $("#basket-status"),
basketFill: $("#basket-fill"),
scaleButtons: $("#scale-buttons"),
scaleOut: $("#scale-out"),
outcomeList: $("#outcome-list"),
statEvents: $("#stat-events"),
statTokens: $("#stat-tokens"),
statBooks: $("#stat-books"),
statMsgs: $("#stat-msgs"),
statUptime: $("#stat-uptime"),
modal: $("#modal"),
modalContent: $("#modal-content"),
};
// --- state ----------------------------------------------------------------
const state = {
events: {},
books: {},
tokenToEvent: {},
selectedEventId: null,
msgCount: 0,
startTime: Date.now(),
ws: null,
pingTimer: null,
scale: 1,
};
class Ladder {
constructor() { this.m = {}; this.sortedKeys = null; }
set(price, size) { if (size <= 0) this.del(price); else { this.m[price] = size; this.sortedKeys = null; } }
del(price) { delete this.m[price]; this.sortedKeys = null; }
clear() { this.m = {}; this.sortedKeys = null; }
size() { return Object.keys(this.m).length; }
keys() { if (this.sortedKeys === null) this.sortedKeys = Object.keys(this.m).map(parseFloat).sort((a,b)=>a-b); return this.sortedKeys; }
best(ascending) {
const ks = this.keys();
if (!ks.length) return null;
const p = ascending ? ks[0] : ks[ks.length - 1];
return { price: p, size: this.m[p] };
}
}
// --- bootstrap ------------------------------------------------------------
bootstrap().catch((err) => {
console.error("bootstrap failed", err);
setConn("bad", "unable to load events — " + err.message);
el.aLine.innerHTML = `<span class="verdict no">Couldn't load.</span>`;
el.aSub.textContent = "Refresh the page in a minute — the events list refreshes hourly.";
});
async function bootstrap() {
setConn("pend", "loading active events…");
const events = await fetchActiveNegRiskEvents();
if (!events.length) {
setConn("bad", "no active events found");
return;
}
for (const e of events) {
state.events[e.id] = e;
for (const o of e.outcomes) state.tokenToEvent[o.token_id] = e.id;
}
state.selectedEventId = events[0].id;
el.watchCount.textContent = events.length;
el.statEvents.textContent = events.length;
el.statTokens.textContent = Object.keys(state.tokenToEvent).length;
renderEventList();
renderSelectedEvent();
wireInteractions();
startUptimeTicker();
connectWS();
}
// --- REST (same-origin events.json — GitHub Action refreshes hourly) ------
async function fetchActiveNegRiskEvents() {
const resp = await fetch(EVENTS_URL + "?t=" + Date.now());
if (!resp.ok) throw new Error("events.json " + resp.status);
const payload = await resp.json();
const raw = Array.isArray(payload?.events) ? payload.events : [];
const kept = [];
for (const ev of raw) {
if (!Array.isArray(ev.outcomes) || ev.outcomes.length < 2) continue;
kept.push({
id: String(ev.id),
title: String(ev.title || "Event"),
slug: ev.slug,
outcomes: ev.outcomes.map(o => ({ token_id: String(o.token_id), name: String(o.name) })),
sum: null,
lastUpdate: 0,
});
if (kept.length >= MAX_EVENTS_TO_WATCH) break;
}
return kept;
}
// --- WebSocket ------------------------------------------------------------
function connectWS() {
const tokenIds = Object.keys(state.tokenToEvent);
if (!tokenIds.length) { setConn("bad", "no tokens"); return; }
setConn("pend", "opening connection to Polymarket…");
const ws = new WebSocket(WS_URL);
state.ws = ws;
ws.addEventListener("open", () => {
setConn("on", `live · reading order books for ${Object.keys(state.events).length} events`);
ws.send(JSON.stringify({ type: "market", assets_ids: tokenIds, custom_feature_enabled: true }));
if (state.pingTimer) clearInterval(state.pingTimer);
state.pingTimer = setInterval(() => { try { ws.send("PING"); } catch {} }, PING_INTERVAL_MS);
});
ws.addEventListener("message", (ev) => {
const raw = ev.data;
if (typeof raw === "string") {
const t = raw.trim();
if (t === "PONG" || t === "PING") return;
try { handlePayload(JSON.parse(t)); } catch {}
}
});
ws.addEventListener("close", () => {
setConn("bad", "connection dropped — reconnecting…");
if (state.pingTimer) clearInterval(state.pingTimer);
setTimeout(connectWS, 3000);
});
ws.addEventListener("error", () => setConn("bad", "connection error"));
}
function handlePayload(payload) {
if (Array.isArray(payload)) for (const m of payload) dispatch(m);
else if (payload && typeof payload === "object") dispatch(payload);
}
function dispatch(msg) {
state.msgCount++;
el.statMsgs.textContent = state.msgCount.toLocaleString();
const t = msg.event_type;
if (t === "book") applyBookSnapshot(msg);
else if (t === "price_change") applyPriceChange(msg);
}
function applyBookSnapshot(msg) {
const assetId = msg.asset_id;
if (!assetId || !state.tokenToEvent[assetId]) return;
const book = getBook(assetId);
book.bids.clear(); book.asks.clear();
for (const lvl of (msg.bids || [])) {
const p = parseFloat(lvl.price), s = parseFloat(lvl.size);
if (p > 0 && s > 0) book.bids.set(p, s);
}
for (const lvl of (msg.asks || [])) {
const p = parseFloat(lvl.price), s = parseFloat(lvl.size);
if (p > 0 && s > 0) book.asks.set(p, s);
}
onBookUpdate(assetId);
}
function applyPriceChange(msg) {
if (!Array.isArray(msg.price_changes)) return;
const touched = new Set();
for (const c of msg.price_changes) {
const assetId = c.asset_id;
if (!assetId || !state.tokenToEvent[assetId]) continue;
const p = parseFloat(c.price), s = parseFloat(c.size);
if (!(p > 0) || isNaN(s)) continue;
const book = getBook(assetId);
const side = (c.side || "").toUpperCase();
if (side === "BUY") book.bids.set(p, s);
else if (side === "SELL") book.asks.set(p, s);
else continue;
touched.add(assetId);
}
for (const id of touched) onBookUpdate(id);
}
function getBook(tokenId) {
if (!state.books[tokenId]) {
state.books[tokenId] = { bids: new Ladder(), asks: new Ladder() };
el.statBooks.textContent = Object.keys(state.books).length.toLocaleString();
}
return state.books[tokenId];
}
// --- engine ---------------------------------------------------------------
function onBookUpdate(tokenId) {
const eventId = state.tokenToEvent[tokenId];
if (!eventId) return;
evaluateEvent(eventId);
renderEventList();
if (eventId === state.selectedEventId) renderSelectedEvent();
renderHeroAnswer();
}
function evaluateEvent(eventId) {
const ev = state.events[eventId];
if (!ev) return;
let sum = 0, missing = 0;
for (const o of ev.outcomes) {
const b = state.books[o.token_id];
const best = b && b.asks.best(true);
if (!best) { missing += 1; continue; }
sum += best.price;
}
ev.sum = missing > 0 ? null : sum;
ev.lastUpdate = Date.now();
}
// --- rendering ------------------------------------------------------------
function setConn(cls, text) {
el.connDot.className = "dot " + cls;
el.connLabel.textContent = text;
}
function renderHeroAnswer() {
// Find the cheapest basket across all events
let bestEv = null, bestSum = Infinity;
for (const id of Object.keys(state.events)) {
const ev = state.events[id];
if (ev.sum === null || ev.sum === undefined) continue;
if (ev.sum < bestSum) { bestSum = ev.sum; bestEv = ev; }
}
if (!bestEv) {
el.aLine.innerHTML = `<span class="spinner"></span><span class="muted">checking live prices…</span>`;
return;
}
const cls = classForSum(bestSum);
const gap = bestSum - 1;
if (cls === "arb") {
const bps = Math.round(-gap * 10_000);
el.aLine.innerHTML = `<span class="verdict yes">Yes!</span> <span class="detail">The cheapest bundle is</span> <span class="cost-chip yes">$${bestSum.toFixed(4)}</span><span class="detail">— free ${Math.abs(bps)} bps (${(-gap*100).toFixed(2)}¢) per $1 bet</span>`;
el.aSub.innerHTML = `On <strong>${escapeHtml(bestEv.title)}</strong>. Click it on the left to see the details.`;
} else if (cls === "near") {
el.aLine.innerHTML = `<span class="verdict near">Almost.</span> <span class="detail">The cheapest bundle is</span> <span class="cost-chip near">$${bestSum.toFixed(4)}</span><span class="detail">— you'd lose ${(gap*100).toFixed(2)}¢ per $1 bet</span>`;
el.aSub.innerHTML = `On <strong>${escapeHtml(bestEv.title)}</strong>. Watch it — if another trader sells off this might flip into arbitrage.`;
} else {
el.aLine.innerHTML = `<span class="verdict no">Not right now.</span> <span class="detail">The cheapest bundle is</span> <span class="cost-chip no">$${bestSum.toFixed(4)}</span><span class="detail">— you'd lose ${(gap*100).toFixed(2)}¢ per $1 bet</span>`;
el.aSub.innerHTML = `Watching <strong>${Object.keys(state.events).length} events</strong>. This is the normal state — arbitrage windows are rare.`;
}
}
function renderEventList() {
el.eventList.innerHTML = "";
for (const id of Object.keys(state.events)) {
const ev = state.events[id];
const cls = classForSum(ev.sum);
const costText = ev.sum === null ? "waiting" : "$" + ev.sum.toFixed(3);
const costCls = ev.sum === null ? "" : cls === "arb" ? "yes" : cls === "near" ? "near" : "no";
const card = document.createElement("div");
card.className = "event-card" + (id === state.selectedEventId ? " active" : "");
card.innerHTML = `
<div class="ec-title">${escapeHtml(ev.title)}</div>
<div class="ec-meta">
<span class="ec-count">${ev.outcomes.length} possible answers</span>
<span class="ec-cost ${costCls}">${costText}</span>
</div>
`;
card.addEventListener("click", () => {
state.selectedEventId = id;
renderEventList();
renderSelectedEvent();
});
el.eventList.appendChild(card);
}
}
function renderSelectedEvent() {
const ev = state.events[state.selectedEventId];
if (!ev) return;
el.eventTitle.textContent = ev.title;
el.eventSub.textContent = `${ev.outcomes.length} possible answers · exactly one will win and pay $1`;
let sum = 0, complete = true;
const rows = [];
for (const o of ev.outcomes) {
const b = state.books[o.token_id];
const best = b && b.asks.best(true);
if (!best) { complete = false; rows.push({ ...o, price: null, size: null }); }
else { sum += best.price; rows.push({ ...o, price: best.price, size: best.size }); }
}
rows.sort((a, b) => (b.price ?? -1) - (a.price ?? -1));
renderCalcCard(complete ? sum : null);
renderOutcomeList(rows, complete ? sum : null);
}
function renderCalcCard(sum) {
if (sum === null) {
el.basketCost.textContent = "—";
el.basketCost.className = "cell-val";
el.basketCostSub.textContent = "waiting for every answer's order book…";
el.basketPnl.textContent = "—";
el.basketPnl.className = "cell-val";
el.basketPnlSub.textContent = "per one-set bet";
el.basketStatus.textContent = "loading";
el.basketStatus.className = "status fair";
el.basketFill.style.width = "0%";
el.basketFill.className = "fill";
el.scaleOut.innerHTML = "";
return;
}
const cls = classForSum(sum);
const delta = sum - 1;
// scale
const q = state.scale;
const totalCost = sum * q;
const totalGet = 1 * q;
const pnl = totalGet - totalCost;
el.basketCost.textContent = "$" + sum.toFixed(4);
el.basketCost.className = "cell-val";
el.basketCostSub.textContent = q === 1 ? "for one full set" : `× ${q} sets = $${totalCost.toFixed(2)} total`;
el.basketPnl.textContent = (pnl >= 0 ? "+" : "") + "$" + pnl.toFixed(q >= 100 ? 2 : 4);
el.basketPnl.className = "cell-val " + (cls === "arb" ? "pos" : cls === "near" ? "near" : "neg");
el.basketPnlSub.textContent = q === 1
? (cls === "arb" ? "guaranteed — buy + redeem" : cls === "near" ? "you'd lose a tiny bit" : "you'd lose this no matter what")
: `on a ${q}-set bet`;
el.basketStatus.textContent = cls === "arb" ? "ARBITRAGE" : cls === "near" ? "NEAR MISS" : "NO ARB";
el.basketStatus.className = "status " + cls;
// interpretation line
if (cls === "arb") {
el.scaleOut.innerHTML = `
Pay <strong>$${totalCost.toFixed(2)}</strong>,
receive <strong>$${totalGet.toFixed(2)}</strong> when the event resolves,
pocket <span class="gain">+$${pnl.toFixed(2)}</span> risk-free.
Every leg fills and the winning outcome pays $1.
`;
} else {
const perSet = delta;
el.scaleOut.innerHTML = `
Pay <strong>$${totalCost.toFixed(2)}</strong>,
receive <strong>$${totalGet.toFixed(2)}</strong> when the event resolves,
net <span class="loss">$${Math.abs(pnl).toFixed(2)}</span>.
That's ${(perSet*100).toFixed(2)}¢ too expensive per set — no arbitrage.
`;
}
const pct = Math.max(0, Math.min(100, ((sum - 0.80) / 0.40) * 100));
el.basketFill.style.width = pct.toFixed(1) + "%";
el.basketFill.className = "fill" + (cls === "arb" ? " arb" : cls === "near" ? " near" : "");
}
function renderOutcomeList(rows, totalSum) {
el.outcomeList.innerHTML = "";
for (const r of rows) {
const hasPrice = r.price !== null;
const pct = hasPrice ? (r.price * 100) : 0;
const highlighted = hasPrice && totalSum !== null && classForSum(totalSum) === "arb";
const row = document.createElement("div");
row.className = "outcome-row" + (!hasPrice ? " empty" : "") + (highlighted ? " highlighted" : "");
row.innerHTML = `
<div class="left">
<div class="outcome-name">${escapeHtml(r.name)}</div>
<div class="prob-bar"><div class="prob-fill" style="width: ${Math.min(100, pct).toFixed(1)}%"></div></div>
</div>
<div class="right">
<div class="pct">${hasPrice ? pct.toFixed(1) + "%" : "waiting"}</div>
<div class="price-size">${hasPrice ? `$${r.price.toFixed(4)} · ${formatSize(r.size)} available` : "no offers yet"}</div>
</div>
`;
el.outcomeList.appendChild(row);
}
}
function classForSum(sum) {
if (sum === null || sum === undefined) return "fair";
if (sum < 1.0) return "arb";
if (sum <= 1.0 + NEAR_ARB_THRESHOLD) return "near";
return "fair";
}
// --- interactions ---------------------------------------------------------
function wireInteractions() {
// scale buttons
el.scaleButtons.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-qty]");
if (!btn) return;
state.scale = parseInt(btn.dataset.qty, 10) || 1;
for (const b of el.scaleButtons.querySelectorAll("button")) b.classList.toggle("active", b === btn);
renderSelectedEvent();
});
// "what is this event" modal
el.whyBtn.addEventListener("click", () => {
const ev = state.events[state.selectedEventId];
if (!ev) return;
const pmUrl = ev.slug ? `https://polymarket.com/event/${encodeURIComponent(ev.slug)}` : "https://polymarket.com";
el.modalContent.innerHTML = `
<h3>${escapeHtml(ev.title)}</h3>
<p>
This is a real, currently-open question on Polymarket. There are
<strong>${ev.outcomes.length} possible answers</strong>, and when the real
event resolves, one will be declared the winner. Shares of the winning
answer pay $1. Shares of every losing answer pay $0.
</p>
<p>
The numbers on this page come straight from Polymarket's live order book —
same feed their website uses. See the original market here:
</p>
<p><a href="${pmUrl}" target="_blank" rel="noopener">View on polymarket.com →</a></p>
`;
el.modal.hidden = false;
});
// modal close
el.modal.addEventListener("click", (e) => {
if (e.target.dataset?.close !== undefined) el.modal.hidden = true;
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") el.modal.hidden = true;
});
}
function startUptimeTicker() {
setInterval(() => {
const secs = Math.floor((Date.now() - state.startTime) / 1000);
const mm = String(Math.floor(secs / 60)).padStart(2, "0");
const ss = String(secs % 60).padStart(2, "0");
el.statUptime.textContent = `${mm}:${ss}`;
}, 1000);
}
// --- utils ----------------------------------------------------------------
function formatSize(s) {
if (s >= 1_000_000) return (s / 1_000_000).toFixed(2) + "M";
if (s >= 10_000) return (s / 1_000).toFixed(1) + "k";
if (s >= 1_000) return (s / 1_000).toFixed(2) + "k";
return s.toFixed(0);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}