Files
polymarket_arbitrage/docs/assets/lab.js
T

676 lines
28 KiB
JavaScript
Raw Normal View History

2026-07-22 18:53:44 +08:00
/*
* 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]));
}