Files
polymarket_arbitrage/docs/assets/lab.js
T
gavindiaz 9259325d8a
deploy GitHub Pages / deploy (push) Has been cancelled
tests / test (push) Has been cancelled
first commit
2026-07-22 18:53:44 +08:00

676 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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]));
}