first commit
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
/*
|
||||
* Live Polymarket arbitrage scanner — portfolio demo.
|
||||
*
|
||||
* What this does, in plain English:
|
||||
* - Loads a list of 6–8 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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/* Lab page — extends style.css with strategy-lab-specific layout */
|
||||
|
||||
.lab-shell { min-height: 100vh; background: var(--bg); }
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
.tabs-inner {
|
||||
max-width: 1120px; margin: 0 auto; padding: 0 1.5rem;
|
||||
display: flex; gap: 0.2rem;
|
||||
}
|
||||
.tab {
|
||||
background: transparent; border: 0;
|
||||
color: var(--text-3); font-size: 0.95rem; font-weight: 500;
|
||||
padding: 1rem 1.4rem; cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.1s ease, border-color 0.1s ease;
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.tab-count {
|
||||
font-family: var(--mono); font-size: 0.78rem;
|
||||
padding: 1px 7px; border-radius: 999px;
|
||||
background: var(--surface-2); color: var(--text-3);
|
||||
}
|
||||
.tab.active .tab-count { background: var(--accent); color: white; }
|
||||
|
||||
.panel { display: none; }
|
||||
.panel.active { display: block; }
|
||||
|
||||
/* Results tab: hero */
|
||||
.lab-hero {
|
||||
background: linear-gradient(180deg, #ffffff 0%, var(--bg) 100%);
|
||||
padding: 2.5rem 1.5rem 2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.lab-hero-inner { max-width: 1120px; margin: 0 auto; }
|
||||
.hero-strategy-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 0.7rem; flex-wrap: wrap; gap: 0.6rem;
|
||||
}
|
||||
.strategy-badge {
|
||||
font-size: 0.78rem; font-weight: 600; letter-spacing: 0.08em;
|
||||
text-transform: uppercase; color: var(--accent);
|
||||
padding: 4px 10px; background: rgba(45,156,219,0.08);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.switch-btn {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
color: var(--text-2); font-weight: 500; font-size: 0.88rem;
|
||||
padding: 0.45rem 0.9rem; border-radius: 8px;
|
||||
cursor: pointer; transition: all 0.1s ease;
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
}
|
||||
.switch-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.switch-btn .arrow { font-family: var(--mono); }
|
||||
.lab-hero h1 {
|
||||
font-size: 2rem; font-weight: 700; letter-spacing: -0.02em;
|
||||
margin: 0 0 0.5rem; color: var(--text);
|
||||
}
|
||||
.lab-hero .lead {
|
||||
font-size: 1rem; color: var(--text-2);
|
||||
max-width: 740px; margin: 0; line-height: 1.55;
|
||||
}
|
||||
|
||||
.bankroll-row {
|
||||
margin-top: 1.4rem; padding: 0.9rem 1rem;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem;
|
||||
}
|
||||
.bankroll-lbl { font-size: 0.9rem; color: var(--text-2); font-weight: 500; }
|
||||
.bankroll-choices { display: flex; gap: 0.3rem; flex-wrap: wrap; }
|
||||
.bankroll-choices button {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
color: var(--text-2); font-size: 0.9rem; font-weight: 600;
|
||||
padding: 0.35rem 0.75rem; border-radius: 6px; cursor: pointer;
|
||||
font-family: var(--mono);
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
.bankroll-choices button:hover { border-color: var(--border-2); color: var(--text); }
|
||||
.bankroll-choices button.active { background: var(--accent); border-color: var(--accent); color: white; }
|
||||
.bankroll-note {
|
||||
flex-basis: 100%; font-size: 0.8rem; color: var(--text-3);
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
/* Verdict section */
|
||||
.verdict-section { padding: 2.2rem 1.5rem; background: var(--surface); border-bottom: 1px solid var(--border); }
|
||||
.verdict-inner { max-width: 1120px; margin: 0 auto; }
|
||||
|
||||
.verdict-card {
|
||||
display: flex; gap: 1.25rem; align-items: center;
|
||||
padding: 1.4rem 1.6rem; border-radius: var(--radius-lg);
|
||||
margin-bottom: 1.25rem;
|
||||
border: 1px solid var(--border); background: var(--surface-2);
|
||||
}
|
||||
.verdict-card.win { background: var(--pos-soft); border-color: rgba(22,163,74,0.3); }
|
||||
.verdict-card.loss { background: var(--neg-soft); border-color: rgba(220,38,38,0.3); }
|
||||
.verdict-card.flat { background: var(--warn-soft); border-color: rgba(217,119,6,0.3); }
|
||||
.verdict-icon {
|
||||
font-size: 2.4rem; line-height: 1;
|
||||
width: 3.8rem; height: 3.8rem;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--surface); border-radius: 50%; flex-shrink: 0;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.verdict-card.win .verdict-icon { color: var(--pos); }
|
||||
.verdict-card.loss .verdict-icon { color: var(--neg); }
|
||||
.verdict-card.flat .verdict-icon { color: var(--warn); }
|
||||
.verdict-label {
|
||||
font-size: 1.35rem; font-weight: 700; letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.verdict-card.win .verdict-label { color: var(--pos); }
|
||||
.verdict-card.loss .verdict-label { color: var(--neg); }
|
||||
.verdict-card.flat .verdict-label { color: var(--warn); }
|
||||
.verdict-detail { color: var(--text-2); margin-top: 0.3rem; font-size: 0.94rem; }
|
||||
|
||||
.verdict-stats {
|
||||
display: grid; gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.vstat {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 1rem 1.1rem;
|
||||
}
|
||||
.vstat-val {
|
||||
font-family: var(--mono); font-size: 1.4rem; font-weight: 700;
|
||||
color: var(--text); letter-spacing: -0.01em;
|
||||
}
|
||||
.vstat-val.pos { color: var(--pos); }
|
||||
.vstat-val.neg { color: var(--neg); }
|
||||
.vstat-lbl {
|
||||
font-size: 0.78rem; color: var(--text-3); margin-top: 0.2rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.verdict-explainer {
|
||||
font-size: 0.93rem; line-height: 1.6; color: var(--text-2);
|
||||
max-width: 820px; margin: 0;
|
||||
padding: 1rem 1.1rem; background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Trades table */
|
||||
.trades-section { padding: 2.2rem 1.5rem; border-bottom: 1px solid var(--border); }
|
||||
.trades-inner { max-width: 1120px; margin: 0 auto; }
|
||||
.section-header { margin-bottom: 1.25rem; }
|
||||
.section-header h2 { font-size: 1.2rem; font-weight: 700; margin: 0 0 0.3rem; letter-spacing: -0.01em; }
|
||||
.section-header p { color: var(--text-2); font-size: 0.93rem; margin: 0 0 0.9rem; max-width: 740px; }
|
||||
|
||||
.trade-filter { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.filter-btn {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
color: var(--text-2); font-size: 0.85rem; font-weight: 500;
|
||||
padding: 0.4rem 0.85rem; border-radius: 999px; cursor: pointer;
|
||||
display: inline-flex; gap: 0.4rem; align-items: center;
|
||||
}
|
||||
.filter-btn:hover { border-color: var(--border-2); color: var(--text); }
|
||||
.filter-btn.active { background: var(--text); color: white; border-color: var(--text); }
|
||||
.filter-btn span {
|
||||
font-family: var(--mono); font-size: 0.75rem;
|
||||
color: var(--text-3); font-weight: 600;
|
||||
}
|
||||
.filter-btn.active span { color: rgba(255,255,255,0.85); }
|
||||
|
||||
.trade-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.trade-row {
|
||||
display: grid; grid-template-columns: 1fr auto auto; gap: 1rem;
|
||||
align-items: center;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 0.85rem 1.1rem;
|
||||
}
|
||||
.trade-row.win { border-left: 3px solid var(--pos); }
|
||||
.trade-row.loss { border-left: 3px solid var(--neg); }
|
||||
.trade-row.skip { opacity: 0.72; border-left: 3px solid var(--border-2); }
|
||||
.trade-event { min-width: 0; }
|
||||
.trade-title {
|
||||
font-size: 0.95rem; font-weight: 500; color: var(--text);
|
||||
margin-bottom: 0.2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.trade-meta { font-size: 0.8rem; color: var(--text-3); }
|
||||
.trade-action {
|
||||
font-family: var(--mono); font-size: 0.82rem; color: var(--text-2);
|
||||
text-align: right; white-space: nowrap;
|
||||
}
|
||||
.trade-result {
|
||||
font-family: var(--mono); font-size: 1rem; font-weight: 700;
|
||||
min-width: 90px; text-align: right;
|
||||
}
|
||||
.trade-result.pos { color: var(--pos); }
|
||||
.trade-result.neg { color: var(--neg); }
|
||||
.trade-result.neutral { color: var(--text-3); font-weight: 400; font-size: 0.85rem; }
|
||||
.trade-show-more {
|
||||
background: var(--surface); border: 1px dashed var(--border-2);
|
||||
color: var(--text-2); font-size: 0.88rem;
|
||||
padding: 0.8rem 1.1rem; border-radius: var(--radius);
|
||||
text-align: center; cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
.trade-show-more:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
.trade-count-footer {
|
||||
text-align: center; padding: 1rem; margin-top: 0.4rem;
|
||||
font-size: 0.85rem; color: var(--text-3);
|
||||
}
|
||||
.trade-count-footer strong { color: var(--text); font-family: var(--mono); }
|
||||
.trade-count-footer a { color: var(--accent); text-decoration: underline; }
|
||||
.trade-count-footer a:hover { color: var(--accent-dark); }
|
||||
|
||||
.data-section { padding: 2rem 1.5rem; background: var(--surface); }
|
||||
.data-inner { max-width: 840px; margin: 0 auto; }
|
||||
.data-inner h2 { font-size: 1.05rem; font-weight: 600; margin: 0 0 0.5rem; }
|
||||
.data-inner p { color: var(--text-2); font-size: 0.9rem; line-height: 1.6; margin-bottom: 0.8rem; }
|
||||
.data-inner strong { color: var(--text); }
|
||||
|
||||
/* Strategies tab */
|
||||
.strategies-hero { padding: 2.5rem 1.5rem 1.5rem; border-bottom: 1px solid var(--border); background: linear-gradient(180deg, #ffffff 0%, var(--bg) 100%); }
|
||||
.strategies-hero-inner { max-width: 1120px; margin: 0 auto; }
|
||||
.strategies-hero h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem; letter-spacing: -0.02em; }
|
||||
.strategies-hero p { color: var(--text-2); max-width: 720px; margin: 0; font-size: 1rem; }
|
||||
|
||||
.strategy-grid-section { padding: 2rem 1.5rem; }
|
||||
.strategy-grid {
|
||||
max-width: 1120px; margin: 0 auto;
|
||||
display: grid; gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
.strat-card {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 1.3rem 1.4rem;
|
||||
cursor: pointer; transition: all 0.12s ease;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.strat-card:hover { border-color: var(--accent); box-shadow: var(--shadow-lg); transform: translateY(-2px); }
|
||||
.strat-card.active { border-color: var(--accent); background: #f0f9ff; }
|
||||
.strat-card-head {
|
||||
display: flex; align-items: start; justify-content: space-between;
|
||||
gap: 0.5rem; margin-bottom: 0.6rem;
|
||||
}
|
||||
.strat-card-name {
|
||||
font-size: 1.05rem; font-weight: 700; color: var(--text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.strat-card-badge {
|
||||
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
|
||||
padding: 3px 9px; border-radius: 4px; flex-shrink: 0;
|
||||
}
|
||||
.strat-card-badge.win { background: var(--pos-soft); color: var(--pos); }
|
||||
.strat-card-badge.loss { background: var(--neg-soft); color: var(--neg); }
|
||||
.strat-card-badge.flat { background: var(--warn-soft); color: var(--warn); }
|
||||
.strat-card-desc {
|
||||
font-size: 0.88rem; color: var(--text-2);
|
||||
line-height: 1.5; margin: 0 0 1rem;
|
||||
}
|
||||
.strat-card-metric {
|
||||
font-family: var(--mono); font-size: 1.8rem; font-weight: 700;
|
||||
letter-spacing: -0.02em; line-height: 1;
|
||||
}
|
||||
.strat-card-metric.pos { color: var(--pos); }
|
||||
.strat-card-metric.neg { color: var(--neg); }
|
||||
.strat-card-metric.flat { color: var(--warn); }
|
||||
.strat-card-metric-sub { font-size: 0.78rem; color: var(--text-3); margin-top: 0.2rem; }
|
||||
.strat-card-stats {
|
||||
display: flex; gap: 1.1rem; margin-top: 0.9rem;
|
||||
padding-top: 0.9rem; border-top: 1px solid var(--border);
|
||||
}
|
||||
.strat-card-stat { font-size: 0.8rem; color: var(--text-3); }
|
||||
.strat-card-stat strong { color: var(--text); font-family: var(--mono); font-weight: 600; }
|
||||
.strat-card-learn {
|
||||
margin-top: 0.9rem; font-size: 0.82rem; color: var(--accent); font-weight: 500;
|
||||
}
|
||||
|
||||
/* Strategy detail modal */
|
||||
.modal-wide { max-width: 720px; }
|
||||
.strategy-detail h2 {
|
||||
font-size: 1.4rem; font-weight: 700; margin: 0 0 0.4rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.strategy-detail .detail-verdict {
|
||||
display: inline-block; padding: 4px 12px; border-radius: 999px;
|
||||
font-weight: 700; font-size: 0.78rem; letter-spacing: 0.05em; text-transform: uppercase;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.strategy-detail .detail-verdict.win { background: var(--pos-soft); color: var(--pos); }
|
||||
.strategy-detail .detail-verdict.loss { background: var(--neg-soft); color: var(--neg); }
|
||||
.strategy-detail .detail-verdict.flat { background: var(--warn-soft); color: var(--warn); }
|
||||
.strategy-detail .detail-rule {
|
||||
background: var(--surface-2); border: 1px solid var(--border);
|
||||
padding: 0.85rem 1rem; border-radius: var(--radius);
|
||||
font-size: 0.92rem; color: var(--text); margin-bottom: 1rem;
|
||||
}
|
||||
.strategy-detail .detail-rule strong { font-weight: 600; }
|
||||
.strategy-detail .detail-section { margin: 1.1rem 0; }
|
||||
.strategy-detail h3 {
|
||||
font-size: 0.78rem; letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--text-3); font-weight: 600; margin: 0 0 0.4rem;
|
||||
}
|
||||
.strategy-detail p { color: var(--text-2); line-height: 1.6; margin: 0 0 0.7rem; font-size: 0.93rem; }
|
||||
.strategy-detail p strong { color: var(--text); }
|
||||
.strategy-detail .detail-stats {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.strategy-detail .dstat {
|
||||
background: var(--surface-2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 0.7rem 0.85rem;
|
||||
}
|
||||
.strategy-detail .dstat-val { font-family: var(--mono); font-size: 1.15rem; font-weight: 700; }
|
||||
.strategy-detail .dstat-val.pos { color: var(--pos); }
|
||||
.strategy-detail .dstat-val.neg { color: var(--neg); }
|
||||
.strategy-detail .dstat-lbl { font-size: 0.72rem; color: var(--text-3); margin-top: 0.1rem; }
|
||||
.strategy-detail .cta-row {
|
||||
margin-top: 1.3rem; display: flex; gap: 0.6rem; flex-wrap: wrap;
|
||||
}
|
||||
.strategy-detail .cta-primary {
|
||||
background: var(--accent); color: white; border: 0;
|
||||
padding: 0.7rem 1.3rem; border-radius: 8px;
|
||||
font-weight: 600; font-size: 0.95rem; cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
.strategy-detail .cta-primary:hover { background: var(--accent-dark); }
|
||||
.strategy-detail .cta-secondary {
|
||||
background: var(--surface-2); color: var(--text-2); border: 1px solid var(--border);
|
||||
padding: 0.7rem 1.3rem; border-radius: 8px;
|
||||
font-weight: 500; font-size: 0.95rem; cursor: pointer;
|
||||
}
|
||||
.strategy-detail .cta-secondary:hover { color: var(--text); border-color: var(--border-2); }
|
||||
@@ -0,0 +1,675 @@
|
||||
/*
|
||||
* Polymarket Strategy Lab — pure browser-side backtester.
|
||||
*
|
||||
* Loads 100+ REAL resolved Polymarket categorical events from docs/data/
|
||||
* historical-events.json, runs five different strategies against them,
|
||||
* and shows you honest results — no cherry-picking.
|
||||
*
|
||||
* Each strategy is a pure function: given an event (outcomes + their
|
||||
* last-trade prices + who actually won), it returns what it would have
|
||||
* bought, how much it paid, and how much it got back. The backtest runner
|
||||
* tallies these across every event.
|
||||
*
|
||||
* The strategies live here, open and readable. You can read exactly what
|
||||
* each rule is doing.
|
||||
*/
|
||||
|
||||
// ============================== DATA ====================================
|
||||
|
||||
const DATA_URL = "./data/historical-events.json";
|
||||
|
||||
// ========================== STRATEGIES ==================================
|
||||
//
|
||||
// A strategy is: strategy(event) -> { action, cost, payout, note }
|
||||
// action: "trade" if we bought anything, "skip" if we passed
|
||||
// cost: total dollars paid (at last-trade prices)
|
||||
// payout: total dollars received after resolution
|
||||
// note: short plain-English description of what happened
|
||||
//
|
||||
// Every strategy bets into the same event in its own way. Results are tallied
|
||||
// across all events in the dataset.
|
||||
|
||||
const STRATEGIES = [
|
||||
{
|
||||
key: "basket-arb",
|
||||
name: "Basket Arbitrage",
|
||||
oneLiner: "Buy one share of every outcome — but only when the total cost is under $1.",
|
||||
rule: "If the sum of every outcome's last trade price is below $1.00, buy one share of every outcome. Otherwise skip. Exactly one outcome will win and pay $1, so you profit the gap.",
|
||||
why: "This is the textbook risk-free trade. It's the one real arbitrage on prediction markets. The question is: does it ever actually trigger in practice, on resting prices, for a retail bot that isn't co-located next to the exchange? The historical data tells the truth.",
|
||||
run(ev, window) {
|
||||
const prices = ev.outcomes.map(o => priceAt(o, ev, window));
|
||||
if (prices.some(p => p == null || p <= 0 || p >= 1)) {
|
||||
return { action: "skip", cost: 0, payout: 0, sum: null, note: "No price data at this window for at least one outcome." };
|
||||
}
|
||||
const sum = prices.reduce((a, b) => a + b, 0);
|
||||
if (sum >= 1.0) {
|
||||
return { action: "skip", cost: 0, payout: 0, sum, note: `Total cost $${sum.toFixed(3)}, above $1. No arbitrage — skipped.` };
|
||||
}
|
||||
return { action: "trade", cost: sum, payout: 1.0, sum, note: `Total cost $${sum.toFixed(3)}. Bought the full set — guaranteed $1 payout.` };
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
key: "favorite",
|
||||
name: "Bet the Favorite",
|
||||
oneLiner: "On every event, buy the single outcome the market thinks is most likely.",
|
||||
rule: "For each event, buy one share of whichever outcome has the highest price at the chosen time window. If that outcome wins you get $1, otherwise $0.",
|
||||
why: "Conventional wisdom: the market knows. If the favorite wins often enough you make money; if favorites are over-priced you lose. Tests whether Polymarket's top-line pricing has any slack.",
|
||||
run(ev, window) {
|
||||
let best = null, bestPrice = -1;
|
||||
for (const o of ev.outcomes) {
|
||||
const p = priceAt(o, ev, window);
|
||||
if (p == null) continue;
|
||||
if (p > bestPrice) { best = o; bestPrice = p; }
|
||||
}
|
||||
if (!best || bestPrice <= 0) return { action: "skip", cost: 0, payout: 0, note: "No valid prices." };
|
||||
return {
|
||||
action: "trade",
|
||||
cost: bestPrice,
|
||||
payout: best.yes_final_price,
|
||||
note: `Bought "${best.name}" at $${bestPrice.toFixed(3)}. ${best.yes_final_price === 1 ? "Won — payout $1." : "Lost — payout $0."}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
key: "longshot",
|
||||
name: "Bet the Longshot",
|
||||
oneLiner: "On every event, buy the cheapest outcome. Pray it wins.",
|
||||
rule: "For each event, buy one share of whichever outcome has the lowest positive price at the chosen window.",
|
||||
why: "The market prices longshots low for a reason. But if underdogs win more often than prices imply (a classic bias), this pays. Direct test.",
|
||||
run(ev, window) {
|
||||
let best = null, bestPrice = Infinity;
|
||||
for (const o of ev.outcomes) {
|
||||
const p = priceAt(o, ev, window);
|
||||
if (p == null || p <= 0) continue;
|
||||
if (p < bestPrice) { best = o; bestPrice = p; }
|
||||
}
|
||||
if (!best) return { action: "skip", cost: 0, payout: 0, note: "No valid prices." };
|
||||
return {
|
||||
action: "trade",
|
||||
cost: bestPrice,
|
||||
payout: best.yes_final_price,
|
||||
note: `Bought "${best.name}" at $${bestPrice.toFixed(3)}. ${best.yes_final_price === 1 ? "Won — payout $1." : "Lost — payout $0."}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
key: "equal-split",
|
||||
name: "Equal Split",
|
||||
oneLiner: "Buy one share of every outcome, always — no matter the price.",
|
||||
rule: "For each event, buy one share of every outcome. You pay the sum of prices. You receive $1 (exactly one wins).",
|
||||
why: "Basket Arbitrage without the safety condition. Every event is a tiny guaranteed loss equal to the “vig” — the amount by which Polymarket's prices overshoot $1. A baseline for what the market's rounding costs.",
|
||||
run(ev, window) {
|
||||
const prices = ev.outcomes.map(o => priceAt(o, ev, window));
|
||||
// If any outcome lacks price data at this window, skip (we can't evaluate)
|
||||
if (prices.some(p => p == null)) {
|
||||
return { action: "skip", cost: 0, payout: 0, sum: null, note: "No price data at this window for at least one outcome." };
|
||||
}
|
||||
if (prices.some(p => p == null || p <= 0)) {
|
||||
return { action: "skip", cost: 0, payout: 0, note: "Missing prices." };
|
||||
}
|
||||
const cost = prices.reduce((a, b) => a + b, 0);
|
||||
return { action: "trade", cost, payout: 1.0, note: `Paid $${cost.toFixed(3)} for every outcome. Guaranteed $1 payout.` };
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
key: "top-three",
|
||||
name: "Top Three",
|
||||
oneLiner: "Buy the three outcomes the market thinks are most likely. Win if any of them wins.",
|
||||
rule: "For each event with 3+ outcomes, buy one share of the three highest-priced outcomes at the chosen window. Pay the sum. Win $1 if any of those three wins.",
|
||||
why: "A hedged bet — buying most of the probability mass but skipping the tail. If the hit rate is high enough, it pays.",
|
||||
run(ev, window) {
|
||||
const priced = ev.outcomes.map(o => ({ o, p: priceAt(o, ev, window) })).filter(x => x.p != null && x.p > 0);
|
||||
if (priced.length < 3) return { action: "skip", cost: 0, payout: 0, note: "Fewer than 3 priced outcomes." };
|
||||
const top = [...priced].sort((a, b) => b.p - a.p).slice(0, 3);
|
||||
const cost = top.reduce((s, x) => s + x.p, 0);
|
||||
const won = top.some(x => x.o.yes_final_price === 1);
|
||||
return {
|
||||
action: "trade",
|
||||
cost,
|
||||
payout: won ? 1.0 : 0.0,
|
||||
note: `Bought top 3 (total $${cost.toFixed(3)}). ${won ? "One won — payout $1." : "None won — payout $0."}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ========================== BACKTEST RUNNER =============================
|
||||
|
||||
function runBacktest(strategy, events, window) {
|
||||
const rows = [];
|
||||
let totalCost = 0, totalPayout = 0;
|
||||
let trades = 0, wins = 0, losses = 0, skipped = 0;
|
||||
|
||||
for (const ev of events) {
|
||||
const result = strategy.run(ev, window);
|
||||
const pnl = (result.payout || 0) - (result.cost || 0);
|
||||
const row = { event: ev, result, pnl };
|
||||
rows.push(row);
|
||||
|
||||
if (result.action === "trade") {
|
||||
trades += 1;
|
||||
totalCost += result.cost || 0;
|
||||
totalPayout += result.payout || 0;
|
||||
if (pnl > 0) wins += 1;
|
||||
else if (pnl < 0) losses += 1;
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const pnlAbs = totalPayout - totalCost;
|
||||
const roi = totalCost > 0 ? pnlAbs / totalCost : 0;
|
||||
const winRate = trades > 0 ? wins / trades : null;
|
||||
|
||||
return {
|
||||
rows,
|
||||
totalCost, totalPayout, pnlAbs, roi,
|
||||
trades, wins, losses, skipped,
|
||||
winRate,
|
||||
eventCount: events.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================== STATE =======================================
|
||||
|
||||
const state = {
|
||||
events: [],
|
||||
results: {},
|
||||
activeKey: "basket-arb",
|
||||
tradeFilter: "all",
|
||||
bankroll: 1000,
|
||||
priceWindow: "24h", // key from WINDOWS below
|
||||
};
|
||||
|
||||
// Time windows: how many seconds before close to read the price.
|
||||
const WINDOWS = {
|
||||
"close": { label: "at close", seconds: 0 },
|
||||
"1h": { label: "1h before close", seconds: 3600 },
|
||||
"6h": { label: "6h before close", seconds: 6*3600 },
|
||||
"24h": { label: "24h before close", seconds: 24*3600 },
|
||||
"3d": { label: "3 days before close", seconds: 3*24*3600 },
|
||||
"7d": { label: "7 days before close", seconds: 7*24*3600 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the actual price a trader would have seen on Polymarket at a specific
|
||||
* time. Uses real historical price data pulled from Polymarket's public CLOB
|
||||
* price-history endpoint — not estimates.
|
||||
*/
|
||||
function priceAt(outcome, ev, windowKey) {
|
||||
const hist = outcome.history;
|
||||
if (!hist || !hist.length) return null;
|
||||
const w = WINDOWS[windowKey] || WINDOWS["close"];
|
||||
const closeTs = ev._closeTs; // precomputed
|
||||
if (closeTs == null) return null;
|
||||
const targetTs = closeTs - w.seconds;
|
||||
// If the target is before any recorded data, no price
|
||||
if (hist[0].t > targetTs) return null;
|
||||
// Binary search for the last point with t <= targetTs
|
||||
let lo = 0, hi = hist.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = Math.ceil((lo + hi) / 2);
|
||||
if (hist[mid].t <= targetTs) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return hist[lo].p;
|
||||
}
|
||||
|
||||
// ========================== DOM =========================================
|
||||
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const el = {
|
||||
tabResults: $("#tab-results"),
|
||||
tabStrategies: $("#tab-strategies"),
|
||||
panelResults: $("#panel-results"),
|
||||
panelStrategies:$("#panel-strategies"),
|
||||
eventCountInline: $("#event-count-inline"),
|
||||
eventCountStrat: $("#event-count-strat"),
|
||||
activeLabel: $("#active-strategy-label"),
|
||||
activeName: $("#active-strategy-name"),
|
||||
activeDesc: $("#active-strategy-desc"),
|
||||
switchBtn: $("#switch-btn"),
|
||||
verdictCard: $("#verdict-card"),
|
||||
verdictIcon: $("#verdict-icon"),
|
||||
verdictLabel: $("#verdict-label"),
|
||||
verdictDetail: $("#verdict-detail"),
|
||||
vstatPnl: $("#vstat-pnl"),
|
||||
vstatPnlLbl: $("#vstat-pnl-lbl"),
|
||||
vstatRoi: $("#vstat-roi"),
|
||||
vstatTrades: $("#vstat-trades"),
|
||||
vstatWinrate: $("#vstat-winrate"),
|
||||
vstatAnnual: $("#vstat-annual"),
|
||||
bankrollChoices: $("#bankroll-choices"),
|
||||
bankrollNote: $("#bankroll-note"),
|
||||
verdictExplainer: $("#verdict-explainer"),
|
||||
cntAll: $("#cnt-all"),
|
||||
cntTrades: $("#cnt-trades"),
|
||||
cntWins: $("#cnt-wins"),
|
||||
cntLosses: $("#cnt-losses"),
|
||||
cntSkipped: $("#cnt-skipped"),
|
||||
tradeList: $("#trade-list"),
|
||||
strategyGrid: $("#strategy-grid"),
|
||||
modal: $("#strategy-modal"),
|
||||
modalContent: $("#strategy-modal-content"),
|
||||
};
|
||||
|
||||
// ========================== BOOT ========================================
|
||||
|
||||
boot().catch(err => {
|
||||
console.error("lab boot failed", err);
|
||||
el.verdictLabel.textContent = "Couldn't load historical data";
|
||||
el.verdictDetail.textContent = String(err.message || err);
|
||||
});
|
||||
|
||||
async function boot() {
|
||||
const resp = await fetch(DATA_URL + "?t=" + Date.now());
|
||||
if (!resp.ok) throw new Error("historical-events.json " + resp.status);
|
||||
const payload = await resp.json();
|
||||
state.events = Array.isArray(payload?.events) ? payload.events : [];
|
||||
if (!state.events.length) throw new Error("No events found in dataset");
|
||||
|
||||
// Precompute the close timestamp (seconds since epoch) for each event, so
|
||||
// priceAt() can do a cheap binary search per lookup.
|
||||
for (const ev of state.events) {
|
||||
const raw = ev.closed_time || ev.end_date || "";
|
||||
const iso = String(raw).replace(" +00", "+00:00").replace("Z", "+00:00");
|
||||
const d = new Date(iso);
|
||||
ev._closeTs = isNaN(d.getTime()) ? null : Math.floor(d.getTime() / 1000);
|
||||
}
|
||||
|
||||
const ends = state.events
|
||||
.map(e => e._closeTs ? new Date(e._closeTs * 1000) : null)
|
||||
.filter(d => d != null)
|
||||
.sort((a, b) => a - b);
|
||||
state.spanFirst = ends[0];
|
||||
state.spanLast = ends[ends.length - 1];
|
||||
state.spanDays = Math.max(1, (state.spanLast - state.spanFirst) / (1000 * 60 * 60 * 24));
|
||||
|
||||
el.eventCountInline.textContent = `${state.events.length} events · ${formatSpanDescription(state.spanFirst, state.spanLast)}`;
|
||||
el.eventCountStrat.textContent = state.events.length;
|
||||
|
||||
rerunBacktests();
|
||||
|
||||
wireInteractions();
|
||||
renderStrategyGrid();
|
||||
renderActiveStrategy();
|
||||
}
|
||||
|
||||
function rerunBacktests() {
|
||||
for (const s of STRATEGIES) {
|
||||
state.results[s.key] = runBacktest(s, state.events, state.priceWindow);
|
||||
}
|
||||
}
|
||||
|
||||
function formatSpanDescription(first, last, withMonths = true) {
|
||||
if (!first || !last) return "";
|
||||
const fmt = { month: "short", year: "numeric" };
|
||||
const range = `${first.toLocaleDateString(undefined, fmt)} – ${last.toLocaleDateString(undefined, fmt)}`;
|
||||
if (!withMonths) return range;
|
||||
const months = (state.spanDays / 30).toFixed(1);
|
||||
return `${range} (${months} months)`;
|
||||
}
|
||||
function pluralize(n, word) {
|
||||
return n === 1 ? `1 ${word}` : `${n} ${word}s`;
|
||||
}
|
||||
|
||||
function wireInteractions() {
|
||||
// Tabs
|
||||
el.tabResults.addEventListener("click", () => switchTab("results"));
|
||||
el.tabStrategies.addEventListener("click", () => switchTab("strategies"));
|
||||
|
||||
// "Change strategy" button on results page -> jumps to strategies tab
|
||||
el.switchBtn.addEventListener("click", () => switchTab("strategies"));
|
||||
|
||||
// Trade filter buttons
|
||||
document.querySelectorAll(".filter-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.tradeFilter = btn.dataset.filter;
|
||||
document.querySelectorAll(".filter-btn").forEach(b => b.classList.toggle("active", b === btn));
|
||||
renderTradeList();
|
||||
});
|
||||
});
|
||||
|
||||
// CSV download
|
||||
const dl = document.getElementById("csv-download");
|
||||
if (dl) dl.addEventListener("click", (e) => { e.preventDefault(); downloadCsv(); });
|
||||
|
||||
// Bankroll selector
|
||||
el.bankrollChoices.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("button[data-bankroll]");
|
||||
if (!btn) return;
|
||||
state.bankroll = parseInt(btn.dataset.bankroll, 10) || 1000;
|
||||
[...el.bankrollChoices.querySelectorAll("button")].forEach(b => b.classList.toggle("active", b === btn));
|
||||
renderActiveStrategy();
|
||||
renderStrategyGrid();
|
||||
});
|
||||
|
||||
// Price-window selector
|
||||
const windowChoices = document.getElementById("window-choices");
|
||||
windowChoices.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("button[data-window]");
|
||||
if (!btn) return;
|
||||
state.priceWindow = btn.dataset.window;
|
||||
[...windowChoices.querySelectorAll("button")].forEach(b => b.classList.toggle("active", b === btn));
|
||||
rerunBacktests();
|
||||
renderActiveStrategy();
|
||||
renderStrategyGrid();
|
||||
});
|
||||
|
||||
// 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 switchTab(which) {
|
||||
const isResults = which === "results";
|
||||
el.tabResults.classList.toggle("active", isResults);
|
||||
el.tabStrategies.classList.toggle("active", !isResults);
|
||||
el.panelResults.classList.toggle("active", isResults);
|
||||
el.panelStrategies.classList.toggle("active", !isResults);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
// ========================== RENDER: RESULTS TAB =========================
|
||||
|
||||
function renderActiveStrategy() {
|
||||
const strategy = STRATEGIES.find(s => s.key === state.activeKey);
|
||||
if (!strategy) return;
|
||||
const result = state.results[strategy.key];
|
||||
|
||||
el.activeLabel.textContent = "Active strategy";
|
||||
el.activeName.textContent = strategy.name;
|
||||
el.activeDesc.textContent = strategy.oneLiner;
|
||||
|
||||
renderVerdict(strategy, result);
|
||||
renderTradeList();
|
||||
}
|
||||
|
||||
function verdictClass(result) {
|
||||
const pnl = result.pnlAbs;
|
||||
if (Math.abs(pnl) < 0.005) return "flat";
|
||||
return pnl > 0 ? "win" : "loss";
|
||||
}
|
||||
|
||||
function renderVerdict(strategy, result) {
|
||||
const cls = verdictClass(result);
|
||||
el.verdictCard.className = "verdict-card " + cls;
|
||||
el.verdictIcon.textContent = cls === "win" ? "✓" : cls === "loss" ? "✗" : "≈";
|
||||
|
||||
const { roi, trades, wins, losses, eventCount } = result;
|
||||
const totalPnl = roi * state.bankroll * trades; // bet $bankroll each trade, PnL per trade = roi*bankroll
|
||||
|
||||
const months = (state.spanDays / 30).toFixed(1);
|
||||
const span = formatSpanDescription(state.spanFirst, state.spanLast, false);
|
||||
const firedN = pluralize(trades, "time");
|
||||
|
||||
if (trades === 0) {
|
||||
el.verdictLabel.textContent = "Strategy never triggered";
|
||||
el.verdictDetail.textContent = `Over ${months} months of real Polymarket events (${span}), this strategy's rule never fired even once. Pure arbitrage on resting prices almost never exists — bots eat any gap in milliseconds.`;
|
||||
} else if (cls === "win") {
|
||||
el.verdictLabel.textContent = "Made money on this dataset";
|
||||
el.verdictDetail.textContent = `Over ${months} months (${span}) this strategy fired ${firedN} across ${eventCount} events. ${wins} wins, ${losses} losses. At a $${state.bankroll.toLocaleString()} bankroll per trade, total profit was ${formatSignedDollar(totalPnl)}.`;
|
||||
} else if (cls === "loss") {
|
||||
el.verdictLabel.textContent = "Lost money on this dataset";
|
||||
el.verdictDetail.textContent = `Over ${months} months (${span}) this strategy fired ${firedN} across ${eventCount} events. ${wins} wins, ${losses} losses. At a $${state.bankroll.toLocaleString()} bankroll per trade, total loss was ${formatSignedDollar(totalPnl)}.`;
|
||||
} else {
|
||||
el.verdictLabel.textContent = "Roughly break-even";
|
||||
el.verdictDetail.textContent = `Over ${months} months (${span}) this strategy fired ${firedN} across ${eventCount} events. Total profit with a $${state.bankroll.toLocaleString()} bankroll was ${formatSignedDollar(totalPnl)} — essentially nothing.`;
|
||||
}
|
||||
|
||||
el.vstatPnl.textContent = formatSignedDollar(totalPnl);
|
||||
el.vstatPnl.className = "vstat-val " + (totalPnl > 0.005 ? "pos" : totalPnl < -0.005 ? "neg" : "");
|
||||
el.vstatPnlLbl.textContent = `Total profit at $${state.bankroll.toLocaleString()} per trade`;
|
||||
|
||||
el.vstatRoi.textContent = trades > 0 ? formatSignedPct(roi) : "—";
|
||||
el.vstatRoi.className = "vstat-val " + (roi > 0.0001 ? "pos" : roi < -0.0001 ? "neg" : "");
|
||||
el.vstatTrades.textContent = `${trades} of ${eventCount}`;
|
||||
el.vstatTrades.className = "vstat-val";
|
||||
el.vstatWinrate.textContent = trades > 0 ? `${(result.winRate * 100).toFixed(1)}%` : "—";
|
||||
el.vstatWinrate.className = "vstat-val";
|
||||
|
||||
// Annualized profit: scale the total by (365 / span)
|
||||
const annualPnl = totalPnl * (365 / state.spanDays);
|
||||
el.vstatAnnual.textContent = trades > 0 ? formatSignedDollar(annualPnl) : "—";
|
||||
el.vstatAnnual.className = "vstat-val " + (annualPnl > 0.005 ? "pos" : annualPnl < -0.005 ? "neg" : "");
|
||||
|
||||
el.verdictExplainer.innerHTML = strategy.why;
|
||||
}
|
||||
|
||||
function renderTradeList() {
|
||||
const strategy = STRATEGIES.find(s => s.key === state.activeKey);
|
||||
const result = state.results[strategy.key];
|
||||
const all = result.rows;
|
||||
|
||||
const filters = {
|
||||
all: (r) => true,
|
||||
trades: (r) => r.result.action === "trade",
|
||||
wins: (r) => r.result.action === "trade" && r.pnl > 0,
|
||||
losses: (r) => r.result.action === "trade" && r.pnl < 0,
|
||||
skipped: (r) => r.result.action === "skip",
|
||||
};
|
||||
const filtered = all.filter(filters[state.tradeFilter]);
|
||||
|
||||
// counts
|
||||
el.cntAll.textContent = all.length;
|
||||
el.cntTrades.textContent = all.filter(filters.trades).length;
|
||||
el.cntWins.textContent = all.filter(filters.wins).length;
|
||||
el.cntLosses.textContent = all.filter(filters.losses).length;
|
||||
el.cntSkipped.textContent = all.filter(filters.skipped).length;
|
||||
|
||||
// sort: trades first (by |pnl| desc), then skipped
|
||||
filtered.sort((a, b) => {
|
||||
const aAct = a.result.action === "trade" ? 0 : 1;
|
||||
const bAct = b.result.action === "trade" ? 0 : 1;
|
||||
if (aAct !== bAct) return aAct - bAct;
|
||||
return Math.abs(b.pnl) - Math.abs(a.pnl);
|
||||
});
|
||||
|
||||
el.tradeList.innerHTML = "";
|
||||
if (!filtered.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "trade-show-more";
|
||||
empty.style.cursor = "default";
|
||||
empty.textContent = "No trades match this filter.";
|
||||
el.tradeList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
// Show every row. If you claim N trades, you show N trades.
|
||||
for (const r of filtered) {
|
||||
el.tradeList.appendChild(renderTradeRow(r));
|
||||
}
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "trade-count-footer";
|
||||
footer.innerHTML = `Showing all <strong>${filtered.length}</strong> ${filtered.length === 1 ? "row" : "rows"} · <a href="#" id="csv-download">download as CSV</a>`;
|
||||
el.tradeList.appendChild(footer);
|
||||
const dl = document.getElementById("csv-download");
|
||||
if (dl) dl.addEventListener("click", (e) => { e.preventDefault(); downloadCsv(); });
|
||||
}
|
||||
|
||||
function downloadCsv() {
|
||||
const strategy = STRATEGIES.find(s => s.key === state.activeKey);
|
||||
const result = state.results[strategy.key];
|
||||
const rows = [["event_title", "neg_risk", "num_outcomes", "action", "cost", "payout", "pnl", "note"]];
|
||||
for (const r of result.rows) {
|
||||
rows.push([
|
||||
r.event.title,
|
||||
String(r.event.neg_risk),
|
||||
String(r.event.num_outcomes),
|
||||
r.result.action,
|
||||
(r.result.cost || 0).toFixed(4),
|
||||
(r.result.payout || 0).toFixed(4),
|
||||
r.pnl.toFixed(4),
|
||||
(r.result.note || "").replace(/[\r\n]+/g, " "),
|
||||
]);
|
||||
}
|
||||
const csv = rows.map(row => row.map(v => {
|
||||
const s = String(v);
|
||||
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
||||
}).join(",")).join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `polymarket-backtest-${strategy.key}.csv`;
|
||||
document.body.appendChild(a); a.click();
|
||||
setTimeout(() => { URL.revokeObjectURL(url); document.body.removeChild(a); }, 0);
|
||||
}
|
||||
|
||||
function renderTradeRow(r) {
|
||||
const row = document.createElement("div");
|
||||
const didTrade = r.result.action === "trade";
|
||||
const cls = didTrade
|
||||
? (r.pnl > 0 ? "win" : r.pnl < 0 ? "loss" : "skip")
|
||||
: "skip";
|
||||
row.className = "trade-row " + cls;
|
||||
|
||||
// Scale by bankroll: if backtest cost was $0.40 for one share, and bankroll is
|
||||
// $1000, the trader would buy $1000/$0.40 = 2500 units — scaled pnl = roi * bankroll.
|
||||
const unitRoi = r.result.cost > 0 ? (r.pnl / r.result.cost) : 0;
|
||||
const scaledCost = didTrade ? state.bankroll : 0;
|
||||
const scaledPayout = didTrade ? state.bankroll * (1 + unitRoi) : 0;
|
||||
const scaledPnl = scaledPayout - scaledCost;
|
||||
|
||||
const meta = didTrade
|
||||
? `paid $${scaledCost.toLocaleString(undefined, {maximumFractionDigits:2})} → got back $${scaledPayout.toLocaleString(undefined, {maximumFractionDigits:2})}`
|
||||
: (r.result.note || "Strategy did not trade this event.");
|
||||
|
||||
const resultCell = didTrade
|
||||
? (scaledPnl > 0.005
|
||||
? `<span class="trade-result pos">+$${scaledPnl.toLocaleString(undefined, {maximumFractionDigits:2})}</span>`
|
||||
: scaledPnl < -0.005
|
||||
? `<span class="trade-result neg">-$${Math.abs(scaledPnl).toLocaleString(undefined, {maximumFractionDigits:2})}</span>`
|
||||
: `<span class="trade-result neutral">$0.00</span>`)
|
||||
: `<span class="trade-result neutral">skipped</span>`;
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="trade-event">
|
||||
<div class="trade-title">${escapeHtml(r.event.title)}</div>
|
||||
<div class="trade-meta">${escapeHtml(meta)}</div>
|
||||
</div>
|
||||
<div class="trade-action">${escapeHtml(didTrade ? r.result.note : "")}</div>
|
||||
${resultCell}
|
||||
`;
|
||||
return row;
|
||||
}
|
||||
|
||||
// ========================== RENDER: STRATEGIES TAB ======================
|
||||
|
||||
function renderStrategyGrid() {
|
||||
el.strategyGrid.innerHTML = "";
|
||||
for (const s of STRATEGIES) {
|
||||
const r = state.results[s.key];
|
||||
const cls = verdictClass(r);
|
||||
const card = document.createElement("div");
|
||||
card.className = "strat-card" + (s.key === state.activeKey ? " active" : "");
|
||||
const metric = r.trades > 0 ? formatSignedPct(r.roi) : "never fired";
|
||||
const totalScaledPnl = r.roi * state.bankroll * r.trades;
|
||||
const metricSub = r.trades > 0
|
||||
? `${formatSignedDollar(totalScaledPnl)} total at $${state.bankroll.toLocaleString()}/trade · ${r.trades} trades`
|
||||
: `skipped all ${r.eventCount} events`;
|
||||
const verdictLabel = r.trades === 0 ? "INACTIVE"
|
||||
: cls === "win" ? "PROFITABLE"
|
||||
: cls === "loss" ? "LOSES MONEY"
|
||||
: "BREAK-EVEN";
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="strat-card-head">
|
||||
<div class="strat-card-name">${escapeHtml(s.name)}</div>
|
||||
<div class="strat-card-badge ${cls}">${verdictLabel}</div>
|
||||
</div>
|
||||
<p class="strat-card-desc">${escapeHtml(s.oneLiner)}</p>
|
||||
<div class="strat-card-metric ${cls}">${metric}</div>
|
||||
<div class="strat-card-metric-sub">${escapeHtml(metricSub)}</div>
|
||||
<div class="strat-card-stats">
|
||||
<div class="strat-card-stat">trades: <strong>${r.trades}</strong></div>
|
||||
<div class="strat-card-stat">wins: <strong>${r.wins}</strong></div>
|
||||
<div class="strat-card-stat">losses: <strong>${r.losses}</strong></div>
|
||||
</div>
|
||||
<div class="strat-card-learn">Learn more & use this strategy →</div>
|
||||
`;
|
||||
card.addEventListener("click", () => openStrategyModal(s));
|
||||
el.strategyGrid.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function openStrategyModal(s) {
|
||||
const r = state.results[s.key];
|
||||
const cls = verdictClass(r);
|
||||
const verdictLabel = r.trades === 0 ? "STRATEGY NEVER FIRED"
|
||||
: cls === "win" ? "PROFITABLE ON THIS DATASET"
|
||||
: cls === "loss" ? "LOSES MONEY ON THIS DATASET"
|
||||
: "ROUGHLY BREAK-EVEN";
|
||||
|
||||
el.modalContent.innerHTML = `
|
||||
<div class="strategy-detail">
|
||||
<h2>${escapeHtml(s.name)}</h2>
|
||||
<div class="detail-verdict ${cls}">${verdictLabel}</div>
|
||||
|
||||
<div class="detail-rule"><strong>The rule:</strong> ${s.rule}</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>Why this strategy?</h3>
|
||||
<p>${s.why}</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>Results on ${r.eventCount} real resolved events</h3>
|
||||
<div class="detail-stats">
|
||||
<div class="dstat">
|
||||
<div class="dstat-val ${r.pnlAbs > 0 ? 'pos' : r.pnlAbs < 0 ? 'neg' : ''}">${formatSignedDollar(r.pnlAbs)}</div>
|
||||
<div class="dstat-lbl">total profit</div>
|
||||
</div>
|
||||
<div class="dstat">
|
||||
<div class="dstat-val ${r.roi > 0 ? 'pos' : r.roi < 0 ? 'neg' : ''}">${r.trades > 0 ? formatSignedPct(r.roi) : '—'}</div>
|
||||
<div class="dstat-lbl">ROI per dollar</div>
|
||||
</div>
|
||||
<div class="dstat">
|
||||
<div class="dstat-val">${r.trades}</div>
|
||||
<div class="dstat-lbl">trades taken</div>
|
||||
</div>
|
||||
<div class="dstat">
|
||||
<div class="dstat-val">${r.trades > 0 ? (r.winRate * 100).toFixed(1) + '%' : '—'}</div>
|
||||
<div class="dstat-lbl">win rate</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cta-row">
|
||||
<button type="button" class="cta-primary" id="use-strategy">Run this strategy on Results tab</button>
|
||||
<button type="button" class="cta-secondary" data-close>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
el.modal.hidden = false;
|
||||
document.getElementById("use-strategy").addEventListener("click", () => {
|
||||
state.activeKey = s.key;
|
||||
state.tradeFilter = "all";
|
||||
document.querySelectorAll(".filter-btn").forEach(b => b.classList.toggle("active", b.dataset.filter === "all"));
|
||||
renderActiveStrategy();
|
||||
renderStrategyGrid();
|
||||
el.modal.hidden = true;
|
||||
switchTab("results");
|
||||
});
|
||||
}
|
||||
|
||||
// ========================== UTILS =======================================
|
||||
|
||||
function formatSignedDollar(x) {
|
||||
const sign = x >= 0 ? "+" : "−";
|
||||
return sign + "$" + Math.abs(x).toFixed(2);
|
||||
}
|
||||
function formatSignedPct(x) {
|
||||
const sign = x >= 0 ? "+" : "−";
|
||||
return sign + Math.abs(x * 100).toFixed(2) + "%";
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
/* Polymarket-inspired layout for a portfolio arb-scanner demo. */
|
||||
|
||||
:root {
|
||||
--bg: #f7f8fa;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f1f3f6;
|
||||
--border: #e5e7eb;
|
||||
--border-2: #d1d5db;
|
||||
--text: #0f172a;
|
||||
--text-2: #4b5563;
|
||||
--text-3: #6b7280;
|
||||
--accent: #2d9cdb;
|
||||
--accent-dark:#1e7fb8;
|
||||
--pos: #16a34a;
|
||||
--pos-soft: #dcfce7;
|
||||
--neg: #dc2626;
|
||||
--neg-soft: #fee2e2;
|
||||
--warn: #d97706;
|
||||
--warn-soft: #fef3c7;
|
||||
--radius: 10px;
|
||||
--radius-lg: 14px;
|
||||
--shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 1px 3px rgba(15, 23, 42, 0.06);
|
||||
--shadow-lg: 0 4px 10px rgba(15, 23, 42, 0.06), 0 2px 4px rgba(15, 23, 42, 0.04);
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
--sans: "Inter", -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
line-height: 1.5;
|
||||
font-size: 15px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* ---------- landing-page shared styles ---------- */
|
||||
.container { max-width: 1120px; margin: 0 auto; padding: 2rem 1.5rem; }
|
||||
header.hero { padding: 4rem 1.5rem 3rem; background: linear-gradient(180deg, #ffffff 0%, var(--bg) 100%); border-bottom: 1px solid var(--border); }
|
||||
.hero-inner { max-width: 1120px; margin: 0 auto; padding: 0 1.5rem; }
|
||||
.hero h1 { font-size: 2.5rem; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 0.5rem; }
|
||||
.hero p.tagline { font-size: 1.08rem; color: var(--text-2); max-width: 720px; margin: 0 0 1.5rem; }
|
||||
.btn-row { display: flex; gap: 0.6rem; flex-wrap: wrap; margin-top: 1.25rem; }
|
||||
.btn { display: inline-block; padding: 0.6rem 1.15rem; border-radius: 8px; background: var(--surface); border: 1px solid var(--border); color: var(--text); font-weight: 500; font-size: 0.95rem; transition: all 0.12s ease; }
|
||||
.btn:hover { text-decoration: none; border-color: var(--border-2); transform: translateY(-1px); }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: white; }
|
||||
.btn.primary:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
|
||||
.badges { display: flex; gap: 0.4rem; margin-top: 0.8rem; flex-wrap: wrap; }
|
||||
.badge { display: inline-block; padding: 3px 10px; border-radius: 4px; background: var(--surface); border: 1px solid var(--border); color: var(--text-2); font-size: 0.78rem; font-family: var(--mono); }
|
||||
.badge.pos { color: var(--pos); border-color: rgba(22, 163, 74, 0.3); background: var(--pos-soft); }
|
||||
section { padding: 3rem 1.5rem; border-bottom: 1px solid var(--border); background: var(--bg); }
|
||||
section:nth-of-type(even) { background: var(--surface); }
|
||||
section h2 { font-size: 1.4rem; font-weight: 600; margin: 0 0 1rem; letter-spacing: -0.01em; }
|
||||
section h2 .rule { display: inline-block; width: 2rem; height: 2px; background: var(--accent); vertical-align: middle; margin-right: 0.6rem; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr; gap: 1.25rem; }
|
||||
@media (min-width: 780px) { .grid-2 { grid-template-columns: 1fr 1fr; } }
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.1rem 1.35rem; box-shadow: var(--shadow); }
|
||||
.card h3 { margin: 0 0 0.5rem; font-size: 1rem; font-weight: 600; }
|
||||
.card p { margin: 0; color: var(--text-2); font-size: 0.94rem; }
|
||||
pre, code { font-family: var(--mono); }
|
||||
code { background: var(--surface-2); padding: 2px 6px; border-radius: 4px; font-size: 0.88em; }
|
||||
pre { background: var(--surface); border: 1px solid var(--border); padding: 0.95rem 1.1rem; border-radius: var(--radius); overflow-x: auto; font-size: 0.86rem; box-shadow: var(--shadow); }
|
||||
pre code { background: transparent; padding: 0; }
|
||||
.arch-diagram { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem; font-family: var(--mono); font-size: 0.78rem; color: var(--text-2); white-space: pre; overflow-x: auto; box-shadow: var(--shadow); }
|
||||
.metrics-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.9rem; margin-top: 1.25rem; }
|
||||
.metric { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; text-align: center; box-shadow: var(--shadow); }
|
||||
.metric .val { font-size: 1.8rem; font-weight: 700; color: var(--accent); font-family: var(--mono); }
|
||||
.metric .label { display: block; font-size: 0.78rem; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; margin-top: 0.2rem; font-weight: 500; }
|
||||
footer { padding: 2.5rem 1.5rem; text-align: center; color: var(--text-3); font-size: 0.85rem; background: var(--bg); }
|
||||
footer a { color: var(--text-2); }
|
||||
|
||||
/* ==================== DEMO PAGE ==================== */
|
||||
|
||||
.demo-shell { min-height: 100vh; background: var(--bg); }
|
||||
|
||||
/* Top nav */
|
||||
.demo-nav {
|
||||
background: var(--surface); border-bottom: 1px solid var(--border);
|
||||
padding: 0.9rem 1.5rem;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
flex-wrap: wrap; gap: 1rem;
|
||||
}
|
||||
.demo-nav .brand { display: flex; align-items: center; gap: 0.7rem; font-size: 0.95rem; }
|
||||
.demo-nav .brand strong { font-weight: 600; }
|
||||
.demo-nav .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--text-3); }
|
||||
.demo-nav .dot.on { background: var(--pos); animation: pulse 2s infinite; }
|
||||
.demo-nav .dot.pend { background: var(--warn); }
|
||||
.demo-nav .dot.bad { background: var(--neg); }
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.55); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(22, 163, 74, 0); }
|
||||
}
|
||||
.demo-nav .status-text { color: var(--text-2); font-size: 0.88rem; }
|
||||
.demo-nav .nav-links { display: flex; gap: 1rem; align-items: center; }
|
||||
.demo-nav .nav-links a { color: var(--text-2); font-size: 0.88rem; font-weight: 500; }
|
||||
.demo-nav .nav-links a:hover { color: var(--accent); }
|
||||
|
||||
/* Hero question */
|
||||
.hero-q {
|
||||
background: linear-gradient(180deg, #ffffff 0%, var(--bg) 100%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 2.5rem 1.5rem;
|
||||
}
|
||||
.hero-q-inner { max-width: 1120px; margin: 0 auto; text-align: center; }
|
||||
.q-line {
|
||||
font-size: 1.6rem; font-weight: 500; color: var(--text-2);
|
||||
max-width: 800px; margin: 0 auto 0.9rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.a-line {
|
||||
font-size: 2.4rem; font-weight: 700; letter-spacing: -0.02em;
|
||||
line-height: 1.15; color: var(--text); margin-bottom: 0.6rem;
|
||||
min-height: 3rem; display: flex; align-items: center; justify-content: center; gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@media (min-width: 700px) { .a-line { font-size: 2.8rem; } }
|
||||
.a-line .verdict { font-size: 1em; }
|
||||
.a-line .verdict.no { color: var(--text-2); }
|
||||
.a-line .verdict.yes { color: var(--pos); }
|
||||
.a-line .verdict.near { color: var(--warn); }
|
||||
.a-line .detail { font-weight: 600; font-size: 0.9em; color: var(--text-2); }
|
||||
.a-line .cost-chip {
|
||||
font-family: var(--mono); font-weight: 700;
|
||||
padding: 0.15em 0.5em; border-radius: 8px; background: var(--surface);
|
||||
border: 1px solid var(--border); font-size: 0.85em;
|
||||
}
|
||||
.a-line .cost-chip.no { color: var(--text); }
|
||||
.a-line .cost-chip.yes { color: var(--pos); border-color: rgba(22,163,74,0.3); background: var(--pos-soft); }
|
||||
.a-line .cost-chip.near{ color: var(--warn); border-color: rgba(217,119,6,0.3); background: var(--warn-soft); }
|
||||
.a-sub {
|
||||
color: var(--text-3); font-size: 0.9rem;
|
||||
max-width: 600px; margin: 0 auto;
|
||||
}
|
||||
.a-sub strong { color: var(--text); }
|
||||
.spinner {
|
||||
width: 1em; height: 1em;
|
||||
border: 2px solid var(--border); border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.muted { color: var(--text-3); }
|
||||
|
||||
/* Workbench layout */
|
||||
.workbench {
|
||||
max-width: 1120px; margin: 0 auto; padding: 1.5rem;
|
||||
display: grid; gap: 1.25rem; grid-template-columns: 1fr;
|
||||
}
|
||||
@media (min-width: 960px) {
|
||||
.workbench { grid-template-columns: 310px 1fr; }
|
||||
}
|
||||
|
||||
/* Left column: event cards */
|
||||
.column-head h3 {
|
||||
font-size: 0.8rem; letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--text-3); margin: 0 0 0.3rem; font-weight: 600;
|
||||
}
|
||||
.column-head p { margin: 0 0 0.8rem; color: var(--text-3); font-size: 0.82rem; }
|
||||
.event-cards { display: flex; flex-direction: column; gap: 0.55rem; }
|
||||
.event-card {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 0.75rem 0.9rem;
|
||||
cursor: pointer; transition: all 0.1s ease;
|
||||
display: flex; flex-direction: column; gap: 0.3rem;
|
||||
}
|
||||
.event-card:hover { border-color: var(--accent); box-shadow: var(--shadow-lg); transform: translateY(-1px); }
|
||||
.event-card.active { border-color: var(--accent); background: #f0f9ff; box-shadow: var(--shadow-lg); }
|
||||
.event-card .ec-title { font-weight: 500; font-size: 0.92rem; color: var(--text); }
|
||||
.event-card .ec-meta {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.event-card .ec-count { color: var(--text-3); }
|
||||
.event-card .ec-cost {
|
||||
font-family: var(--mono); font-weight: 600;
|
||||
padding: 2px 8px; border-radius: 4px;
|
||||
background: var(--surface-2); color: var(--text-3);
|
||||
}
|
||||
.event-card .ec-cost.no { background: var(--surface-2); color: var(--text-3); }
|
||||
.event-card .ec-cost.near { background: var(--warn-soft); color: var(--warn); }
|
||||
.event-card .ec-cost.yes { background: var(--pos-soft); color: var(--pos); font-weight: 700; }
|
||||
.event-card.skeleton { cursor: default; color: var(--text-3); font-size: 0.88rem; text-align: center; padding: 1.5rem 0.9rem; }
|
||||
|
||||
/* Right column: focused event + calculator */
|
||||
.focused-event {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 1.5rem 1.6rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.focused-head { margin-bottom: 1.2rem; }
|
||||
.focused-title-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.focused-event h2 {
|
||||
font-size: 1.35rem; font-weight: 700; margin: 0 0 0.25rem;
|
||||
letter-spacing: -0.01em; color: var(--text);
|
||||
}
|
||||
.focused-event .subtitle { color: var(--text-3); font-size: 0.85rem; }
|
||||
.why-btn {
|
||||
background: var(--surface-2); border: 1px solid var(--border);
|
||||
color: var(--text-2); font-size: 0.8rem; font-weight: 500;
|
||||
padding: 0.3rem 0.7rem; border-radius: 999px; cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
.why-btn:hover { background: var(--surface); border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
/* The calculator card — the star */
|
||||
.calc-card {
|
||||
background: var(--surface-2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.calc-row.calc-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; gap: 1rem; }
|
||||
.calc-label { font-size: 0.88rem; color: var(--text-2); font-weight: 500; }
|
||||
.status {
|
||||
font-size: 0.75rem; font-weight: 700; padding: 3px 10px; border-radius: 999px;
|
||||
text-transform: uppercase; letter-spacing: 0.04em; flex-shrink: 0;
|
||||
}
|
||||
.status.arb { background: var(--pos-soft); color: var(--pos); }
|
||||
.status.near { background: var(--warn-soft); color: var(--warn); }
|
||||
.status.fair { background: var(--surface); color: var(--text-3); border: 1px solid var(--border); }
|
||||
|
||||
.calc-grid {
|
||||
display: grid; grid-template-columns: 1fr; gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@media (min-width: 620px) { .calc-grid { grid-template-columns: 1fr 1fr 1fr; gap: 0.75rem; } }
|
||||
.calc-cell {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 0.8rem 1rem;
|
||||
}
|
||||
.calc-cell.highlight { border-color: var(--border-2); }
|
||||
.calc-cell .cell-label { font-size: 0.75rem; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; font-weight: 500; }
|
||||
.calc-cell .cell-val { font-family: var(--mono); font-size: 1.6rem; font-weight: 700; color: var(--text); letter-spacing: -0.01em; margin-top: 0.15rem; }
|
||||
.calc-cell .cell-val.is-fixed { color: var(--text-2); }
|
||||
.calc-cell .cell-val.pos { color: var(--pos); }
|
||||
.calc-cell .cell-val.neg { color: var(--neg); }
|
||||
.calc-cell .cell-val.near { color: var(--warn); }
|
||||
.calc-cell .cell-sub { font-size: 0.78rem; color: var(--text-3); margin-top: 0.1rem; }
|
||||
|
||||
.scale-row { display: flex; align-items: center; gap: 0.8rem; margin-bottom: 0.5rem; flex-wrap: wrap; }
|
||||
.scale-lbl { font-size: 0.85rem; color: var(--text-2); }
|
||||
.scale-buttons { display: flex; gap: 0.3rem; flex-wrap: wrap; }
|
||||
.scale-buttons button {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
color: var(--text-2); font-size: 0.82rem; font-weight: 500;
|
||||
padding: 0.35rem 0.75rem; border-radius: 6px; cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
.scale-buttons button:hover { border-color: var(--border-2); color: var(--text); }
|
||||
.scale-buttons button.active { background: var(--text); border-color: var(--text); color: white; }
|
||||
.scale-out {
|
||||
font-size: 0.86rem; color: var(--text-2); padding: 0.6rem 0.9rem;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
margin-bottom: 0.9rem; min-height: 1.5em;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.scale-out strong { color: var(--text); font-family: var(--mono); }
|
||||
.scale-out .gain { color: var(--pos); font-weight: 600; font-family: var(--mono); }
|
||||
.scale-out .loss { color: var(--neg); font-weight: 600; font-family: var(--mono); }
|
||||
|
||||
/* Threshold bar */
|
||||
.threshold-bar {
|
||||
position: relative; height: 10px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 999px; overflow: hidden; margin-top: 0.3rem; margin-bottom: 0.4rem;
|
||||
}
|
||||
.threshold-bar .fill {
|
||||
position: absolute; left: 0; top: 0; bottom: 0;
|
||||
background: var(--accent); transition: width 0.35s ease, background 0.35s ease;
|
||||
}
|
||||
.threshold-bar .fill.arb { background: var(--pos); }
|
||||
.threshold-bar .fill.near { background: var(--warn); }
|
||||
.threshold-bar .marker-dollar {
|
||||
position: absolute; top: -3px; bottom: -3px; width: 2px;
|
||||
background: var(--text); left: 50%;
|
||||
}
|
||||
.threshold-bar-labels {
|
||||
display: flex; justify-content: space-between; font-size: 0.72rem;
|
||||
color: var(--text-3); position: relative;
|
||||
}
|
||||
.threshold-bar-labels .center {
|
||||
position: absolute; left: 50%; transform: translateX(-50%); font-weight: 600; color: var(--text-2);
|
||||
}
|
||||
|
||||
/* The outcomes list */
|
||||
.section-head { margin: 0 0 0.8rem; }
|
||||
.section-head h3 { font-size: 1rem; font-weight: 600; margin: 0 0 0.25rem; color: var(--text); }
|
||||
.section-head p { color: var(--text-3); font-size: 0.86rem; margin: 0; max-width: 640px; }
|
||||
|
||||
.outcome-list { display: flex; flex-direction: column; gap: 0.45rem; }
|
||||
.outcome-row {
|
||||
display: grid; grid-template-columns: 1fr auto; gap: 0.75rem;
|
||||
align-items: center; padding: 0.8rem 1rem;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
}
|
||||
.outcome-row.highlighted { background: var(--pos-soft); border-color: rgba(22,163,74,0.35); }
|
||||
.outcome-row.empty, .outcome-row.skeleton { opacity: 0.7; color: var(--text-3); }
|
||||
.outcome-row.skeleton { justify-content: center; text-align: center; }
|
||||
.outcome-row .left { min-width: 0; }
|
||||
.outcome-row .outcome-name {
|
||||
font-size: 0.94rem; font-weight: 500; color: var(--text);
|
||||
margin-bottom: 0.3rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.outcome-row .prob-bar {
|
||||
height: 5px; background: var(--surface-2); border-radius: 999px; overflow: hidden; width: 100%;
|
||||
}
|
||||
.outcome-row .prob-bar .prob-fill {
|
||||
height: 100%; background: var(--accent); transition: width 0.35s ease;
|
||||
}
|
||||
.outcome-row .right { display: flex; flex-direction: column; align-items: flex-end; min-width: 120px; }
|
||||
.outcome-row .pct {
|
||||
font-family: var(--mono); font-size: 1.2rem; font-weight: 700; color: var(--text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.outcome-row .price-size { font-family: var(--mono); font-size: 0.76rem; color: var(--text-3); margin-top: 2px; }
|
||||
|
||||
/* FAQ */
|
||||
.faq {
|
||||
background: var(--surface); border-top: 1px solid var(--border);
|
||||
padding: 3rem 1.5rem; margin-top: 1.5rem;
|
||||
}
|
||||
.faq-inner { max-width: 780px; margin: 0 auto; }
|
||||
.faq-inner h2 { font-size: 1.3rem; font-weight: 700; margin: 0 0 1.2rem; letter-spacing: -0.01em; }
|
||||
.faq-item {
|
||||
background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 0.25rem 1.1rem;
|
||||
margin-bottom: 0.6rem; transition: border-color 0.12s ease;
|
||||
}
|
||||
.faq-item[open] { border-color: var(--accent); background: var(--surface); }
|
||||
.faq-item summary {
|
||||
cursor: pointer; padding: 0.85rem 0; font-weight: 600; font-size: 0.98rem;
|
||||
list-style: none; position: relative; padding-left: 1.6rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.faq-item summary::-webkit-details-marker { display: none; }
|
||||
.faq-item summary::before {
|
||||
content: "+"; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||
font-size: 1.3rem; color: var(--text-3); font-weight: 400; width: 1.2rem; text-align: center;
|
||||
}
|
||||
.faq-item[open] summary::before { content: "−"; color: var(--accent); }
|
||||
.faq-item p {
|
||||
color: var(--text-2); font-size: 0.93rem; line-height: 1.6;
|
||||
margin: 0 0 0.85rem; padding-bottom: 0.2rem;
|
||||
}
|
||||
.faq-item p:last-child { margin-bottom: 0.6rem; }
|
||||
.faq-item p strong { color: var(--text); }
|
||||
|
||||
/* Live stats footer */
|
||||
.live-stats {
|
||||
background: var(--surface); border-top: 1px solid var(--border);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.live-inner {
|
||||
max-width: 1120px; margin: 0 auto;
|
||||
display: grid; gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
.stat { text-align: center; }
|
||||
.stat-num { font-family: var(--mono); font-size: 1.5rem; font-weight: 700; color: var(--text); }
|
||||
.stat-lbl { font-size: 0.75rem; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; margin-top: 0.15rem; }
|
||||
.live-caption {
|
||||
max-width: 600px; margin: 1rem auto 0; text-align: center;
|
||||
font-size: 0.82rem; color: var(--text-3);
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
text-align: center; padding: 1.5rem; font-size: 0.85rem;
|
||||
color: var(--text-3); background: var(--bg);
|
||||
}
|
||||
.site-footer a { color: var(--text-2); }
|
||||
|
||||
/* Modal */
|
||||
.modal { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 1rem; }
|
||||
.modal[hidden] { display: none; }
|
||||
.modal-backdrop { position: absolute; inset: 0; background: rgba(15, 23, 42, 0.45); backdrop-filter: blur(2px); cursor: pointer; }
|
||||
.modal-card {
|
||||
position: relative; background: var(--surface); border-radius: var(--radius-lg);
|
||||
max-width: 540px; width: 100%; padding: 1.8rem 2rem 1.6rem;
|
||||
box-shadow: 0 20px 50px rgba(15,23,42,0.25);
|
||||
max-height: 80vh; overflow-y: auto;
|
||||
}
|
||||
.modal-card h3 { margin: 0 0 0.5rem; font-size: 1.2rem; }
|
||||
.modal-card p { color: var(--text-2); font-size: 0.95rem; line-height: 1.6; }
|
||||
.modal-x {
|
||||
position: absolute; top: 0.8rem; right: 0.8rem;
|
||||
background: transparent; border: 0; font-size: 1.6rem; color: var(--text-3);
|
||||
cursor: pointer; line-height: 1; padding: 0.2rem 0.5rem; border-radius: 6px;
|
||||
}
|
||||
.modal-x:hover { background: var(--surface-2); color: var(--text); }
|
||||
Reference in New Issue
Block a user