vin268 commited on
Commit
a4f42e8
·
verified ·
1 Parent(s): 516362b

site update

Browse files
Files changed (2) hide show
  1. README.md +7 -5
  2. index.html +361 -17
README.md CHANGED
@@ -1,10 +1,12 @@
1
  ---
2
- title: Cascade Lab
3
- emoji: 🏆
4
- colorFrom: purple
5
- colorTo: yellow
6
  sdk: static
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
1
  ---
2
+ title: Cascade Research Lab
3
+ colorFrom: blue
4
+ colorTo: gray
 
5
  sdk: static
6
  pinned: false
7
+ short_description: Live crypto liquidations and an open lab notebook
8
  ---
9
 
10
+ Static website. It reads the public ledger dataset
11
+ (`latest.json`, `journey/lab.json`) and shows the live recorder, the studies
12
+ and the lab notebook. All data comes from the dataset; this page holds none.
index.html CHANGED
@@ -1,19 +1,363 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
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, viewport-fit=cover">
6
+ <title>Cascade Research Lab</title>
7
+ <meta name="description" content="A live crypto liquidation recorder and an open research notebook on liquidation cascades.">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans+Condensed:wght@500;600&family=IBM+Plex+Sans:wght@400;500&display=swap">
11
+ <style>
12
+ /* One reading column: live recorder first (what is running now), then the
13
+ studies and their gates, then the dated notebook. */
14
+ :root {
15
+ --paper: #f6f7f9; --panel: #ffffff; --ink: #141820; --ink-2: #4f5866; --rule: #dde1e8;
16
+ --accent: #2147c4; --accent-soft: #e6ecfb;
17
+ --long: #c2412d; --short: #14806b; --ok: #18684a; --warn: #8a5300;
18
+ --k-pre: #2147c4; --k-pre-bg: #e6ecfb; --k-caught: #8a5300; --k-caught-bg: #fbefd9;
19
+ --k-null: #5b3fa6; --k-null-bg: #efe9fb; --k-find: #18684a; --k-find-bg: #e2f3ea;
20
+ --k-plain: #4f5866; --k-plain-bg: #eceef2;
21
+ --display: "IBM Plex Sans Condensed", "Arial Narrow", system-ui, sans-serif;
22
+ --body: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
23
+ --mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Consolas, monospace;
24
+ color-scheme: light;
25
+ }
26
+ @media (prefers-color-scheme: dark) {
27
+ :root {
28
+ --paper: #0f1217; --panel: #161b22; --ink: #e6e9ee; --ink-2: #9ba4b1; --rule: #2a313b;
29
+ --accent: #8aa6ff; --accent-soft: #1d2840; --long: #f08a70; --short: #5fd1b5; --ok: #6fd1a2; --warn: #f0b75c;
30
+ --k-pre: #8aa6ff; --k-pre-bg: #1d2840; --k-caught: #f0b75c; --k-caught-bg: #3a2c12;
31
+ --k-null: #b9a4f5; --k-null-bg: #2a2340; --k-find: #6fd1a2; --k-find-bg: #15302a;
32
+ --k-plain: #9ba4b1; --k-plain-bg: #222831;
33
+ color-scheme: dark;
34
+ }
35
+ }
36
+ * { box-sizing: border-box; }
37
+ body { margin: 0; background: var(--paper); color: var(--ink); font: 400 16px/1.6 var(--body); }
38
+ .page { max-width: 56rem; margin: 0 auto; padding-block: 48px 64px; padding-left: max(16px, env(safe-area-inset-left, 0px)); padding-right: max(16px, env(safe-area-inset-right, 0px)); display: grid; gap: 56px; }
39
+ .mono { font-family: var(--mono); font-size: 13px; }
40
+ h1, h2, h3 { text-wrap: balance; margin: 0; }
41
+ h2 { font: 600 24px/1.2 var(--display); letter-spacing: .01em; }
42
+ h3 { font: 500 17px/1.35 var(--body); }
43
+ p { margin: 0; }
44
+ a { color: var(--accent); text-underline-offset: 3px; }
45
+ a:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
46
+ .note { color: var(--ink-2); max-width: 64ch; }
47
+ .section-head { display: grid; gap: 8px; margin-bottom: 20px; }
48
+
49
+ .hero { display: grid; gap: 16px; }
50
+ .eyebrow { color: var(--ink-2); text-transform: uppercase; letter-spacing: .08em; font-size: 12px; }
51
+ .hero h1 { font: 600 clamp(30px, 5.2vw, 46px)/1.08 var(--display); max-width: 22ch; }
52
+ .summary { font-size: 18px; color: var(--ink-2); max-width: 60ch; }
53
+
54
+ /* live panel */
55
+ .live { background: var(--panel); border: 1px solid var(--rule); border-radius: 6px; padding: 24px; display: grid; gap: 20px; }
56
+ .live-head { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; justify-content: space-between; }
57
+ .pill { display: inline-flex; align-items: center; gap: 8px; font: 500 13px/1 var(--mono); padding: 6px 10px; border-radius: 999px; border: 1px solid var(--rule); }
58
+ .pill i { width: 8px; height: 8px; border-radius: 50%; background: var(--ink-2); }
59
+ .pill.on i { background: var(--ok); }
60
+ .pill.stale i { background: var(--warn); }
61
+ .figs { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 9.5rem), 1fr)); gap: 16px; }
62
+ .fig { display: grid; gap: 2px; align-content: start; }
63
+ .fig .lab { font-size: 13px; color: var(--ink-2); }
64
+ .fig .val { font: 500 26px/1.15 var(--display); font-variant-numeric: tabular-nums; }
65
+ .fig .sub { font: 400 12px/1.3 var(--mono); color: var(--ink-2); }
66
+ .val.long { color: var(--long); } .val.short { color: var(--short); }
67
+ .chart { position: relative; min-height: 220px; }
68
+ .chart svg { display: block; width: 100%; height: auto; }
69
+ .legend { display: flex; flex-wrap: wrap; gap: 8px 20px; font-size: 13px; color: var(--ink-2); }
70
+ .sw { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; vertical-align: -1px; }
71
+ .tip { position: absolute; pointer-events: none; background: var(--panel); border: 1px solid var(--rule); border-radius: 4px; padding: 6px 10px; font-size: 12px; white-space: nowrap; box-shadow: 0 2px 8px rgba(0,0,0,.12); }
72
+ .two { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); gap: 24px; }
73
+ table { width: 100%; border-collapse: collapse; font-size: 14px; font-variant-numeric: tabular-nums; }
74
+ th, td { padding: 6px 8px; text-align: right; border-bottom: 1px solid var(--rule); white-space: nowrap; }
75
+ th { color: var(--ink-2); font-weight: 500; font-size: 13px; }
76
+ th:first-child, td:first-child { text-align: left; }
77
+ .scroll { overflow-x: auto; }
78
+ .caveat { font-size: 13px; color: var(--ink-2); }
79
+ .empty { color: var(--ink-2); font-size: 14px; }
80
+
81
+ /* studies */
82
+ .studies { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
83
+ .study { background: var(--panel); border: 1px solid var(--rule); border-radius: 6px; padding: 20px; display: grid; grid-template-columns: 3.25rem minmax(0, 1fr); gap: 4px 16px; }
84
+ .study.is-planned { background: transparent; }
85
+ .study-id { font: 500 22px/1.2 var(--mono); color: var(--ink-2); }
86
+ .study.is-active .study-id { color: var(--accent); }
87
+ .study-main { display: grid; gap: 12px; min-width: 0; }
88
+ .study .q { color: var(--ink-2); max-width: 66ch; }
89
+ .track { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 4px; }
90
+ .seg { display: grid; gap: 6px; }
91
+ .seg i { display: block; height: 4px; border-radius: 2px; background: var(--rule); }
92
+ .seg.done i { background: var(--accent); }
93
+ .seg.next i { background: transparent; box-shadow: inset 0 0 0 1px var(--accent); }
94
+ .seg span { font: 400 11px/1.3 var(--mono); color: var(--ink-2); overflow-wrap: anywhere; }
95
+ .seg.done span, .seg.next span { color: var(--ink); }
96
+ @media (max-width: 34rem) { .seg span { display: none; } .seg.next span { display: block; } }
97
+ .headline { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; }
98
+ .headline li { padding-left: 16px; position: relative; }
99
+ .headline li::before { content: ""; position: absolute; left: 0; top: .62em; width: 7px; height: 7px; border-radius: 50%; background: var(--k-find); }
100
+ .study-foot { display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: baseline; }
101
+ .status { color: var(--ink-2); font-size: 14px; }
102
+ .study-links { display: flex; flex-wrap: wrap; gap: 8px 16px; font-size: 14px; }
103
+
104
+ /* notebook */
105
+ .filters { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
106
+ .chip { font: 400 13px/1 var(--mono); padding: 7px 12px; border-radius: 999px; border: 1px solid var(--rule); background: transparent; color: var(--ink); cursor: pointer; }
107
+ .chip[aria-pressed="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
108
+ .log { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--rule); }
109
+ .entry { display: grid; grid-template-columns: 11rem minmax(0, 1fr); gap: 4px 24px; padding-block: 18px; border-bottom: 1px solid var(--rule); }
110
+ .entry time { color: var(--ink-2); padding-top: 2px; }
111
+ .entry-main { display: grid; gap: 6px; min-width: 0; }
112
+ .meta { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; }
113
+ .kind { font: 500 12px/1 var(--mono); padding: 5px 8px; border-radius: 4px; color: var(--k-plain); background: var(--k-plain-bg); }
114
+ .kind[data-k="Pre-registration"] { color: var(--k-pre); background: var(--k-pre-bg); }
115
+ .kind[data-k="Caught"] { color: var(--k-caught); background: var(--k-caught-bg); }
116
+ .kind[data-k="Null result"] { color: var(--k-null); background: var(--k-null-bg); }
117
+ .kind[data-k="Finding"] { color: var(--k-find); background: var(--k-find-bg); }
118
+ .meta .mono { color: var(--ink-2); }
119
+ .entry p { color: var(--ink-2); max-width: 66ch; }
120
+ @media (max-width: 40rem) { .entry { grid-template-columns: minmax(0, 1fr); } }
121
+ .foot { border-top: 1px solid var(--rule); padding-top: 16px; color: var(--ink-2); font-size: 14px; display: grid; gap: 4px; }
122
+ </style>
123
+ </head>
124
+ <body>
125
+ <main class="page">
126
+ <header class="hero">
127
+ <p class="eyebrow mono">Open research notebook</p>
128
+ <h1 id="focus">Liquidation cascades in crypto</h1>
129
+ <p class="summary" id="summary"></p>
130
+ </header>
131
+
132
+ <section id="live" aria-labelledby="live-h">
133
+ <div class="section-head">
134
+ <h2 id="live-h">Live: liquidation recorder</h2>
135
+ <p class="note">Every liquidation order Binance pushes on its USD-M perpetual futures, all symbols, plus open interest for five large coins every minute. Aggregated each hour and published to a public dataset.</p>
136
+ </div>
137
+ <div class="live">
138
+ <div class="live-head">
139
+ <span class="pill" id="pill"><i></i><span id="pill-text">Loading</span></span>
140
+ <span class="mono" id="updated"></span>
141
+ </div>
142
+ <div class="figs">
143
+ <div class="fig"><span class="lab">Liquidations, last 24 h</span><span class="val" id="f-n">—</span><span class="sub" id="f-n-sub"></span></div>
144
+ <div class="fig"><span class="lab">Longs liquidated</span><span class="val long" id="f-long">—</span><span class="sub">sell orders</span></div>
145
+ <div class="fig"><span class="lab">Shorts liquidated</span><span class="val short" id="f-short">—</span><span class="sub">buy orders</span></div>
146
+ <div class="fig"><span class="lab">Largest single</span><span class="val" id="f-big">—</span><span class="sub" id="f-big-sub"></span></div>
147
+ <div class="fig"><span class="lab">Recorder coverage, 24 h</span><span class="val" id="f-cov">—</span><span class="sub" id="f-cov-sub"></span></div>
148
+ </div>
149
+ <div>
150
+ <div class="chart" id="chart"></div>
151
+ <div class="legend"><span><span class="sw" style="background: var(--long)"></span>Longs liquidated (USD, up)</span><span><span class="sw" style="background: var(--short)"></span>Shorts liquidated (USD, down)</span><span>Faded bars: the recorder was connected for less than the full hour</span></div>
152
+ </div>
153
+ <div class="two">
154
+ <div>
155
+ <h3>Most liquidated, last 24 h</h3>
156
+ <div class="scroll"><table><thead><tr><th>Symbol</th><th>Orders</th><th>USD</th><th>Longs</th></tr></thead><tbody id="top"></tbody></table></div>
157
+ </div>
158
+ <div>
159
+ <h3>Open interest, change over 24 h</h3>
160
+ <div class="scroll"><table><thead><tr><th>Coin</th><th>Change</th></tr></thead><tbody id="oi"></tbody></table></div>
161
+ </div>
162
+ </div>
163
+ <p class="caveat">Binance pushes at most one liquidation per symbol per second, so counts and USD values are lower bounds when liquidations cluster. Research only; nothing here is investment advice.</p>
164
+ </div>
165
+ </section>
166
+
167
+ <section id="studies" aria-labelledby="studies-h">
168
+ <div class="section-head">
169
+ <h2 id="studies-h">Studies</h2>
170
+ <p class="note">Six studies, worked through in order, from what happens in a cascade to a paper-trading forward test. Each must pass its gate before the next step; failures are published too.</p>
171
+ </div>
172
+ <ol class="studies" id="study-list"><li class="empty">Loading the studies…</li></ol>
173
+ </section>
174
+
175
+ <section id="notebook" aria-labelledby="notebook-h">
176
+ <div class="section-head">
177
+ <h2 id="notebook-h">Lab notebook</h2>
178
+ <p class="note">Dated entries as the work happens, including the bugs caught and the tests that failed.</p>
179
+ </div>
180
+ <div class="filters" id="filters" role="group" aria-label="Filter by study"></div>
181
+ <ol class="log" id="log"></ol>
182
+ </section>
183
+
184
+ <footer class="foot">
185
+ <span>Every number on this page is read from the public ledger, <a id="ledger-link" href="#" target="_blank" rel="noopener">a Hugging Face dataset</a>. Each hourly update is a commit, so its history is the project's timestamped record.</span>
186
+ <span>Data: Binance USD-M futures public market data. Charts and code are the project's own.</span>
187
+ </footer>
188
+ </main>
189
+
190
+ <script>
191
+ (() => {
192
+ const DATASET = 'vin268/cascade-lab-ledger';
193
+ const onHub = /(\.hf\.space|huggingface\.co)$/.test(location.hostname);
194
+ const BASE = onHub ? `https://huggingface.co/datasets/${DATASET}/resolve/main` : './data';
195
+ document.getElementById('ledger-link').href = `https://huggingface.co/datasets/${DATASET}`;
196
+
197
+ const $ = (id) => document.getElementById(id);
198
+ const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
199
+ const link = (href, text) => { const a = el('a', null, text); a.href = href; a.target = '_blank'; a.rel = 'noopener'; return a; };
200
+ const usd = (v) => v >= 1e6 ? '$' + (v / 1e6).toFixed(2) + 'm' : v >= 1e3 ? '$' + (v / 1e3).toFixed(1) + 'k' : '$' + Math.round(v);
201
+ const pct = (v) => (v > 0 ? '+' : v < 0 ? '−' : '') + Math.abs(v).toFixed(2) + '%';
202
+ const fmtDay = (iso) => new Date(iso.length === 10 ? iso + 'T00:00:00Z' : iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
203
+ const fmtTime = (iso) => new Date(iso).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' });
204
+ const fetchJson = (path) => fetch(`${BASE}/${path}?t=${Date.now()}`).then((r) => { if (!r.ok) throw new Error(r.status); return r.json(); });
205
+
206
+ // ---------- live panel ----------
207
+ function renderLive(d) {
208
+ const ageMin = (Date.now() - Date.parse(d.updated)) / 60000;
209
+ const stale = ageMin > 120 || (d.recorder && d.recorder.connected === false);
210
+ $('pill').className = 'pill ' + (stale ? 'stale' : 'on');
211
+ $('pill-text').textContent = stale ? 'Not updating' : 'Recording';
212
+ $('updated').textContent = `Updated ${fmtDay(d.updated)} ${fmtTime(d.updated)} UTC` + (d.recording_since ? ` · recording since ${fmtDay(d.recording_since)}` : '');
213
+ $('f-n').textContent = d.n_liquidations.toLocaleString('en-GB');
214
+ $('f-n-sub').textContent = `${d.n_symbols} symbols`;
215
+ $('f-long').textContent = usd(d.usd_long_liquidated);
216
+ $('f-short').textContent = usd(d.usd_short_liquidated);
217
+ if (d.largest) {
218
+ $('f-big').textContent = usd(d.largest.usd);
219
+ $('f-big-sub').textContent = `${d.largest.symbol} ${d.largest.side === 'SELL' ? 'long' : 'short'} · ${fmtTime(d.largest.time)} UTC`;
220
+ }
221
+ $('f-cov').textContent = (100 * d.coverage_24h).toFixed(1) + '%';
222
+ $('f-cov-sub').textContent = 'of the last 24 h connected';
223
+
224
+ const top = $('top'); top.replaceChildren();
225
+ if (!d.top_symbols.length) { const tr = el('tr'); const td = el('td', 'empty', 'No liquidations yet'); td.colSpan = 4; tr.appendChild(td); top.appendChild(tr); }
226
+ d.top_symbols.forEach((s) => {
227
+ const tr = el('tr');
228
+ [s.symbol, s.n.toLocaleString('en-GB'), usd(s.usd), s.long_share == null ? '—' : Math.round(100 * s.long_share) + '%'].forEach((v) => tr.appendChild(el('td', null, v)));
229
+ top.appendChild(tr);
230
+ });
231
+ const oi = $('oi'); oi.replaceChildren();
232
+ const entries = Object.entries(d.oi_change_24h_pct || {});
233
+ if (!entries.length) { const tr = el('tr'); const td = el('td', 'empty', 'Not enough polls yet'); td.colSpan = 2; tr.appendChild(td); oi.appendChild(tr); }
234
+ entries.forEach(([c, v]) => { const tr = el('tr'); tr.appendChild(el('td', null, c)); tr.appendChild(el('td', null, pct(v))); oi.appendChild(tr); });
235
+ drawChart(d.hourly_7d || []);
236
+ }
237
+
238
+ function drawChart(rows) {
239
+ const box = $('chart'); box.replaceChildren();
240
+ if (!rows.length) { box.appendChild(el('p', 'empty', 'The hourly chart appears after the first full hour of recording.')); return; }
241
+ const W = Math.max(320, box.clientWidth), H = 220, M = { t: 10, r: 8, b: 24, l: 64 };
242
+ const max = Math.max(1, ...rows.map((r) => Math.max(r.usd_long_liquidated, r.usd_short_liquidated)));
243
+ const mid = M.t + (H - M.t - M.b) / 2, half = (H - M.t - M.b) / 2;
244
+ // fixed 7-day axis, one slot per hour, ending at the latest hour
245
+ const SLOTS = 168, HOUR = 3600e3;
246
+ const endMs = Date.parse(rows[rows.length - 1].hour);
247
+ const startMs = endMs - (SLOTS - 1) * HOUR;
248
+ const bw = (W - M.l - M.r) / SLOTS;
249
+ const slot = (iso) => Math.round((Date.parse(iso) - startMs) / HOUR);
250
+ rows = rows.filter((r) => slot(r.hour) >= 0);
251
+ const y = (v) => (v / max) * half;
252
+ const ns = 'http://www.w3.org/2000/svg';
253
+ const svg = document.createElementNS(ns, 'svg');
254
+ svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
255
+ svg.setAttribute('role', 'img');
256
+ svg.setAttribute('aria-label', 'Hourly USD of long and short liquidations');
257
+ const add = (tag, attrs, text) => { const e = document.createElementNS(ns, tag); Object.entries(attrs).forEach(([k, v]) => e.setAttribute(k, v)); if (text != null) e.textContent = text; svg.appendChild(e); return e; };
258
+ const ink2 = getComputedStyle(document.body).getPropertyValue('--ink-2');
259
+ const rule = getComputedStyle(document.body).getPropertyValue('--rule');
260
+ add('line', { x1: M.l, x2: W - M.r, y1: mid, y2: mid, stroke: rule });
261
+ [['↑ ' + usd(max), mid - half + 8], ['0', mid + 4], ['↓ ' + usd(max), mid + half]].forEach(([t, yy]) => add('text', { x: M.l - 6, y: yy, 'text-anchor': 'end', 'font-size': 11, fill: ink2, 'font-family': 'IBM Plex Mono, monospace' }, t));
262
+ const tip = el('div', 'tip'); tip.hidden = true; box.appendChild(tip);
263
+ rows.forEach((r) => {
264
+ const i = slot(r.hour);
265
+ const x = M.l + i * bw + bw * 0.12, w = Math.max(1.5, bw * 0.76);
266
+ const op = r.coverage >= 0.999 ? 1 : 0.35;
267
+ add('rect', { x, y: mid - y(r.usd_long_liquidated), width: w, height: y(r.usd_long_liquidated), fill: 'var(--long)', opacity: op, rx: 1 });
268
+ add('rect', { x, y: mid, width: w, height: y(r.usd_short_liquidated), fill: 'var(--short)', opacity: op, rx: 1 });
269
+ const hit = add('rect', { x: M.l + i * bw, y: M.t, width: bw, height: H - M.t - M.b, fill: 'transparent' });
270
+ hit.addEventListener('pointerenter', () => {
271
+ tip.replaceChildren(el('div', 'mono', `${fmtDay(r.hour)} ${fmtTime(r.hour)} UTC`), el('div', null, `Longs ${usd(r.usd_long_liquidated)} (${r.n_long_liquidated})`), el('div', null, `Shorts ${usd(r.usd_short_liquidated)} (${r.n_short_liquidated})`), el('div', null, `Coverage ${Math.round(100 * r.coverage)}%`));
272
+ tip.hidden = false;
273
+ const px = (M.l + i * bw) * (box.clientWidth / W);
274
+ tip.style.left = Math.min(box.clientWidth - tip.offsetWidth, Math.max(0, px + 12)) + 'px';
275
+ tip.style.top = '0px';
276
+ });
277
+ hit.addEventListener('pointerleave', () => { tip.hidden = true; });
278
+ });
279
+ // day ticks at UTC midnight
280
+ for (let i = 0; i < SLOTS; i++) {
281
+ const t = new Date(startMs + i * HOUR);
282
+ if (t.getUTCHours() !== 0) continue;
283
+ add('line', { x1: M.l + i * bw, x2: M.l + i * bw, y1: M.t, y2: H - M.b, stroke: rule });
284
+ add('text', { x: M.l + i * bw + 3, y: H - 6, 'font-size': 11, fill: ink2, 'font-family': 'IBM Plex Mono, monospace' }, t.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' }));
285
+ }
286
+ box.insertBefore(svg, tip);
287
+ }
288
+
289
+ // ---------- journey ----------
290
+ function fmtAt(iso) { return fmtDay(iso.slice(0, 10)) + ' · ' + fmtTime(iso) + ' UTC'; }
291
+ function renderJourney(lab) {
292
+ $('focus').textContent = lab.lab.focus;
293
+ $('summary').textContent = lab.lab.summary;
294
+ const repo = lab.lab.repo;
295
+ const list = $('study-list'); list.replaceChildren();
296
+ const LINK = { dashboard: 'Interactive dashboard', findings: 'Findings', code: 'Code' };
297
+ lab.studies.forEach((s) => {
298
+ const li = el('li', 'study ' + (s.stage > 0 ? 'is-active' : 'is-planned'));
299
+ li.appendChild(el('div', 'study-id', s.id));
300
+ const main = el('div', 'study-main');
301
+ main.appendChild(el('h3', null, s.title));
302
+ main.appendChild(el('p', 'q', s.question));
303
+ const track = el('div', 'track');
304
+ track.setAttribute('role', 'img');
305
+ track.setAttribute('aria-label', `${s.stage} of ${lab.stages.length} stages done`);
306
+ lab.stages.forEach((name, i) => {
307
+ const seg = el('div', 'seg' + (i < s.stage ? ' done' : i === s.stage ? ' next' : ''));
308
+ seg.appendChild(el('i'));
309
+ seg.appendChild(el('span', null, i === s.stage ? 'Next: ' + name : name));
310
+ track.appendChild(seg);
311
+ });
312
+ main.appendChild(track);
313
+ if (s.headline && s.headline.length) { const ul = el('ul', 'headline'); s.headline.forEach((h) => ul.appendChild(el('li', null, h))); main.appendChild(ul); }
314
+ const foot = el('div', 'study-foot');
315
+ foot.appendChild(el('span', 'status', s.status));
316
+ const sl = el('div', 'study-links');
317
+ Object.entries(s.links || {}).forEach(([k, v]) => { if (v && !v.includes('claude.ai')) sl.appendChild(link(v, LINK[k] || k)); });
318
+ if (sl.children.length) foot.appendChild(sl);
319
+ main.appendChild(foot);
320
+ li.appendChild(main);
321
+ list.appendChild(li);
322
+ });
323
+
324
+ const log = $('log'); log.replaceChildren();
325
+ const entries = [...lab.notebook].sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
326
+ entries.forEach((n) => {
327
+ const li = el('li', 'entry'); li.dataset.study = n.study;
328
+ const t = el('time', 'mono', fmtAt(n.at)); t.dateTime = n.at; li.appendChild(t);
329
+ const main = el('div', 'entry-main');
330
+ const meta = el('p', 'meta');
331
+ const k = el('span', 'kind', n.kind); k.dataset.k = n.kind; meta.appendChild(k);
332
+ meta.appendChild(el('span', 'mono', n.study === 'live' ? 'Live system' : 'Study ' + n.study));
333
+ if (n.ref) meta.appendChild(repo ? link(repo.replace(/\/$/, '') + '/commit/' + n.ref, n.ref) : el('span', 'mono', n.ref));
334
+ main.appendChild(meta);
335
+ main.appendChild(el('h3', null, n.title));
336
+ main.appendChild(el('p', null, n.body));
337
+ li.appendChild(main);
338
+ log.appendChild(li);
339
+ });
340
+ const filters = $('filters'); filters.replaceChildren();
341
+ const ids = [...new Set(entries.map((n) => n.study))].sort();
342
+ let picked = 'all';
343
+ const apply = () => {
344
+ filters.querySelectorAll('.chip').forEach((c) => c.setAttribute('aria-pressed', String(c.dataset.v === picked)));
345
+ log.querySelectorAll('.entry').forEach((e) => { e.hidden = picked !== 'all' && e.dataset.study !== picked; });
346
+ };
347
+ [['all', 'All']].concat(ids.map((i) => [i, i === 'live' ? 'Live system' : 'Study ' + i])).forEach(([v, label]) => {
348
+ const b = el('button', 'chip', label); b.type = 'button'; b.dataset.v = v;
349
+ b.addEventListener('click', () => { picked = v; apply(); });
350
+ filters.appendChild(b);
351
+ });
352
+ filters.hidden = ids.length < 2;
353
+ apply();
354
+ }
355
+
356
+ fetchJson('latest.json').then(renderLive).catch(() => { $('pill').className = 'pill stale'; $('pill-text').textContent = 'Ledger not reachable'; });
357
+ fetchJson('journey/lab.json').then(renderJourney).catch(() => { $('study-list').replaceChildren(el('li', 'empty', 'Could not load the studies.')); });
358
+ let lastW = 0;
359
+ window.addEventListener('resize', () => { const w = $('chart').clientWidth; if (Math.abs(w - lastW) > 40) { lastW = w; fetchJson('latest.json').then(renderLive).catch(() => {}); } });
360
+ })();
361
+ </script>
362
+ </body>
363
  </html>