standalone forward-test page (same file as the Space; open locally if the Space is down)
Browse files- forwardtest/index.html +231 -0
forwardtest/index.html
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>Crypto Trend Forward Test</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root { color-scheme: dark; }
|
| 9 |
+
* { box-sizing: border-box; }
|
| 10 |
+
body { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; background:#0d1117; color:#e6edf3; margin:0; padding:24px; max-width:1100px; margin-inline:auto; }
|
| 11 |
+
h1 { font-size:1.4rem; margin:0 0 4px; }
|
| 12 |
+
.sub { color:#8b949e; font-size:.85rem; margin-bottom:20px; }
|
| 13 |
+
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:14px; margin-bottom:20px; }
|
| 14 |
+
.card { background:#161b22; border:1px solid #30363d; border-radius:10px; padding:16px; }
|
| 15 |
+
.card h2 { font-size:.95rem; margin:0 0 10px; color:#8b949e; font-weight:600; }
|
| 16 |
+
.signal { font-size:1.7rem; font-weight:700; }
|
| 17 |
+
.long { color:#3fb950; } .flat { color:#8b949e; }
|
| 18 |
+
.meta { font-size:.8rem; color:#8b949e; margin-top:8px; line-height:1.5; }
|
| 19 |
+
.stat { display:flex; justify-content:space-between; font-size:.85rem; padding:3px 0; }
|
| 20 |
+
.stat b { font-weight:600; }
|
| 21 |
+
table { width:100%; border-collapse:collapse; font-size:.78rem; }
|
| 22 |
+
th,td { text-align:right; padding:4px 8px; border-bottom:1px solid #21262d; }
|
| 23 |
+
th:first-child, td:first-child { text-align:left; }
|
| 24 |
+
th { color:#8b949e; font-weight:600; }
|
| 25 |
+
canvas { width:100%; height:280px; }
|
| 26 |
+
.badge { display:inline-block; background:#1f6feb33; color:#58a6ff; border:1px solid #1f6feb; border-radius:20px; padding:2px 10px; font-size:.72rem; margin-left:8px; }
|
| 27 |
+
.warn { background:#bb800933; border-color:#bb8009; color:#d29922; }
|
| 28 |
+
#status { font-size:.8rem; color:#8b949e; margin:10px 0; }
|
| 29 |
+
button { background:#21262d; color:#e6edf3; border:1px solid #30363d; border-radius:6px; padding:6px 14px; cursor:pointer; font-size:.85rem; }
|
| 30 |
+
button:hover { background:#30363d; }
|
| 31 |
+
.footer { margin-top:24px; font-size:.75rem; color:#8b949e; line-height:1.6; }
|
| 32 |
+
</style>
|
| 33 |
+
</head>
|
| 34 |
+
<body>
|
| 35 |
+
<h1>Crypto Trend Forward Test <span class="badge" id="days-badge">day 0</span></h1>
|
| 36 |
+
<div class="sub">Live forward test of the eval-verified Donchian long-only trend strategies · inception 2026-10-25 · data: Binance daily klines (public REST)</div>
|
| 37 |
+
<div id="status">loading prices...</div>
|
| 38 |
+
<button onclick="run(true)">Refresh</button>
|
| 39 |
+
|
| 40 |
+
<div class="cards" style="margin-top:16px">
|
| 41 |
+
<div class="card">
|
| 42 |
+
<h2>BTC — Donchian 200d long-only <span class="badge" style="background:#23863633;border-color:#238636;color:#3fb950">era-stable pick</span></h2>
|
| 43 |
+
<div class="signal" id="sig-btc">—</div>
|
| 44 |
+
<div class="meta" id="meta-btc"></div>
|
| 45 |
+
<div id="stats-btc"></div>
|
| 46 |
+
</div>
|
| 47 |
+
<div class="card">
|
| 48 |
+
<h2>ETH — Donchian 20d long-only <span class="badge" style="background:#bb800933;border-color:#bb8009;color:#d29922">highest backtest SR, decaying edge</span></h2>
|
| 49 |
+
<div class="signal" id="sig-eth">—</div>
|
| 50 |
+
<div class="meta" id="meta-eth"></div>
|
| 51 |
+
<div id="stats-eth"></div>
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div class="card" style="margin-bottom:20px">
|
| 56 |
+
<h2>Forward-test equity (start = 1.0 at inception) — strategy vs buy & hold</h2>
|
| 57 |
+
<canvas id="chart"></canvas>
|
| 58 |
+
<div class="meta">Fees 5 bps taker + sqrt-impact slippage (floor 2 bps) + 11%/yr funding drag on held longs, applied daily, same as the backtest harness.</div>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<div class="card">
|
| 62 |
+
<h2>Daily log (inception → now)</h2>
|
| 63 |
+
<div style="max-height:320px;overflow:auto">
|
| 64 |
+
<table id="log"><thead><tr>
|
| 65 |
+
<th>date</th><th>BTC close</th><th>BTC pos</th><th>ETH close</th><th>ETH pos</th>
|
| 66 |
+
</tr></thead><tbody></tbody></table>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<div class="footer">
|
| 71 |
+
Method: position decided at each daily close using data through that close (20d/200d close-channel breakout, long-only, held until opposite channel break) — identical to the audited harness in
|
| 72 |
+
<a href="https://huggingface.co/Avi-L/crypto-quant-eval" style="color:#58a6ff">Avi-L/crypto-quant-eval</a>.
|
| 73 |
+
The forward test is stateless: every load recomputes positions from live Binance klines since inception, so the record is exactly reproducible.
|
| 74 |
+
Backtest evidence: BTC Donchian 200d long-only OOS SR ~0.9, era-stable; ETH Donchian 20d full-sample SR 1.56 with a decaying recent edge (0.5).
|
| 75 |
+
<b>Max drawdowns of −60/−80% are inherent to these strategies. This is research output, not trading advice.</b>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<script>
|
| 79 |
+
const INCEPTION = "2026-10-25";
|
| 80 |
+
const FEE = 0.0005, IMPACT_K = 0.5, FUND_APR = 0.1095, SLIP_FLOOR = 0.0002;
|
| 81 |
+
const SIGMA_DAILY = { BTC: 0.0317, ETH: 0.0450 }; // realized daily vol, from the eval dataset
|
| 82 |
+
|
| 83 |
+
async function klines(symbol) {
|
| 84 |
+
const url = `https://api.binance.com/api/v3/klines?symbol=${symbol}&interval=1d&limit=400`;
|
| 85 |
+
const r = await fetch(url);
|
| 86 |
+
if (!r.ok) throw new Error(symbol + " fetch failed: " + r.status);
|
| 87 |
+
return (await r.json()).map(k => ({
|
| 88 |
+
t: new Date(k[0]).toISOString().slice(0,10),
|
| 89 |
+
close: parseFloat(k[4]),
|
| 90 |
+
}));
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function positions(closes, N) {
|
| 94 |
+
// close-channel breakout, long-only: decision at t uses closes through t
|
| 95 |
+
// (channel = rolling max/min of closes through t-1); pos HELD over day t+1
|
| 96 |
+
const pos = new Array(closes.length).fill(0);
|
| 97 |
+
let cur = 0;
|
| 98 |
+
for (let i = 0; i < closes.length; i++) {
|
| 99 |
+
if (i >= N) {
|
| 100 |
+
let hi = -Infinity, lo = Infinity;
|
| 101 |
+
for (let j = i - N; j < i; j++) { hi = Math.max(hi, closes[j]); lo = Math.min(lo, closes[j]); }
|
| 102 |
+
if (closes[i] > hi) cur = 1;
|
| 103 |
+
else if (closes[i] < lo) cur = 0;
|
| 104 |
+
}
|
| 105 |
+
pos[i] = cur;
|
| 106 |
+
}
|
| 107 |
+
return pos;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
function forwardReturns(closes, pos, sigma, adv) {
|
| 111 |
+
// equity: position decided at close t earns close t -> close t+1, net of costs
|
| 112 |
+
const rets = [];
|
| 113 |
+
for (let i = 1; i < closes.length; i++) {
|
| 114 |
+
const turn = Math.abs(pos[i - 1] - (i >= 2 ? pos[i - 2] : 0));
|
| 115 |
+
const notional = turn; // per unit of equity
|
| 116 |
+
const bps = Math.max(SLIP_FLOOR, IMPACT_K * sigma * Math.sqrt(Math.max(notional, 1e-9) / adv));
|
| 117 |
+
const slip = turn * bps;
|
| 118 |
+
const fee = turn * FEE;
|
| 119 |
+
const fund = pos[i - 1] * FUND_APR / 365;
|
| 120 |
+
rets.push(pos[i - 1] * (closes[i] / closes[i - 1] - 1) - fee - slip - fund);
|
| 121 |
+
}
|
| 122 |
+
return rets;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
function bhReturns(closes) {
|
| 126 |
+
const r = [];
|
| 127 |
+
for (let i = 1; i < closes.length; i++) r.push(closes[i] / closes[i - 1] - 1);
|
| 128 |
+
return r;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
function stats(rets) {
|
| 132 |
+
if (!rets.length) return null;
|
| 133 |
+
const mu = rets.reduce((a, b) => a + b, 0) / rets.length;
|
| 134 |
+
const sd = Math.sqrt(rets.reduce((a, b) => a + (b - mu) ** 2, 0) / (rets.length - 1));
|
| 135 |
+
let eq = 1, peak = 1, mdd = 0;
|
| 136 |
+
const curve = rets.map(r => { eq *= 1 + r; peak = Math.max(peak, eq); mdd = Math.min(mdd, eq / peak - 1); return eq; });
|
| 137 |
+
return { sr: mu / sd * Math.sqrt(365), mdd, total: eq, curve, n: rets.length };
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
function fmt(n, d = 2) { return n.toFixed(d); }
|
| 141 |
+
|
| 142 |
+
function draw(canvasId, series) {
|
| 143 |
+
const c = document.getElementById(canvasId);
|
| 144 |
+
const ctx = c.getContext("2d");
|
| 145 |
+
const W = c.width = c.clientWidth * 2, H = c.height = 560;
|
| 146 |
+
ctx.scale(1, 1);
|
| 147 |
+
ctx.clearRect(0, 0, W, H);
|
| 148 |
+
const all = series.flatMap(s => s.data);
|
| 149 |
+
const maxV = Math.max(...all, 1.05), minV = Math.min(...all, 0.95);
|
| 150 |
+
const pad = 40, x0 = pad, y0 = 10, y1 = H - 30, x1 = W - 10;
|
| 151 |
+
const X = i => x0 + (x1 - x0) * i / (maxLen - 1);
|
| 152 |
+
const Y = v => y1 - (y1 - y0) * (v - minV) / (maxV - minV);
|
| 153 |
+
ctx.strokeStyle = "#30363d"; ctx.beginPath(); ctx.moveTo(x0, Y(1)); ctx.lineTo(x1, Y(1)); ctx.stroke();
|
| 154 |
+
ctx.fillStyle = "#8b949e"; ctx.font = "18px sans-serif"; ctx.fillText("1.0", 2, Y(1) - 4);
|
| 155 |
+
for (const s of series) {
|
| 156 |
+
ctx.strokeStyle = s.color; ctx.lineWidth = 2.5; ctx.beginPath();
|
| 157 |
+
s.data.forEach((v, i) => i ? ctx.lineTo(X(i), Y(v)) : ctx.moveTo(X(i), Y(v)));
|
| 158 |
+
ctx.stroke();
|
| 159 |
+
}
|
| 160 |
+
ctx.fillStyle = "#8b949e"; ctx.font = "20px sans-serif";
|
| 161 |
+
let x = x0;
|
| 162 |
+
for (const s of series) { ctx.fillStyle = s.color; ctx.fillText(s.label, x, H - 6); x += ctx.measureText(s.label).width + 30; }
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
let maxLen = 1;
|
| 166 |
+
|
| 167 |
+
async function run(manual) {
|
| 168 |
+
const status = document.getElementById("status");
|
| 169 |
+
try {
|
| 170 |
+
status.textContent = "fetching Binance daily klines...";
|
| 171 |
+
const [btc, eth] = await Promise.all([klines("BTCUSDT"), klines("ETHUSDT")]);
|
| 172 |
+
const start = btc.findIndex(k => k.t >= INCEPTION);
|
| 173 |
+
if (start < 0) throw new Error("no data since inception");
|
| 174 |
+
const days = btc.length - start;
|
| 175 |
+
document.getElementById("days-badge").textContent = `day ${days - 1} of forward test`;
|
| 176 |
+
|
| 177 |
+
const out = {};
|
| 178 |
+
for (const [sym, raw, N] of [["BTC", btc, 200], ["ETH", eth, 20]]) {
|
| 179 |
+
const closes = raw.slice(start).map(k => k.close);
|
| 180 |
+
const pos = positions(closes, N);
|
| 181 |
+
const rets = forwardReturns(closes, pos, SIGMA_DAILY[sym], sym === "BTC" ? 20e9 : 10e9);
|
| 182 |
+
const st = stats(rets);
|
| 183 |
+
const bh = stats(bhReturns(closes));
|
| 184 |
+
out[sym] = { closes, pos, st, bh, last: raw[raw.length - 1] };
|
| 185 |
+
const sigEl = document.getElementById("sig-" + sym.toLowerCase());
|
| 186 |
+
const held = pos[pos.length - 2]; // position held today (decided yesterday)
|
| 187 |
+
const next = pos[pos.length - 1]; // position for tomorrow (decided today)
|
| 188 |
+
sigEl.innerHTML = held ? '<span class="long">LONG</span>' : '<span class="flat">FLAT</span>';
|
| 189 |
+
sigEl.innerHTML += held !== next ? ` → ${next ? '<span class="long">LONG at close</span>' : '<span class="flat">EXIT at close</span>'}` : "";
|
| 190 |
+
const chan = closes.slice(-N);
|
| 191 |
+
document.getElementById("meta-" + sym.toLowerCase()).innerHTML =
|
| 192 |
+
`last close ${fmt(out[sym].last.close, sym === "BTC" ? 0 : 1)} · ` +
|
| 193 |
+
`${N}d channel [${fmt(Math.min(...chan), sym === "BTC" ? 0 : 1)} – ${fmt(Math.max(...chan), sym === "BTC" ? 0 : 1)}] · ` +
|
| 194 |
+
`position for tomorrow: <b>${next ? "LONG" : "FLAT"}</b>`;
|
| 195 |
+
document.getElementById("stats-" + sym.toLowerCase()).innerHTML =
|
| 196 |
+
`<div class="stat"><span>Forward days</span><b>${st.n}</b></div>` +
|
| 197 |
+
`<div class="stat"><span>Equity (net)</span><b>${fmt(st.total, 3)}</b></div>` +
|
| 198 |
+
`<div class="stat"><span>Forward SR (ann.)</span><b>${fmt(st.sr)}</b></div>` +
|
| 199 |
+
`<div class="stat"><span>Max drawdown</span><b>${fmt(st.mdd * 100, 1)}%</b></div>` +
|
| 200 |
+
`<div class="stat"><span>Buy & hold equity</span><b>${fmt(bh.total, 3)}</b></div>` +
|
| 201 |
+
`<div class="stat"><span>B&H max drawdown</span><b>${fmt(bh.mdd * 100, 1)}%</b></div>`;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
maxLen = Math.max(out.BTC.st.curve.length, out.ETH.st.curve.length);
|
| 205 |
+
draw("chart", [
|
| 206 |
+
{ label: "BTC Donchian200 (net)", color: "#3fb950", data: out.BTC.st.curve },
|
| 207 |
+
{ label: "BTC buy&hold", color: "#58a6ff", data: out.BTC.bh.curve },
|
| 208 |
+
{ label: "ETH Donchian20 (net)", color: "#d29922", data: out.ETH.st.curve },
|
| 209 |
+
{ label: "ETH buy&hold", color: "#8b949e", data: out.ETH.bh.curve },
|
| 210 |
+
]);
|
| 211 |
+
|
| 212 |
+
const tb = document.querySelector("#log tbody");
|
| 213 |
+
tb.innerHTML = "";
|
| 214 |
+
for (let i = out.BTC.st.n; i >= Math.max(0, out.BTC.st.n - 30); i--) {
|
| 215 |
+
const r = document.createElement("tr");
|
| 216 |
+
r.innerHTML = `<td>${btc[start + i].t}</td><td>${fmt(btc[start + i].close, 0)}</td>` +
|
| 217 |
+
`<td>${out.BTC.pos[i] ? "LONG" : "flat"}</td>` +
|
| 218 |
+
`<td>${fmt(eth[start + i].close, 1)}</td><td>${out.ETH.pos[i] ? "LONG" : "flat"}</td>`;
|
| 219 |
+
tb.appendChild(r);
|
| 220 |
+
}
|
| 221 |
+
status.textContent = `updated ${new Date().toISOString().slice(0, 16).replace("T", " ")} UTC · ` +
|
| 222 |
+
`${days - 1} forward days since inception (${INCEPTION})`;
|
| 223 |
+
} catch (e) {
|
| 224 |
+
status.textContent = "error: " + e.message + " — if Binance is unreachable from your network, try a refresh or VPN";
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
run(false);
|
| 228 |
+
setInterval(() => run(false), 15 * 60 * 1000); // auto-refresh every 15 min
|
| 229 |
+
</script>
|
| 230 |
+
</body>
|
| 231 |
+
</html>
|