first commit
deploy GitHub Pages / deploy (push) Has been cancelled
tests / test (push) Has been cancelled

This commit is contained in:
2026-07-22 18:53:44 +08:00
commit 9259325d8a
60 changed files with 11081 additions and 0 deletions
+484
View File
@@ -0,0 +1,484 @@
/*
* 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]));
}
+333
View File
@@ -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); }
+675
View File
@@ -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 &ldquo;vig&rdquo; — 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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
+396
View File
@@ -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); }