Bit-Trading-Company commited on
Commit
27c0524
·
verified ·
1 Parent(s): 39c225c

Backtest Lab v1.0.0

Browse files
.preview/components.html ADDED
@@ -0,0 +1,540 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html><html><head><meta charset='utf-8'><style>@font-face{font-family:'Styrene A';src:url('file:///Users/bit-trading-company/Desktop/dev/hugging-face/space/assets/fonts/StyreneA-Light.otf') format('opentype');font-weight:300;font-style:normal;font-display:swap}
2
+ @font-face{font-family:'Styrene A';src:url('file:///Users/bit-trading-company/Desktop/dev/hugging-face/space/assets/fonts/StyreneA-Regular.otf') format('opentype');font-weight:400;font-style:normal;font-display:swap}
3
+ @font-face{font-family:'Styrene A';src:url('file:///Users/bit-trading-company/Desktop/dev/hugging-face/space/assets/fonts/StyreneA-Medium.otf') format('opentype');font-weight:500;font-style:normal;font-display:swap}
4
+ @font-face{font-family:'Mac Minecraft';src:url('file:///Users/bit-trading-company/Desktop/dev/hugging-face/space/assets/fonts/MacMinecraft.ttf') format('truetype');font-weight:400;font-style:normal;font-display:swap}
5
+ :root{
6
+ --pure-black:#000000;
7
+ --pure-white:#ffffff;
8
+ --stone-950:#161512;
9
+ --stone-900:#1d1c18;
10
+ --stone-850:#24221d;
11
+ --stone-800:#2c2a24;
12
+ --stone-700:#3d3a32;
13
+ --stone-600:#54503f;
14
+ --stone-500:#6f6a56;
15
+ --stone-400:#918c76;
16
+ --stone-300:#b6b09a;
17
+ --stone-200:#d6d1bf;
18
+ --stone-100:#ece8dc;
19
+ --stone-50:#f5f3ea;
20
+ --off-white:#f7f4ec;
21
+
22
+ --accent-moss:#68781e;
23
+ --accent-moss-strong:#7d901f;
24
+ --accent-moss-dim:#4d5817;
25
+ --accent-amber:#af9209;
26
+ --accent-amber-strong:#cfab0a;
27
+ --accent-amber-dim:#7d6a09;
28
+
29
+ --mute-red:#8a5a54;
30
+ --mute-orange:#8a6f54;
31
+ --mute-yellow:#8a8154;
32
+ --mute-green:#6e8a54;
33
+ --mute-teal:#54898a;
34
+ --mute-blue:#59656e;
35
+ --mute-indigo:#5c5c8a;
36
+ --mute-violet:#75588a;
37
+
38
+ --fin-up:oklch(66% 0.22 149);
39
+ --fin-up-strong:oklch(72% 0.24 149);
40
+ --fin-down:oklch(62% 0.26 24);
41
+ --fin-down-strong:oklch(68% 0.27 24);
42
+ --fin-flat:var(--stone-400);
43
+ --fin-up-cvd:oklch(58% 0.13 240);
44
+ --fin-up-cvd-strong:oklch(64% 0.14 240);
45
+ --fin-down-cvd:oklch(58% 0.13 55);
46
+ --fin-down-cvd-strong:oklch(64% 0.14 55);
47
+ --fin-buy:var(--fin-up);
48
+ --fin-buy-strong:var(--fin-up-strong);
49
+ --fin-sell:var(--fin-down);
50
+ --fin-sell-strong:var(--fin-down-strong);
51
+
52
+ --focus-ring:var(--accent-amber);
53
+
54
+ --bg-canvas:var(--stone-950);
55
+ --bg-panel:var(--stone-900);
56
+ --bg-raised:var(--stone-850);
57
+ --bg-sunken:var(--pure-black);
58
+ --border-subtle:var(--stone-800);
59
+ --border-default:var(--stone-700);
60
+ --border-strong:var(--stone-500);
61
+ --text-primary:var(--off-white);
62
+ --text-secondary:var(--stone-300);
63
+ --text-tertiary:var(--stone-500);
64
+ --text-disabled:var(--stone-600);
65
+ --text-inverse:var(--stone-950);
66
+ --surface-accent-fg:var(--stone-950);
67
+
68
+ --glass-bg:rgba(29,28,24,0.72);
69
+ --glass-border:rgba(247,244,236,0.14);
70
+ --glass-blur:20px;
71
+ }
72
+ [data-theme="dark"]{
73
+ --bg-canvas:var(--stone-950);
74
+ --bg-panel:var(--stone-900);
75
+ --bg-raised:var(--stone-850);
76
+ --bg-sunken:var(--pure-black);
77
+ --border-subtle:var(--stone-800);
78
+ --border-default:var(--stone-700);
79
+ --border-strong:var(--stone-500);
80
+ --text-primary:var(--off-white);
81
+ --text-secondary:var(--stone-300);
82
+ --text-tertiary:var(--stone-500);
83
+ --text-disabled:var(--stone-600);
84
+ --text-inverse:var(--stone-950);
85
+ --surface-accent-fg:var(--stone-950);
86
+ }
87
+ [data-colorblind="true"]{
88
+ --fin-up:var(--fin-up-cvd);
89
+ --fin-up-strong:var(--fin-up-cvd-strong);
90
+ --fin-down:var(--fin-down-cvd);
91
+ --fin-down-strong:var(--fin-down-cvd-strong);
92
+ }
93
+ [data-theme="light"]{
94
+ --bg-canvas:var(--off-white);
95
+ --bg-panel:var(--stone-50);
96
+ --bg-raised:var(--pure-white);
97
+ --bg-sunken:var(--stone-100);
98
+ --border-subtle:var(--stone-200);
99
+ --border-default:var(--stone-300);
100
+ --border-strong:var(--stone-500);
101
+ --text-primary:var(--stone-950);
102
+ --text-secondary:var(--stone-700);
103
+ --text-tertiary:var(--stone-500);
104
+ --text-disabled:var(--stone-300);
105
+ --text-inverse:var(--off-white);
106
+ --surface-accent-fg:var(--stone-950);
107
+ --glass-bg:rgba(245,243,234,0.72);
108
+ --glass-border:rgba(22,21,18,0.1);
109
+ }
110
+ /* Custom themes: copy this block, rename the [data-theme] value, and override only
111
+ the tokens that should change. Every component reads these tokens, never raw colors,
112
+ so a new theme needs no component edits — set data-theme on any ancestor (SidebarNav's
113
+ theme selector does this at the app root) and everything downstream updates. */
114
+ [data-theme="custom"]{
115
+ --bg-canvas:var(--stone-950);
116
+ --bg-panel:var(--stone-900);
117
+ --bg-raised:var(--stone-850);
118
+ --bg-sunken:var(--pure-black);
119
+ --border-subtle:var(--stone-800);
120
+ --border-default:var(--stone-700);
121
+ --border-strong:var(--accent-amber-dim);
122
+ --text-primary:var(--off-white);
123
+ --text-secondary:var(--stone-300);
124
+ --text-tertiary:var(--stone-500);
125
+ --text-disabled:var(--stone-600);
126
+ --text-inverse:var(--stone-950);
127
+ --surface-accent-fg:var(--stone-950);
128
+ }
129
+
130
+
131
+ :root{
132
+ --font-styrene:'Styrene A',sans-serif;
133
+ --font-system:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
134
+ --font-mono:'JetBrains Mono',ui-monospace,'SFMono-Regular',Menlo,monospace;
135
+ --font-tiny:'Mac Minecraft',monospace;
136
+ --font-body:var(--font-system);
137
+ --font-heading:var(--font-styrene);
138
+ --weight-heading:400;
139
+ --weight-body:300;
140
+ --weight-styrene-thin:100;
141
+ --weight-styrene-light:300;
142
+ --weight-styrene-regular:400;
143
+ --weight-styrene-medium:500;
144
+ --weight-mono:400;
145
+ --weight-tiny:400;
146
+ --weight-strong:700;
147
+
148
+ --text-2xs:8px;
149
+ --text-xs:10px;
150
+ --text-sm:11px;
151
+ --text-base:12px;
152
+ --text-md:16px;
153
+ --text-lg:19px;
154
+ --text-xl:25px;
155
+ --text-2xl:33px;
156
+ --text-3xl:45px;
157
+ --text-4xl:62px;
158
+
159
+ --leading-tight:1.1;
160
+ --leading-snug:1.3;
161
+ --leading-normal:1.5;
162
+ --leading-relaxed:1.7;
163
+
164
+ --tracking-tight:-0.01em;
165
+ --tracking-normal:0;
166
+ --tracking-wide:0.04em;
167
+ --tracking-wider:0.12em;
168
+ }
169
+
170
+ :root{
171
+ --space-0:0px;
172
+ --space-1:4px;
173
+ --space-2:8px;
174
+ --space-3:12px;
175
+ --space-4:16px;
176
+ --space-5:24px;
177
+ --space-6:32px;
178
+ --space-7:48px;
179
+ --space-8:64px;
180
+ --space-9:96px;
181
+
182
+ --radius-0:0px;
183
+ --radius-sm:4px;
184
+ --radius-md:8px;
185
+ --radius-pill:999px;
186
+ --border-width:1px;
187
+ --border-width-strong:2px;
188
+
189
+ --ease:ease;/* @kind other */
190
+ --duration:1s;/* @kind other */
191
+
192
+ --shadow-none:none;
193
+ --shadow-panel:0 0 0 var(--border-width) var(--border-default);
194
+ --z-header:100;/* @kind other */
195
+ --z-menu:200;/* @kind other */
196
+ --z-modal:300;/* @kind other */
197
+ --z-toast:400;/* @kind other */
198
+ }
199
+
200
+
201
+ /* ================= app shell ================= */
202
+ .gradio-container{
203
+ max-width:100% !important; padding:0 !important;
204
+ background:var(--bg-canvas) !important;
205
+ font-family:var(--font-body); font-weight:var(--weight-body);
206
+ color:var(--text-primary); font-size:var(--text-base);
207
+ }
208
+ .gradio-container *{ border-radius:var(--radius-sm) !important; }
209
+ footer{ display:none !important; }
210
+ .gap.svelte-vt1mxs, .gradio-container .gap{ gap:var(--space-2) !important; }
211
+
212
+ /* ================= typography ================= */
213
+ .bit-h1,.bit-h2,.bit-h3{
214
+ font-family:var(--font-heading); font-weight:var(--weight-heading);
215
+ letter-spacing:var(--tracking-wide); text-transform:uppercase;
216
+ color:var(--text-primary); margin:0;
217
+ }
218
+ .bit-h1{ font-size:var(--text-md); }
219
+ .bit-h2{ font-size:var(--text-md); }
220
+ .bit-h3{ font-size:var(--text-base); }
221
+ .bit-micro{
222
+ font-family:var(--font-tiny); font-size:var(--text-xs);
223
+ letter-spacing:var(--tracking-wider); text-transform:uppercase;
224
+ color:var(--text-tertiary); line-height:1.6;
225
+ }
226
+ .bit-spacer{ flex:1; }
227
+
228
+ /* ================= top bar ================= */
229
+ .bit-topbar{
230
+ display:flex; align-items:center; gap:var(--space-3); flex-wrap:wrap;
231
+ background:var(--bg-panel);
232
+ border-bottom:var(--border-width) solid var(--border-default);
233
+ padding:var(--space-3) var(--space-4);
234
+ position:sticky; top:0; z-index:var(--z-header);
235
+ }
236
+ .bit-mark{
237
+ width:20px; height:20px; background:var(--accent-amber);
238
+ display:inline-block; flex:0 0 20px;
239
+ box-shadow:inset 0 0 0 3px var(--bg-panel), inset 0 0 0 6px var(--accent-amber);
240
+ }
241
+ .bit-wordmark{
242
+ font-family:var(--font-heading); font-size:var(--text-md); font-weight:500;
243
+ letter-spacing:var(--tracking-wide); color:var(--text-primary);
244
+ }
245
+ .bit-slash{ color:var(--text-tertiary); }
246
+ .bit-chip{
247
+ display:inline-flex; align-items:center; gap:6px; white-space:nowrap;
248
+ font-family:var(--font-mono); font-size:var(--text-sm);
249
+ border:var(--border-width) solid var(--border-default);
250
+ padding:3px var(--space-2); color:var(--text-secondary); background:transparent;
251
+ }
252
+ .bit-chip-ok{ color:var(--accent-moss-strong); border-color:var(--accent-moss-dim); }
253
+ .bit-chip-run{ color:var(--accent-amber-strong); border-color:var(--accent-amber-dim); }
254
+ .bit-chip-warn{ color:var(--fin-down); border-color:var(--fin-down); }
255
+ .bit-chip-accent{ color:var(--stone-950); background:var(--accent-amber);
256
+ border-color:var(--accent-amber); }
257
+ .bit-chip-ctx{ color:var(--text-tertiary); }
258
+ a.bit-link{ text-decoration:none; }
259
+ a.bit-link:hover{ color:var(--accent-amber-strong);
260
+ border-color:var(--accent-amber-dim); }
261
+
262
+ /* ================= panels ================= */
263
+ .bit-panel{
264
+ background:var(--bg-panel);
265
+ border:var(--border-width) solid var(--border-default);
266
+ padding:var(--space-4); margin-bottom:var(--space-3);
267
+ }
268
+ .bit-panel-head{
269
+ display:flex; align-items:baseline; gap:var(--space-3);
270
+ border-bottom:var(--border-width) solid var(--border-subtle);
271
+ padding-bottom:var(--space-2); margin-bottom:var(--space-3);
272
+ }
273
+ .bit-zone-title{
274
+ font-family:var(--font-heading); text-transform:uppercase;
275
+ letter-spacing:var(--tracking-wide); font-size:var(--text-md);
276
+ color:var(--text-primary); padding:var(--space-3) var(--space-1) var(--space-2);
277
+ display:flex; align-items:baseline; gap:var(--space-2);
278
+ }
279
+ .bit-zone-title .bit-micro{ margin-left:auto; }
280
+
281
+ /* ================= stat band ================= */
282
+ .bit-statband{
283
+ display:flex; flex-wrap:wrap;
284
+ border:var(--border-width) solid var(--border-default);
285
+ background:var(--bg-panel); margin-bottom:var(--space-3);
286
+ }
287
+ .bit-stat{
288
+ flex:1 1 132px; padding:var(--space-3) var(--space-4);
289
+ border-right:var(--border-width) solid var(--border-subtle);
290
+ border-bottom:var(--border-width) solid var(--border-subtle);
291
+ }
292
+ .bit-stat-label{
293
+ font-family:var(--font-tiny); font-size:var(--text-xs);
294
+ letter-spacing:var(--tracking-wider); text-transform:uppercase;
295
+ color:var(--text-tertiary);
296
+ }
297
+ .bit-stat-value{
298
+ font-family:var(--font-mono); font-size:var(--text-xl);
299
+ line-height:var(--leading-tight); color:var(--text-primary);
300
+ margin:3px 0 2px; letter-spacing:var(--tracking-tight);
301
+ }
302
+ .bit-stat-sub{
303
+ font-family:var(--font-mono); font-size:var(--text-xs);
304
+ color:var(--text-tertiary);
305
+ }
306
+ .bit-up{ color:var(--fin-up-strong); } .bit-down{ color:var(--fin-down-strong); }
307
+
308
+ /* ================= notes ================= */
309
+ .bit-note{
310
+ font-family:var(--font-mono); font-size:var(--text-sm); line-height:1.5;
311
+ border-left:var(--border-width-strong) solid var(--accent-amber);
312
+ background:var(--bg-raised); padding:var(--space-2) var(--space-3);
313
+ color:var(--text-secondary); margin-bottom:var(--space-2);
314
+ }
315
+ .bit-note-danger{ border-left-color:var(--fin-down); }
316
+ .bit-tag{
317
+ font-family:var(--font-tiny); font-size:var(--text-2xs);
318
+ letter-spacing:var(--tracking-wider); border:1px solid var(--border-default);
319
+ padding:1px 4px; color:var(--text-tertiary); margin-left:4px;
320
+ }
321
+
322
+ /* ================= empty state ================= */
323
+ .bit-empty{
324
+ display:flex; flex-direction:column; align-items:center; justify-content:center;
325
+ gap:var(--space-3); padding:var(--space-8) var(--space-4); text-align:center;
326
+ border:var(--border-width) dashed var(--border-default); background:var(--bg-panel);
327
+ }
328
+ .bit-empty-glyph{ font-size:34px; color:var(--accent-amber-dim); line-height:1; }
329
+ .bit-empty-copy{ color:var(--text-secondary); max-width:46ch; font-size:var(--text-base); }
330
+ .bit-kbd-row{ display:flex; gap:var(--space-2); flex-wrap:wrap; justify-content:center; }
331
+ .bit-kbd{
332
+ font-family:var(--font-tiny); font-size:var(--text-xs);
333
+ letter-spacing:var(--tracking-wider); color:var(--text-tertiary);
334
+ border:var(--border-width) solid var(--border-default); padding:3px 7px;
335
+ }
336
+
337
+ /* ================= run manager ================= */
338
+ .bit-run-card{
339
+ border:var(--border-width) solid var(--border-subtle);
340
+ padding:var(--space-2); margin-bottom:var(--space-1); background:var(--bg-panel);
341
+ }
342
+ .bit-run-card-sel{ border-color:var(--accent-amber-dim); }
343
+ .bit-run-top{ display:flex; align-items:center; gap:var(--space-2); }
344
+ .bit-run-name{ font-size:var(--text-sm); color:var(--text-primary);
345
+ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
346
+ .bit-run-mark{ margin-left:auto; color:var(--accent-amber-strong); font-size:11px; }
347
+ .bit-run-sharpe{ font-family:var(--font-mono); font-size:var(--text-base);
348
+ display:flex; gap:var(--space-2); }
349
+ .bit-run-ret{ margin-left:auto; color:var(--text-tertiary); }
350
+ .bit-gloss{ margin-bottom:var(--space-2); }
351
+ .bit-gloss-def{ font-size:var(--text-sm); color:var(--text-secondary); line-height:1.45; }
352
+
353
+ /* ================= KPI row ================= */
354
+ .bit-kpi-row{ display:flex; flex-wrap:wrap; gap:var(--space-4); }
355
+ .bit-kpi{ min-width:78px; }
356
+ .bit-kpi-value{ font-family:var(--font-mono); font-size:var(--text-lg);
357
+ line-height:1.1; color:var(--text-primary); }
358
+
359
+ /* ================= podium ================= */
360
+ .bit-podium-row{ display:flex; gap:var(--space-3); flex-wrap:wrap;
361
+ margin-bottom:var(--space-3); }
362
+ .bit-podium{
363
+ flex:1 1 200px; border:var(--border-width) solid var(--border-default);
364
+ background:var(--bg-panel); padding:var(--space-3);
365
+ border-top:var(--border-width-strong) solid var(--border-strong);
366
+ }
367
+ .bit-podium-1{ border-top-color:var(--accent-amber); }
368
+ .bit-podium-2{ border-top-color:var(--accent-moss-strong); }
369
+ .bit-podium-3{ border-top-color:var(--mute-teal); }
370
+ .bit-podium-rank{ font-family:var(--font-tiny); font-size:var(--text-xs);
371
+ color:var(--text-tertiary); letter-spacing:var(--tracking-wider); }
372
+ .bit-podium-name{ font-size:var(--text-md); color:var(--text-primary);
373
+ font-family:var(--font-heading); letter-spacing:var(--tracking-tight); }
374
+ .bit-podium-value{ font-family:var(--font-mono); font-size:var(--text-2xl);
375
+ line-height:1.1; margin-top:var(--space-2); display:flex;
376
+ align-items:baseline; gap:var(--space-2); }
377
+ .bit-podium-unit{ font-family:var(--font-tiny); font-size:var(--text-xs);
378
+ letter-spacing:var(--tracking-wider); color:var(--text-tertiary); }
379
+
380
+ /* ================= signal aggregator ================= */
381
+ .bit-sig-grid{ display:flex; gap:var(--space-4); flex-wrap:wrap; align-items:flex-start; }
382
+ .bit-sig-list{ flex:1 1 340px; min-width:0; }
383
+ .bit-sig-row{
384
+ display:flex; align-items:center; gap:var(--space-3);
385
+ border:var(--border-width) solid var(--border-subtle);
386
+ padding:5px var(--space-2); margin-bottom:3px;
387
+ font-family:var(--font-mono); font-size:var(--text-sm);
388
+ }
389
+ .bit-sig-name{ flex:1 1 auto; color:var(--text-secondary);
390
+ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
391
+ .bit-sig-dir{ width:74px; text-align:right; }
392
+ .bit-sig-edge{ width:64px; text-align:right; }
393
+ .bit-sig-acc,.bit-sig-w{ width:64px; text-align:right; color:var(--text-tertiary); }
394
+ .bit-consensus{
395
+ flex:0 0 210px; border:var(--border-width) solid var(--accent-moss-dim);
396
+ background:var(--bg-raised); padding:var(--space-3);
397
+ }
398
+ .bit-consensus-dir{ font-family:var(--font-heading); font-size:var(--text-2xl);
399
+ line-height:1.1; margin:var(--space-1) 0; letter-spacing:var(--tracking-tight); }
400
+ .bit-conf-track{ height:4px; background:var(--bg-sunken); margin:var(--space-2) 0 4px; }
401
+ .bit-conf-fill{ height:100%; }
402
+
403
+ /* ================= gradio overrides ================= */
404
+ .gradio-container .tabs > .tab-nav{
405
+ border-bottom:var(--border-width) solid var(--border-default) !important;
406
+ background:transparent !important; gap:0 !important; flex-wrap:wrap;
407
+ }
408
+ .gradio-container .tabs > .tab-nav > button{
409
+ font-family:var(--font-heading) !important; text-transform:uppercase;
410
+ letter-spacing:var(--tracking-wide); font-size:var(--text-base) !important;
411
+ color:var(--text-tertiary) !important; background:transparent !important;
412
+ border:none !important; border-bottom:2px solid transparent !important;
413
+ padding:var(--space-2) var(--space-4) !important;
414
+ }
415
+ .gradio-container .tabs > .tab-nav > button:hover{ color:var(--text-secondary) !important; }
416
+ .gradio-container .tabs > .tab-nav > button.selected{
417
+ color:var(--text-primary) !important;
418
+ border-bottom-color:var(--accent-amber) !important;
419
+ }
420
+ .gradio-container .form, .gradio-container .block{
421
+ background:transparent !important; border:none !important;
422
+ }
423
+ /* Field captions only -- scoped so it never swallows option text. */
424
+ .block > label > span, .block > .form > label > span{
425
+ font-family:var(--font-tiny) !important; font-size:var(--text-xs) !important;
426
+ letter-spacing:var(--tracking-wider) !important; text-transform:uppercase;
427
+ color:var(--text-tertiary) !important;
428
+ }
429
+ input, select, textarea{
430
+ font-family:var(--font-mono) !important; font-size:var(--text-base) !important;
431
+ background:var(--bg-sunken) !important; color:var(--text-primary) !important;
432
+ border:var(--border-width) solid var(--border-default) !important;
433
+ }
434
+ input:focus, select:focus, textarea:focus{
435
+ outline:1px solid var(--focus-ring) !important; outline-offset:-1px;
436
+ }
437
+
438
+ /* Radio / checkbox options read as segmented chips. */
439
+ .gradio-container fieldset label,
440
+ .gradio-container .wrap label:has(input[type="radio"]),
441
+ .gradio-container .wrap label:has(input[type="checkbox"]){
442
+ background:transparent !important;
443
+ border:var(--border-width) solid var(--border-default) !important;
444
+ color:var(--text-secondary) !important;
445
+ padding:4px 10px !important; margin:2px !important;
446
+ }
447
+ .gradio-container fieldset label span,
448
+ .gradio-container .wrap label:has(input[type="radio"]) span,
449
+ .gradio-container .wrap label:has(input[type="checkbox"]) span{
450
+ font-family:var(--font-mono) !important; font-size:var(--text-base) !important;
451
+ letter-spacing:var(--tracking-normal) !important; text-transform:none !important;
452
+ color:var(--text-secondary) !important; opacity:1 !important;
453
+ }
454
+ .gradio-container fieldset label.selected,
455
+ .gradio-container fieldset label:has(input:checked),
456
+ .gradio-container .wrap label:has(input[type="radio"]:checked){
457
+ background:var(--accent-amber) !important; border-color:var(--accent-amber) !important;
458
+ }
459
+ .gradio-container fieldset label.selected span,
460
+ .gradio-container fieldset label:has(input:checked) span,
461
+ .gradio-container .wrap label:has(input[type="radio"]:checked) span{
462
+ color:var(--stone-950) !important; font-weight:500 !important;
463
+ }
464
+ .gradio-container input[type="radio"], .gradio-container input[type="checkbox"]{
465
+ accent-color:var(--accent-amber);
466
+ }
467
+
468
+ /* Accordions read as the design's numbered sections. */
469
+ .gradio-container .label-wrap, .bit-accordion > .label-wrap{
470
+ font-family:var(--font-heading) !important; text-transform:uppercase;
471
+ letter-spacing:var(--tracking-wide); font-size:var(--text-base) !important;
472
+ color:var(--text-primary) !important;
473
+ border-bottom:var(--border-width) solid var(--border-subtle) !important;
474
+ padding:var(--space-2) 0 !important;
475
+ }
476
+ .bit-accordion{
477
+ border:none !important;
478
+ border-top:var(--border-width) solid var(--border-subtle) !important;
479
+ padding:0 !important; margin-bottom:var(--space-1) !important;
480
+ }
481
+
482
+ /* Buttons */
483
+ .bit-run-btn{
484
+ background:var(--accent-amber) !important; color:var(--stone-950) !important;
485
+ font-family:var(--font-heading) !important; text-transform:uppercase;
486
+ letter-spacing:var(--tracking-wide); border:none !important; font-weight:500 !important;
487
+ }
488
+ .bit-run-btn:hover{ background:var(--accent-amber-strong) !important; }
489
+ .bit-ghost-btn{
490
+ background:transparent !important; color:var(--text-secondary) !important;
491
+ border:var(--border-width) solid var(--border-default) !important;
492
+ font-family:var(--font-mono) !important; font-size:var(--text-sm) !important;
493
+ }
494
+ .bit-ghost-btn:hover{ border-color:var(--border-strong) !important;
495
+ color:var(--text-primary) !important; }
496
+
497
+ /* Tables */
498
+ .bit-table table{ font-family:var(--font-mono) !important; font-size:var(--text-sm) !important; }
499
+ .bit-table thead th{
500
+ font-family:var(--font-tiny) !important; font-size:var(--text-xs) !important;
501
+ letter-spacing:var(--tracking-wider); text-transform:uppercase;
502
+ color:var(--text-tertiary) !important; background:var(--bg-raised) !important;
503
+ border-bottom:var(--border-width) solid var(--border-default) !important;
504
+ }
505
+ .bit-table tbody td{ border-color:var(--border-subtle) !important; }
506
+
507
+ /* Footer */
508
+ .bit-footer{
509
+ position:sticky; bottom:0; z-index:var(--z-header);
510
+ display:flex; justify-content:space-between; gap:var(--space-4);
511
+ background:var(--bg-panel); border-top:var(--border-width) solid var(--border-default);
512
+ padding:var(--space-2) var(--space-4);
513
+ font-family:var(--font-mono); font-size:var(--text-sm); color:var(--text-tertiary);
514
+ }
515
+ .bit-footer-right{ white-space:nowrap; }
516
+
517
+ @media (max-width: 900px){
518
+ .bit-statband{ flex-direction:column; }
519
+ .bit-stat{ border-right:none; }
520
+ .bit-sig-grid{ flex-direction:column; }
521
+ .bit-consensus{ flex:1 1 auto; width:100%; }
522
+ }
523
+ </style></head><body class='gradio-container' style='margin:0'>
524
+ <div class="bit-topbar">
525
+ <span class="bit-mark"></span>
526
+ <span class="bit-wordmark">BIT</span>
527
+ <span class="bit-slash">/</span>
528
+ <span class="bit-h1">Backtest Lab</span>
529
+ <span class="bit-chip bit-chip-ctx">BTC-USD · 1D · 2023-08-16 → 2026-08-15 · WALK-FORWARD</span>
530
+ <span class="bit-spacer"></span>
531
+ <span class="bit-micro" style="margin:0 4px">0.1s</span><span class="bit-chip bit-chip-ok">RUN 7bee8d1b COMPLETE</span>
532
+ <a class="bit-chip bit-link" target="_blank" rel="noopener"
533
+ href="https://huggingface.co/datasets/The-Bit-Trading-Company/bit-signal-store">SIGNAL STORE ↗</a>
534
+ <a class="bit-chip bit-link" target="_blank" rel="noopener"
535
+ href="https://huggingface.co/spaces/Bit-Trading-Company/bit-backtest-lab">SPACE ↗</a>
536
+ </div><div style="padding:16px"><div class="bit-zone-title">Leaderboard podium</div><div class="bit-podium-row"><div class="bit-podium bit-podium-1"><div class="bit-podium-rank">#1</div><div class="bit-podium-name">Bollinger Breakout</div><div class="bit-micro">NVDA · 1h</div><div class="bit-podium-value" style="color:var(--accent-moss-strong)">3.33<span class="bit-podium-unit">OOS SHARPE</span></div><div class="bit-micro">ret +22.3% · dd -19.3% · 49 trades</div></div><div class="bit-podium bit-podium-2"><div class="bit-podium-rank">#2</div><div class="bit-podium-name">Chronos Forecast Follower</div><div class="bit-micro">BTC-USD · 1d · Chronos-Bolt Small</div><div class="bit-podium-value" style="color:var(--accent-moss-strong)">1.43<span class="bit-podium-unit">OOS SHARPE</span></div><div class="bit-micro">ret +122.3% · dd -23.1% · 35 trades</div></div><div class="bit-podium bit-podium-3"><div class="bit-podium-rank">#3</div><div class="bit-podium-name">MACD Momentum</div><div class="bit-micro">NVDA · 1h</div><div class="bit-podium-value" style="color:var(--accent-moss-strong)">1.26<span class="bit-podium-unit">OOS SHARPE</span></div><div class="bit-micro">ret +21.7% · dd -29.7% · 93 trades</div></div></div><div class="bit-zone-title">Signal aggregator</div><div class="bit-panel"><div class="bit-panel-head"><span class="bit-h3">Signal aggregator</span><span class="bit-micro">8 MODELS</span></div><div class="bit-sig-grid"><div class="bit-sig-list"><div class="bit-sig-row"><span class="bit-sig-name">Chronos-Bolt Small</span><span class="bit-sig-dir" style="color:var(--fin-up-strong)">▲ LONG</span><span class="bit-sig-edge" style="color:var(--fin-up-strong)">+0.27%</span><span class="bit-sig-acc">acc 53%</span><span class="bit-sig-w">w0.07</span></div><div class="bit-sig-row"><span class="bit-sig-name">Chronos-Bolt Base</span><span class="bit-sig-dir" style="color:var(--fin-down-strong)">▼ SHORT</span><span class="bit-sig-edge" style="color:var(--fin-down-strong)">-1.15%</span><span class="bit-sig-acc">acc 51%</span><span class="bit-sig-w">w0.03</span></div><div class="bit-sig-row"><span class="bit-sig-name">Chronos-Bolt Mini</span><span class="bit-sig-dir" style="color:var(--fin-down-strong)">▼ SHORT</span><span class="bit-sig-edge" style="color:var(--fin-down-strong)">-1.23%</span><span class="bit-sig-acc">acc 51%</span><span class="bit-sig-w">w0.03</span></div><div class="bit-sig-row"><span class="bit-sig-name">Baseline · Drift <span class="bit-tag">BASELINE</span></span><span class="bit-sig-dir" style="color:var(--fin-down-strong)">▼ SHORT</span><span class="bit-sig-edge" style="color:var(--fin-down-strong)">-0.06%</span><span class="bit-sig-acc">acc 51%</span><span class="bit-sig-w">w0.03</span></div><div class="bit-sig-row"><span class="bit-sig-name">Baseline · Seasonal naive <span class="bit-tag">BASELINE</span></span><span class="bit-sig-dir" style="color:var(--fin-down-strong)">▼ SHORT</span><span class="bit-sig-edge" style="color:var(--fin-down-strong)">-8.33%</span><span class="bit-sig-acc">acc 51%</span><span class="bit-sig-w">w0.03</span></div><div class="bit-sig-row"><span class="bit-sig-name">Chronos-Bolt Tiny</span><span class="bit-sig-dir" style="color:var(--fin-up-strong)">▲ LONG</span><span class="bit-sig-edge" style="color:var(--fin-up-strong)">+0.60%</span><span class="bit-sig-acc">acc 50%</span><span class="bit-sig-w">w0.01</span></div><div class="bit-sig-row"><span class="bit-sig-name">Baseline · Random walk <span class="bit-tag">BASELINE</span></span><span class="bit-sig-dir" style="color:var(--text-tertiary)">■ FLAT</span><span class="bit-sig-edge" style="color:var(--text-tertiary)">+0.00%</span><span class="bit-sig-acc">acc —</span><span class="bit-sig-w">w0.00</span></div><div class="bit-sig-row"><span class="bit-sig-name">Chronos T5 Small</span><span class="bit-sig-dir" style="color:var(--fin-up-strong)">▲ LONG</span><span class="bit-sig-edge" style="color:var(--fin-up-strong)">+0.95%</span><span class="bit-sig-acc">acc 48%</span><span class="bit-sig-w">w0.00</span></div></div><div class="bit-consensus"><div class="bit-micro">BTC-USD · 1d</div><div class="bit-consensus-dir" style="color:var(--fin-down-strong)">SHORT ▼</div><div class="bit-micro">WEIGHTED EDGE -1.44%</div><div class="bit-conf-track"><div class="bit-conf-fill" style="width:50%;background:var(--fin-down-strong)"></div></div><div class="bit-micro">4/8 MODELS AGREE</div></div></div><div class="bit-micro" style="color:var(--text-tertiary)">weight = realised directional accuracy above a coin flip, on this asset and timeframe</div></div><div class="bit-note bit-note-danger">Learned models call direction correctly 50.2% of the time; naive baselines manage 51.1%. <b>The models are not beating the baselines on direction.</b> Read the leaderboard with that in mind.</div><div class="bit-zone-title">Coverage</div><div class="bit-note bit-note-danger">The signal store has no coverage yet.</div></div>
537
+ <div class="bit-footer">
538
+ <span>Backtested results are hypothetical, derived from historical data, and are not indicative of future results. Nothing here is investment advice. The Bit Trading Company is not a licensed investment adviser.</span>
539
+ <span class="bit-footer-right">BITTRADING BACKTEST LAB v1.1.0</span>
540
+ </div></body></html>
app.py CHANGED
@@ -1,32 +1,38 @@
1
  """Bit Trading Company — Backtest Lab.
2
 
3
- Gradio Blocks implementation of the Backtest Lab design: Strategy Builder on
4
- the left, tabbed Results Canvas in the centre, Run Manager on the right, with
5
- the disclaimer pinned to the footer.
 
 
 
6
 
7
  The app reads exclusively from the cached signal store. The only path that can
8
  reach an external provider is the batch refresh in `scripts/seed_store.py`, and
9
- the only path that runs inference is the ZeroGPU extension flow in
10
- `src/extension.py`, which spends the signed-in user's own quota.
11
  """
12
 
13
  from __future__ import annotations
14
 
15
  import logging
16
  import os
17
- from dataclasses import asdict
18
 
19
  import gradio as gr
20
  import pandas as pd
21
 
22
- from src import charts, comparisons, config, runtime, strategies
23
  from src.runtime import RunError, RunRecord, RunRequest
 
 
24
  from src.ui import theme
 
25
 
26
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
27
  log = logging.getLogger("bit.app")
28
 
29
  MAX_COMPARE = 6
 
30
  GLOSSARY = [
31
  ("SHARPE", "Annualized mean excess return divided by return volatility. "
32
  "Above 1 is good; above 3 usually means a bug."),
@@ -37,120 +43,30 @@ GLOSSARY = [
37
  ("MAE / MFE", "Worst and best unrealized excursion while the trade was open."),
38
  ("WALK-FORWARD", "Train on a rolling window, test on the next unseen window, repeat."),
39
  ("OOS", "Out of sample: data the parameters never saw during fitting."),
 
 
40
  ]
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  # --------------------------------------------------------------------------
44
- # Formatting helpers
45
  # --------------------------------------------------------------------------
46
 
47
 
48
- def pct(v, digits=1, signed=True) -> str:
49
- if v is None or pd.isna(v):
50
- return "—"
51
- return f"{v * 100:+.{digits}f}%" if signed else f"{v * 100:.{digits}f}%"
52
-
53
-
54
- def num(v, digits=2) -> str:
55
- if v is None or pd.isna(v):
56
- return "—"
57
- return f"{v:.{digits}f}"
58
-
59
-
60
- def money(v) -> str:
61
- if v is None or pd.isna(v):
62
- return "—"
63
- return f"${v:,.0f}"
64
-
65
-
66
- def _cls(v) -> str:
67
- if v is None or pd.isna(v) or v == 0:
68
- return ""
69
- return "bit-up" if v > 0 else "bit-down"
70
-
71
-
72
- def _arrow(v) -> str:
73
- if v is None or pd.isna(v) or v == 0:
74
- return ""
75
- return " ▲" if v > 0 else " ▼"
76
-
77
-
78
- def seg(metrics, fmt, *args, **kwargs) -> str:
79
- """Format a segment metric, or an em dash when that segment has no bars.
80
-
81
- A segment with no data must never render as 0.00 -- "the out-of-sample
82
- Sharpe is zero" and "there is no out-of-sample period" are different claims,
83
- and only one of them is true here.
84
- """
85
- if metrics is None or metrics.bars == 0:
86
- return "—"
87
- return fmt(*args, **kwargs)
88
-
89
-
90
- def stat_band_html(rec: RunRecord | None) -> str:
91
- """The stat band. Every stat carries its IS and OOS split, per the design."""
92
- if rec is None:
93
- return ""
94
- r = rec.result
95
- a, i, o = r.metrics_all, r.metrics_is, r.metrics_oos
96
-
97
- def isoos(fmt, ikey, okey, *fargs):
98
- iv = seg(i, fmt, getattr(i, ikey), *fargs)
99
- ov = seg(o, fmt, getattr(o, okey), *fargs)
100
- return f"IS {iv} · OOS {ov}"
101
- bench_gap = a.total_return - (
102
- float(r.benchmark_equity.iloc[-1] / r.benchmark_equity.iloc[0] - 1.0)
103
- if len(r.benchmark_equity) else 0.0
104
- )
105
-
106
- cells = [
107
- ("Total return", f"{pct(a.total_return)}{_arrow(a.total_return)}", _cls(a.total_return),
108
- isoos(pct, "total_return", "total_return"),
109
- "Cumulative return of the strategy equity curve, costs included."),
110
- ("CAGR", pct(a.cagr), _cls(a.cagr), isoos(pct, "cagr", "cagr"),
111
- "Compound annual growth rate implied by the equity curve."),
112
- ("Sharpe", num(a.sharpe), _cls(a.sharpe),
113
- isoos(num, "sharpe", "sharpe"), GLOSSARY[0][1]),
114
- ("Sortino", num(a.sortino), _cls(a.sortino),
115
- isoos(num, "sortino", "sortino"), GLOSSARY[1][1]),
116
- ("Max drawdown", pct(a.max_drawdown), "bit-down",
117
- isoos(pct, "max_drawdown", "max_drawdown"), GLOSSARY[2][1]),
118
- ("Win rate", pct(a.win_rate, 0, signed=False), "",
119
- f"IS {seg(i, pct, i.win_rate, 0, False)} · OOS {seg(o, pct, o.win_rate, 0, False)}",
120
- "Share of closed trades with positive net P&L."),
121
- ("Profit factor", num(a.profit_factor), _cls(a.profit_factor - 1.0),
122
- isoos(num, "profit_factor", "profit_factor"), GLOSSARY[3][1]),
123
- ("Trades", f"{a.trade_count}", "",
124
- f"IS {seg(i, str, i.trade_count)} · OOS {seg(o, str, o.trade_count)}",
125
- "Closed round-trip trades in the period."),
126
- ("Exposure", pct(a.exposure, 0, signed=False), "",
127
- f"IS {seg(i, pct, i.exposure, 0, False)} · OOS {seg(o, pct, o.exposure, 0, False)}",
128
- "Fraction of bars holding a position."),
129
- ("vs buy & hold", f"{pct(bench_gap)}", _cls(bench_gap),
130
- f"costs paid {money(r.costs_paid)}",
131
- "Strategy return minus buy-and-hold return over the same window."),
132
- ]
133
- html = ['<div class="bit-statband">']
134
- for label, value, cls, sub, tip in cells:
135
- html.append(
136
- f'<div class="bit-stat" title="{tip}">'
137
- f'<div class="bit-stat-label">{label}</div>'
138
- f'<div class="bit-stat-value {cls}">{value}</div>'
139
- f'<div class="bit-stat-sub">{sub}</div></div>'
140
- )
141
- html.append("</div>")
142
- for note in getattr(r.plan, "notes", []):
143
- html.append(f'<div class="bit-note bit-note-danger">{note}</div>')
144
- if rec.result.metrics_holdout is not None:
145
- h = rec.result.metrics_holdout
146
- html.append(
147
- f'<div class="bit-note">LOCKED HOLDOUT · return {pct(h.total_return)} · '
148
- f'Sharpe {num(h.sharpe)} · {h.bars} bars never used for any parameter choice.</div>'
149
- )
150
- return "".join(html)
151
-
152
-
153
- def context_chip(req: RunRequest) -> str:
154
  mode = {"walk_forward": "WALK-FORWARD", "holdout": "HOLDOUT",
155
  "split": "SPLIT", "none": "NO SPLIT"}.get(req.validation_mode, "")
156
  try:
@@ -158,53 +74,7 @@ def context_chip(req: RunRequest) -> str:
158
  span = f"{s.date()} → {e.date()}"
159
  except Exception:
160
  span = req.date_range
161
- return (f'<span class="bit-chip">{req.asset} · {req.timeframe.upper()} · '
162
- f'{span} · {mode}</span>')
163
-
164
-
165
- def top_bar_html(status_text="NO RUN LOADED", status_cls="bit-chip", chip="") -> str:
166
- return f"""
167
- <div class="bit-topbar">
168
- <span class="bit-mark"></span>
169
- <span class="bit-h1">BIT</span>
170
- <span style="color:var(--text-tertiary)">/</span>
171
- <span class="bit-h1">Backtest Lab</span>
172
- {chip}
173
- <span style="flex:1"></span>
174
- <span class="{status_cls}">{status_text}</span>
175
- <a class="bit-chip" href="https://huggingface.co/datasets/{config.STORE_REPO}"
176
- target="_blank" rel="noopener">SIGNAL STORE ↗</a>
177
- </div>"""
178
-
179
-
180
- FOOTER_HTML = f"""
181
- <div class="bit-footer">
182
- <span>{config.DISCLAIMER}</span>
183
- <span style="white-space:nowrap">BITTRADING BACKTEST LAB v1.0.0</span>
184
- </div>"""
185
-
186
-
187
- EMPTY_HTML = """
188
- <div class="bit-empty">
189
- <div class="bit-h2">No run loaded</div>
190
- <div style="color:var(--text-secondary);max-width:46ch">
191
- Configure a strategy on the left, or start from a worked example and edit it.
192
- </div>
193
- <div style="display:flex;gap:8px;margin-top:8px">
194
- <span class="bit-kbd">⌘↵ RUN</span>
195
- <span class="bit-kbd">⌘S SAVE CONFIG</span>
196
- <span class="bit-kbd">COSTS DEFAULT ON</span>
197
- </div>
198
- </div>"""
199
-
200
-
201
- def glossary_html() -> str:
202
- rows = "".join(
203
- f'<div style="margin-bottom:8px"><div class="bit-micro">{t}</div>'
204
- f'<div style="font-size:11px;color:var(--text-secondary)">{d}</div></div>'
205
- for t, d in GLOSSARY
206
- )
207
- return f'<div class="bit-panel">{rows}</div>'
208
 
209
 
210
  def trades_frame(rec: RunRecord | None) -> pd.DataFrame:
@@ -218,67 +88,81 @@ def trades_frame(rec: RunRecord | None) -> pd.DataFrame:
218
  "Entry": t["entry_ts"].dt.strftime("%Y-%m-%d %H:%M"),
219
  "Exit": t["exit_ts"].dt.strftime("%Y-%m-%d %H:%M"),
220
  "Side": t["side"].str.upper(),
221
- "Entry px": t["entry_px"].round(2),
222
- "Exit px": t["exit_px"].round(2),
223
  "Size": t["size"].round(4),
224
- "Gross": t["gross_pnl"].round(2),
225
- "Costs": t["costs"].round(2),
226
- "Net": t["net_pnl"].round(2),
227
- "R": t["r_multiple"].round(2),
228
- "Bars": t["duration_bars"],
229
- "MAE": (t["mae"] * 100).round(1),
230
- "Segment": t["segment"],
231
- "Trigger": t["trigger"],
232
  })
233
 
234
 
 
 
 
 
 
 
 
235
  def report_markdown(rec: RunRecord | None) -> str:
236
  if rec is None:
237
  return "_Run a backtest to generate the report._"
 
 
 
238
  r, req = rec.result, rec.request
239
  a, o, h = r.metrics_all, r.metrics_oos, r.metrics_holdout
240
- bench = float(r.benchmark_equity.iloc[-1] / r.benchmark_equity.iloc[0] - 1.0) \
241
- if len(r.benchmark_equity) else 0.0
242
  ratio = (o.sharpe / r.metrics_is.sharpe) if r.metrics_is.sharpe else float("nan")
243
  grade, checks = runtime.overfit_verdict(rec)
244
 
245
  lines = [
246
  f"### {rec.label}",
247
- f"`RUN {rec.run_id} · {rec.created_at} · "
248
- f"{req.validation_mode.upper()} · COSTS {'ON' if req.costs_on else 'OFF'}`",
249
- "",
250
  f"Over {a.bars} bars and {a.trade_count} trades the strategy returns "
251
  f"**{pct(a.total_return)}** (CAGR {pct(a.cagr)}, Sharpe {num(a.sharpe)}) "
252
  f"against **{pct(bench)}** for buy and hold, with a maximum drawdown of "
253
  f"{pct(a.max_drawdown)}. Modelled costs of {money(r.costs_paid)} are already "
254
- f"deducted — the costed number is the real one.",
255
- "",
256
  (f"Out-of-sample Sharpe is {num(o.sharpe)}, which is {num(ratio)} of the "
257
  f"in-sample figure." if o.bars else
258
  "**No out-of-sample period was produced for this configuration**, so every "
259
  "number above is in-sample. Widen the date range or shorten the training "
260
- "window before reading anything into it.")
261
- + (f" On the locked holdout — {h.bars} bars that no parameter choice ever "
262
- f"touched — it returns {pct(h.total_return)} at Sharpe {num(h.sharpe)}."
263
- if h else ""),
264
- "",
265
- f"**Verdict: {grade}**",
266
  "",
 
 
 
267
  ]
268
  lines += [f"- {mark} {text}" for mark, text in checks]
269
  lines += ["", "#### Config snapshot", "```json",
270
- _pretty_config(req), "```"]
271
  return "\n".join(lines)
272
 
273
 
274
- def _pretty_config(req: RunRequest) -> str:
275
- import json
276
- return json.dumps(asdict(req), indent=2, sort_keys=True)
277
-
278
-
279
- # --------------------------------------------------------------------------
280
- # Handlers
281
- # --------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
282
 
283
 
284
  def collect_request(strategy, asset, timeframe, date_range, model_slug,
@@ -289,7 +173,7 @@ def collect_request(strategy, asset, timeframe, date_range, model_slug,
289
  preset = strategies.PRESETS.get(strategy)
290
  params = {}
291
  if preset:
292
- for (key, _label, _d, _lo, _hi), value in zip(preset.params, (p1, p2, p3)):
293
  if value is not None:
294
  params[key] = value
295
  return RunRequest(
@@ -298,8 +182,8 @@ def collect_request(strategy, asset, timeframe, date_range, model_slug,
298
  costs_on=bool(costs_on), commission_bps=float(commission_bps),
299
  slippage_bps=float(slippage_bps),
300
  slippage_model="volume_scaled" if slippage_model == "Volume-scaled" else "fixed",
301
- sizing_mode={"Fixed %": "fixed_pct", "Vol-target 15% ann.": "vol_target"}.get(
302
- sizing_mode, "fixed_pct"),
303
  size_pct=float(size_pct), leverage=float(leverage),
304
  sl_pct=(float(sl_pct) / 100.0 if sl_pct else None),
305
  tp_pct=(float(tp_pct) / 100.0 if tp_pct else None),
@@ -312,71 +196,78 @@ def collect_request(strategy, asset, timeframe, date_range, model_slug,
312
  )
313
 
314
 
 
 
 
 
 
315
  def build_app() -> gr.Blocks:
316
- css = theme.full_css()
 
 
 
 
317
 
318
- with gr.Blocks(theme=theme.bit_theme(), css=css, title="Bit · Backtest Lab",
319
- analytics_enabled=False, fill_height=True) as demo:
 
320
 
321
- history = gr.State([]) # list[RunRecord]
322
- selected = gr.State([]) # run_ids chosen for comparison
323
- current = gr.State(None) # RunRecord
324
 
325
- top_bar = gr.HTML(top_bar_html())
326
 
327
  with gr.Row(equal_height=False):
328
- # ============================ LEFT ============================
329
- with gr.Column(scale=2, min_width=280):
330
- gr.HTML('<div class="bit-h2" style="padding:12px 4px 4px">Strategy Builder</div>')
331
 
332
  with gr.Accordion("1 · STRATEGY", open=True, elem_classes="bit-accordion"):
333
- strategy = gr.Dropdown(
334
- choices=[p.name for p in strategies.PRESETS.values()],
335
- value="SMA Crossover", label="Preset", interactive=True,
336
- )
337
  preset_note = gr.HTML("")
338
  p1 = gr.Number(label="Fast MA", value=20, precision=4)
339
  p2 = gr.Number(label="Slow MA", value=50, precision=4)
340
  p3 = gr.Number(label="—", value=None, visible=False, precision=4)
341
- model_slug = gr.Dropdown(
342
- choices=runtime.available_models(), value=None,
343
- label="Forecast model", visible=False, interactive=True,
344
- )
345
-
346
- with gr.Accordion("2 · UNIVERSE & DATA", open=True, elem_classes="bit-accordion"):
347
- asset = gr.Dropdown(choices=runtime.available_assets(),
348
- value="BTC-USD", label="Asset")
349
- timeframe = gr.Radio(choices=list(config.TIMEFRAMES),
350
- value="1d", label="Timeframe")
351
- date_range = gr.Radio(choices=["1Y", "3Y", "5Y", "Max"],
352
- value="3Y", label="Date range")
353
  coverage_note = gr.HTML("")
354
 
355
- with gr.Accordion("3 · COSTS & EXECUTION", open=False, elem_classes="bit-accordion"):
 
356
  costs_on = gr.Checkbox(value=True, label="Costs on")
357
- gr.HTML('<div class="bit-note">Costs on. Turning these off is how '
358
- 'strategies lie to you.</div>')
359
  commission_bps = gr.Number(value=10.0, label="Commission bps / side")
360
  slippage_bps = gr.Number(value=5.0, label="Slippage bps")
361
- slippage_model = gr.Radio(choices=["Fixed bps", "Volume-scaled"],
362
  value="Fixed bps", label="Slippage model")
363
- gr.Radio(choices=["Next bar open"], value="Next bar open",
364
- label="Fill", interactive=False,
365
  info="Next-bar-open execution is enforced by the engine.")
366
 
367
- with gr.Accordion("4 · SIZING & RISK", open=False, elem_classes="bit-accordion"):
368
- sizing_mode = gr.Radio(choices=["Fixed %", "Vol-target 15% ann."],
 
369
  value="Fixed %", label="Sizing")
370
  size_pct = gr.Slider(0.05, 1.0, value=1.0, step=0.05,
371
- label="Position size (fraction of equity)")
372
  leverage = gr.Slider(1.0, 3.0, value=1.0, step=0.5, label="Leverage")
373
  sl_pct = gr.Number(value=None, label="Stop loss %")
374
  tp_pct = gr.Number(value=None, label="Take profit %")
375
  trail_pct = gr.Number(value=None, label="Trailing stop %")
376
 
377
- with gr.Accordion("5 · VALIDATION", open=False, elem_classes="bit-accordion"):
 
378
  validation_mode = gr.Radio(
379
- choices=["Walk-forward", "Simple split", "Holdout only", "None"],
380
  value="Walk-forward", label="Mode")
381
  train_m = gr.Number(value=12, label="Train months", precision=0)
382
  test_m = gr.Number(value=3, label="Test months", precision=0)
@@ -393,94 +284,167 @@ def build_app() -> gr.Blocks:
393
  share_out = gr.Textbox(label="Share token", visible=False,
394
  show_copy_button=True, lines=2)
395
 
396
- # =========================== CENTER ===========================
397
- with gr.Column(scale=7, min_width=520):
398
- run_status = gr.HTML("")
399
  stat_band = gr.HTML("")
400
- empty_state = gr.HTML(EMPTY_HTML)
401
 
402
  with gr.Tabs():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  with gr.Tab("Overview"):
404
- equity_plot = gr.Plot(label=None)
 
405
  with gr.Row():
406
  log_scale = gr.Checkbox(value=False, label="Log scale")
407
  cvd = gr.Checkbox(value=False, label="Colorblind-safe prices")
408
- regime_plot = gr.Plot(label=None)
409
  with gr.Row():
410
- underwater_plot = gr.Plot(label=None)
411
- rolling_plot = gr.Plot(label=None)
412
- price_plot = gr.Plot(label=None)
413
  with gr.Row():
414
- pnl_plot = gr.Plot(label=None)
415
- hold_plot = gr.Plot(label=None)
416
- mae_plot = gr.Plot(label=None)
417
  costs_note = gr.HTML("")
418
 
419
  with gr.Tab("Trades"):
420
- trades_head = gr.HTML("")
421
- trades_table = gr.Dataframe(
422
- value=pd.DataFrame(), interactive=False, wrap=False,
423
- elem_classes="bit-table", max_height=520,
424
- )
425
  export_btn = gr.Button("Export CSV →", size="sm",
426
  elem_classes="bit-ghost-btn")
427
  export_file = gr.File(label="trades.csv", visible=False)
428
 
429
- with gr.Tab("Comparison"):
430
- gr.HTML('<div class="bit-micro">CELL = OOS SHARPE · '
431
- 'PRECOMPUTED FROM THE SIGNAL STORE</div>')
432
- heatmap_plot = gr.Plot(label=None)
433
- gr.HTML('<div class="bit-micro">SELECTED RUNS · '
434
- f'MAX {MAX_COMPARE}</div>')
435
- compare_picker = gr.CheckboxGroup(choices=[], value=[],
436
- label="Runs to compare")
437
- overlay_plot = gr.Plot(label=None)
438
- small_mult_plot = gr.Plot(label=None)
439
- with gr.Row():
440
- corr_plot = gr.Plot(label=None)
441
- regime_bars_plot = gr.Plot(label=None)
442
- metrics_table = gr.Dataframe(value=pd.DataFrame(),
443
- interactive=False,
444
- elem_classes="bit-table")
445
-
446
  with gr.Tab("Robustness"):
447
  verdict_html = gr.HTML("")
448
  with gr.Row():
449
- wf_plot = gr.Plot(label=None)
450
- mc_plot = gr.Plot(label=None)
 
 
451
  with gr.Row():
452
- sens_plot = gr.Plot(label=None)
453
- slip_plot = gr.Plot(label=None)
454
- robust_btn = gr.Button(
455
- "Run sensitivity + slippage stress (slower)",
456
- size="sm", elem_classes="bit-ghost-btn")
457
 
458
  with gr.Tab("Report"):
459
  report_md = gr.Markdown("_Run a backtest to generate the report._")
460
- report_equity = gr.Plot(label=None)
461
  with gr.Row():
462
- save_run_btn = gr.Button("Save run summary", size="sm",
463
  elem_classes="bit-ghost-btn")
464
  copy_cfg_btn = gr.Button("Copy share link", size="sm",
465
  elem_classes="bit-ghost-btn")
466
  save_note = gr.HTML("")
467
 
468
  with gr.Tab("Coverage"):
469
- gr.HTML('<div class="bit-micro">SIGNAL STORE COVERAGE MAP · '
470
- 'MODEL × ASSET × TIMEFRAME</div>')
471
- coverage_table = gr.Dataframe(
472
- value=runtime.coverage_frame(), interactive=False,
473
- elem_classes="bit-table", max_height=420)
474
- gr.HTML('<div class="bit-micro" style="margin-top:8px">'
475
- 'EXTEND COVERAGE</div>')
476
  extend_panel = gr.HTML("")
477
  with gr.Row():
478
- ext_model = gr.Dropdown(choices=list(config.SEED_MODELS),
479
- label="Model", scale=2)
480
- ext_asset = gr.Dropdown(choices=list(config.ASSETS),
481
- label="Asset", scale=2)
482
- ext_tf = gr.Dropdown(choices=list(config.TIMEFRAMES),
483
- value="1d", label="Timeframe", scale=1)
484
  with gr.Row():
485
  ext_start = gr.Textbox(label="Start (YYYY-MM-DD)", scale=2)
486
  ext_end = gr.Textbox(label="End (YYYY-MM-DD)", scale=2)
@@ -490,169 +454,136 @@ def build_app() -> gr.Blocks:
490
  extend_btn = gr.Button("Extend coverage", size="sm",
491
  elem_classes="bit-run-btn")
492
  extend_out = gr.HTML("")
493
-
494
- gr.HTML('<div class="bit-micro" style="margin-top:16px">'
495
- 'ADD MODEL</div>')
496
  with gr.Row():
497
  add_family = gr.Dropdown(
498
- choices=list(config.ALLOWED_ADAPTER_FAMILIES),
499
- value="chronos", label="Adapter family", scale=1)
500
  add_model_id = gr.Textbox(label="HF model id (owner/name)",
501
  scale=2)
502
  add_btn = gr.Button("Smoke test & add", size="sm",
503
  elem_classes="bit-ghost-btn", scale=1)
504
  add_out = gr.HTML("")
505
 
506
- # ============================ RIGHT ===========================
507
- with gr.Column(scale=2, min_width=250):
508
- gr.HTML('<div class="bit-h2" style="padding:12px 4px 4px">Run Manager</div>')
509
- login_slot = gr.HTML("")
510
  try:
511
  gr.LoginButton(value="Sign in with Hugging Face", size="sm")
512
  except Exception:
513
- login_slot.value = (
514
- '<div class="bit-note">Sign-in appears when the Space runs '
515
- 'on Hugging Face with OAuth enabled.</div>')
516
- history_html = gr.HTML(
517
- '<div class="bit-micro">NO RUNS YET IN THIS SESSION</div>')
518
  with gr.Accordion("METRICS GLOSSARY", open=False,
519
  elem_classes="bit-accordion"):
520
- gr.HTML(glossary_html())
521
  with gr.Accordion("HOW RESULTS ARE COMPUTED", open=False,
522
  elem_classes="bit-accordion"):
523
- gr.Markdown(
524
- "- Fills execute at the **next bar's open**. A decision at bar "
525
- "`t` can never trade at bar `t`.\n"
526
- "- Strategies are checked for lookahead by perturbing future "
527
- "prices and asserting past outputs do not move.\n"
528
- "- Costs are **on by default**: commission per side plus a "
529
- "slippage model, both booked onto every trade row.\n"
530
- "- The locked holdout is excluded from every parameter-selection "
531
- "path, not merely reported separately.\n"
532
- "- The store holds raw model outputs only. Trading rules are "
533
- "applied live, per run."
534
- )
535
-
536
- gr.HTML(FOOTER_HTML)
537
-
538
- # ------------------------------------------------------------------
539
- # Wiring
540
- # ------------------------------------------------------------------
541
-
542
- builder_inputs = [strategy, asset, timeframe, date_range, model_slug,
543
- p1, p2, p3, costs_on, commission_bps, slippage_bps,
544
- slippage_model, sizing_mode, size_pct, leverage,
545
- sl_pct, tp_pct, trail_pct,
546
- validation_mode, train_m, test_m, roll_m, holdout_m]
547
-
548
- overview_outputs = [equity_plot, regime_plot, underwater_plot, rolling_plot,
549
- price_plot, pnl_plot, hold_plot, mae_plot, costs_note]
550
 
551
  def on_strategy_change(name):
552
  preset = strategies.PRESETS.get(name)
553
  if preset is None:
554
- return (gr.update(), gr.update(), gr.update(), gr.update(), "")
555
- if not preset.available:
556
- note = (f'<div class="bit-note bit-note-danger">{name} is unavailable. '
557
- f'{preset.unavailable_reason}</div>')
558
- else:
559
- note = ""
560
  ups = []
561
  for i in range(3):
562
  if i < len(preset.params):
563
- key, label, default, lo, hi = preset.params[i]
564
  ups.append(gr.update(label=label, value=default, visible=True))
565
  else:
566
  ups.append(gr.update(visible=False, value=None))
567
- model_up = gr.update(visible=preset.needs_signals,
568
- choices=runtime.available_models(),
569
- value=(runtime.available_models() or [None])[0]
570
- if preset.needs_signals else None)
571
- return (*ups, model_up, note)
572
 
573
- strategy.change(on_strategy_change, [strategy], [p1, p2, p3, model_slug, preset_note])
 
574
 
575
  def on_universe_change(a, tf):
576
  cov = runtime.price_coverage_for(a, tf)
577
  models = runtime.available_models(a, tf)
578
  if cov is None:
579
- html = ('<div class="bit-note bit-note-danger">No cached price coverage '
580
- f'for {a} {tf}. Pick another pair or extend coverage.</div>')
581
  else:
582
- html = (f'<div class="bit-micro">CACHED {cov[0]} → {cov[1]}'
583
- + (f' · MODELS: {", ".join(models)}' if models else
584
- ' · NO MODEL SIGNALS') + '</div>')
585
- return html, gr.update(choices=models,
586
- value=(models[0] if models else None))
587
 
588
  asset.change(on_universe_change, [asset, timeframe], [coverage_note, model_slug])
589
- timeframe.change(on_universe_change, [asset, timeframe], [coverage_note, model_slug])
 
590
 
591
  def do_run(hist, *vals, progress=gr.Progress()):
592
  progress(0.05, desc="Reading cached slices")
593
  req = collect_request(*vals)
594
  try:
595
- progress(0.35, desc="Simulating trades")
596
  rec = runtime.execute(req)
597
  except (RunError, ValueError) as e:
598
- err = f'<div class="bit-note bit-note-danger">{e}</div>'
599
- return (hist, None, top_bar_html("RUN FAILED", "bit-chip bit-chip-warn"),
600
- err, "", gr.update(visible=True), *(gr.update(),) * 9,
601
- "", pd.DataFrame(), "", gr.update(choices=[], value=[]),
602
  "_Run failed._", None)
603
 
604
- progress(0.75, desc="Building charts")
605
  hist = ([rec] + list(hist))[:40]
606
- figs = build_overview(rec, log_scale=False, cvd=False)
607
- progress(0.95, desc="Computing robustness")
608
-
609
- choices = [f"{r.run_id} · {r.label}" for r in hist]
610
- return (
611
- hist, rec,
612
- top_bar_html(f"RUN {rec.run_id} COMPLETE · {rec.elapsed_s:.1f}S",
613
- "bit-chip bit-chip-ok", context_chip(req)),
614
- "", stat_band_html(rec), gr.update(visible=False),
615
- *figs,
616
- trades_head_html(rec), trades_frame(rec),
617
- history_list_html(hist),
618
- gr.update(choices=choices, value=choices[:1]),
619
- report_markdown(rec),
620
- charts.equity_curve(rec.result.equity, rec.result.benchmark_equity,
621
- plan=rec.result.plan),
622
- )
623
-
624
- run_outputs = [history, current, top_bar, run_status, stat_band, empty_state,
625
- *overview_outputs, trades_head, trades_table, history_html,
626
- compare_picker, report_md, report_equity]
627
-
628
- run_btn.click(do_run, [history, *builder_inputs], run_outputs)
629
 
630
  def load_example():
631
- """The design's worked example: Sentiment-Gated Momentum on BTC 1h."""
632
- return ("Sentiment-Gated Momentum", "BTC-USD", "1h", "1Y",
633
- gr.update(value=20, label="Fast MA", visible=True),
634
- gr.update(value=50, label="Slow MA", visible=True),
635
- gr.update(value=0.40, label="Sentiment gate", visible=True),
 
636
  True, "Walk-forward")
637
 
638
  example_btn.click(
639
  load_example, None,
640
- [strategy, asset, timeframe, date_range, p1, p2, p3, costs_on, validation_mode],
641
- ).then(do_run, [history, *builder_inputs], run_outputs)
 
642
 
643
  def replot(rec, log_s, colorblind):
644
  if rec is None:
645
  return (gr.update(),) * 9
646
  return build_overview(rec, log_scale=log_s, cvd=colorblind)
647
 
648
- log_scale.change(replot, [current, log_scale, cvd], overview_outputs)
649
- cvd.change(replot, [current, log_scale, cvd], overview_outputs)
650
-
651
- def do_share(*vals):
652
- req = collect_request(*vals)
653
- return gr.update(value=req.encode(), visible=True)
654
 
655
- share_btn.click(do_share, builder_inputs, [share_out])
 
656
  copy_cfg_btn.click(lambda r: gr.update(value=r.request.encode(), visible=True)
657
  if r else gr.update(), [current], [share_out])
658
 
@@ -667,76 +598,29 @@ def build_app() -> gr.Blocks:
667
 
668
  def do_save(rec):
669
  if rec is None:
670
- return '<div class="bit-note">Nothing to save yet.</div>'
671
  try:
672
- rid = runtime.save_run_summary(rec)
673
- return (f'<div class="bit-note">Run <b>{rid}</b> staged for the store. '
674
- f'It is committed with the next batch.</div>')
675
  except Exception as e:
676
- return f'<div class="bit-note bit-note-danger">Could not save: {e}</div>'
677
 
678
  save_run_btn.click(do_save, [current], [save_note])
679
 
680
- def do_compare(hist, picks):
681
- hist = list(hist or [])
682
- by_id = {f"{r.run_id} · {r.label}": r for r in hist}
683
- chosen = [by_id[p] for p in (picks or [])[:MAX_COMPARE] if p in by_id]
684
- if not chosen:
685
- empty = charts.empty_figure("select runs to compare")
686
- return empty, empty, empty, empty, pd.DataFrame()
687
-
688
- curves = {r.label[:28]: r.result.equity for r in chosen}
689
- rets = {r.label[:28]: r.result.equity.pct_change().dropna() for r in chosen}
690
- regimes = [runtime.regime_breakdown(r) for r in chosen]
691
- regime_df = regimes[0] if regimes else pd.DataFrame()
692
- for extra in regimes[1:]:
693
- if not extra.empty and not regime_df.empty:
694
- regime_df = regime_df.merge(extra, on="regime", how="outer")
695
-
696
- rows = []
697
- for label, key in (("Total return", "total_return"), ("CAGR", "cagr"),
698
- ("OOS Sharpe", None), ("Max drawdown", "max_drawdown"),
699
- ("Win rate", "win_rate"), ("Trades", "trade_count")):
700
- row = {"Metric": label}
701
- for r in chosen:
702
- if key is None:
703
- row[r.label[:22]] = num(r.result.metrics_oos.sharpe)
704
- elif key == "trade_count":
705
- row[r.label[:22]] = r.result.metrics_all.trade_count
706
- elif key == "win_rate":
707
- row[r.label[:22]] = pct(r.result.metrics_all.win_rate, 0, False)
708
- else:
709
- row[r.label[:22]] = pct(getattr(r.result.metrics_all, key))
710
- rows.append(row)
711
-
712
- return (charts.overlaid_returns(curves),
713
- charts.small_multiples(curves),
714
- charts.correlation_matrix(rets),
715
- charts.regime_bars(regime_df),
716
- pd.DataFrame(rows))
717
-
718
- compare_picker.change(do_compare, [history, compare_picker],
719
- [overlay_plot, small_mult_plot, corr_plot,
720
- regime_bars_plot, metrics_table])
721
-
722
  def do_robustness(rec):
723
  if rec is None:
724
  e = charts.empty_figure("run a backtest first")
725
- return "", e, e, e, e
726
  grade, checks = runtime.overfit_verdict(rec)
727
- items = "".join(
728
- f'<div style="font-size:11px;color:var(--text-secondary)">{m} {t}</div>'
729
- for m, t in checks)
730
- html = (f'<div class="bit-panel"><div class="bit-h2">Overfit verdict: '
731
- f'<span style="color:var(--accent-amber-strong)">{grade}</span></div>'
732
  f'{items}</div>')
733
- wf = charts.walk_forward_bars(rec.result.windows)
734
- mc = charts.monte_carlo_cone(charts.monte_carlo_paths(rec.result.trades))
735
- e = charts.empty_figure("press the button below to run the sweep")
736
- return html, wf, mc, e, e
737
 
738
- current.change(do_robustness, [current],
739
- [verdict_html, wf_plot, mc_plot, sens_plot, slip_plot])
740
 
741
  def do_sweep(rec, progress=gr.Progress()):
742
  if rec is None:
@@ -749,37 +633,83 @@ def build_app() -> gr.Blocks:
749
  sens = charts.empty_figure("this preset has fewer than two parameters")
750
  else:
751
  progress(0.1, desc="Parameter sweep")
752
- base_x = req.params.get(keys[0], 20)
753
- base_y = req.params.get(keys[1], 50)
754
- xs = sorted({max(2, int(base_x * m)) for m in (0.5, 0.75, 1.0, 1.5, 2.0)})
755
- ys = sorted({max(3, int(base_y * m)) for m in (0.5, 0.75, 1.0, 1.5, 2.0)})
756
- grid = runtime.parameter_sweep(req, keys[0], xs, keys[1], ys)
757
- sens = charts.parameter_sensitivity(grid, x=keys[0], y=keys[1])
 
758
  progress(0.7, desc="Slippage stress")
759
- slip = charts.slippage_stress(runtime.slippage_stress(req))
760
- return sens, slip
761
 
762
  robust_btn.click(do_sweep, [current], [sens_plot, slip_plot])
763
 
764
- # ---- Coverage / extension (Phase 4) ----
765
- from src import extension
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
766
 
767
- estimate_btn.click(extension.estimate_ui, [ext_model, ext_asset, ext_tf,
768
- ext_start, ext_end], [extend_out])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
769
  extend_btn.click(extension.extend_ui,
770
  [ext_model, ext_asset, ext_tf, ext_start, ext_end],
771
  [extend_out, coverage_table])
772
  add_btn.click(extension.add_model_ui, [add_family, add_model_id],
773
  [add_out, coverage_table])
774
 
775
- # ---- Load: heatmap, coverage, share-link restore ----
776
  def on_load(request: gr.Request):
777
- store = runtime.get_store()
778
- heat = comparisons.load_table(store, comparisons.HEATMAP)
779
- fig = charts.strategy_timeframe_heatmap(heat)
780
- cov_html = ""
 
 
 
 
 
 
 
 
 
 
 
 
781
  restored = [gr.update()] * 4
782
- token = None
783
  try:
784
  token = dict(request.query_params).get("cfg") if request else None
785
  except Exception:
@@ -790,82 +720,28 @@ def build_app() -> gr.Blocks:
790
  restored = [gr.update(value=req.strategy), gr.update(value=req.asset),
791
  gr.update(value=req.timeframe),
792
  gr.update(value=req.date_range)]
793
- cov_html = '<div class="bit-note">Config restored from share link.</div>'
794
  except Exception as e:
795
- cov_html = (f'<div class="bit-note bit-note-danger">'
796
- f'Share link rejected: {e}</div>')
797
- return (fig, runtime.coverage_frame(), extension.status_html(),
798
- cov_html, *restored)
 
 
799
 
800
  demo.load(on_load, None,
801
- [heatmap_plot, coverage_table, extend_panel, run_status,
 
802
  strategy, asset, timeframe, date_range])
803
-
804
  demo.load(lambda: on_universe_change("BTC-USD", "1d"), None,
805
  [coverage_note, model_slug])
806
 
807
  return demo
808
 
809
 
810
- def trades_head_html(rec: RunRecord | None) -> str:
811
- if rec is None or rec.result.trades.empty:
812
- return '<div class="bit-micro">NO TRADES</div>'
813
- n = len(rec.result.trades)
814
- costs = rec.result.costs_paid
815
- return (f'<div class="bit-micro">{n} TOTAL · COSTS PAID {money(costs)} · '
816
- f'FILLS AT NEXT BAR OPEN</div>')
817
-
818
-
819
- def history_list_html(hist) -> str:
820
- if not hist:
821
- return '<div class="bit-micro">NO RUNS YET IN THIS SESSION</div>'
822
- rows = []
823
- for r in hist[:12]:
824
- s = r.sharpe
825
- color = ("var(--accent-moss-strong)" if s >= 1
826
- else "var(--fin-down)" if s < 0 else "var(--text-secondary)")
827
- rows.append(
828
- f'<div style="border:1px solid var(--border-subtle);padding:6px;margin-bottom:4px">'
829
- f'<div style="font-size:11px;color:var(--text-primary)">{r.label[:34]}</div>'
830
- f'<div class="bit-micro">{r.meta}</div>'
831
- f'<div style="font-family:var(--font-mono);font-size:12px;color:{color}">'
832
- f'SHARPE {s:.2f}</div></div>'
833
- )
834
- return "".join(rows)
835
-
836
-
837
- def build_overview(rec: RunRecord, *, log_scale: bool, cvd: bool):
838
- r = rec.result
839
- bpy = config.bars_per_year(rec.request.asset, rec.request.timeframe)
840
- window = {"1d": 90, "1h": 24 * 30, "15m": 4 * 24 * 14}.get(rec.request.timeframe, 90)
841
-
842
- costs_html = (
843
- f'<div class="bit-note">COSTS PAID TOTAL: {money(r.costs_paid)}. '
844
- f'The costed number is the real one.</div>'
845
- if rec.request.costs_on else
846
- '<div class="bit-note bit-note-danger">COSTS ARE OFF. '
847
- 'These numbers are not achievable.</div>'
848
- )
849
- return (
850
- charts.equity_curve(r.equity, r.benchmark_equity, plan=r.plan,
851
- log_scale=log_scale, cvd=cvd),
852
- charts.regime_strip(r.prices),
853
- charts.underwater_chart(r.equity),
854
- charts.rolling_sharpe_chart(r.equity, window, bpy),
855
- charts.price_with_trades(r.prices, r.trades, cvd=cvd),
856
- charts.pnl_histogram(r.trades, cvd=cvd),
857
- charts.holding_period_histogram(r.trades),
858
- charts.mae_mfe_scatter(r.trades, cvd=cvd),
859
- costs_html,
860
- )
861
-
862
-
863
  demo = build_app()
864
 
865
  if __name__ == "__main__":
866
  gr.set_static_paths(paths=theme.static_paths())
867
  demo.queue(max_size=32).launch(
868
- server_name="0.0.0.0",
869
- server_port=int(os.environ.get("PORT", 7860)),
870
- show_api=False,
871
- )
 
1
  """Bit Trading Company — Backtest Lab.
2
 
3
+ Layout follows the design: Strategy Builder on the left, tabbed Results Canvas
4
+ in the centre, Run Manager on the right, disclaimer pinned to the footer.
5
+
6
+ This module is layout and wiring only. Formatting lives in `src/ui/format.py`,
7
+ markup in `src/ui/components.py`, the global comparison in
8
+ `src/ui/compare_tab.py`, and every number in `src/metrics.py`.
9
 
10
  The app reads exclusively from the cached signal store. The only path that can
11
  reach an external provider is the batch refresh in `scripts/seed_store.py`, and
12
+ the only path that runs inference is the ZeroGPU flow in `src/extension.py`,
13
+ which spends the signed-in user's own quota.
14
  """
15
 
16
  from __future__ import annotations
17
 
18
  import logging
19
  import os
 
20
 
21
  import gradio as gr
22
  import pandas as pd
23
 
24
+ from src import catalog, charts, config, extension, runtime, strategies
25
  from src.runtime import RunError, RunRecord, RunRequest
26
+ from src.ui import components as C
27
+ from src.ui import compare_tab as CT
28
  from src.ui import theme
29
+ from src.ui.format import EM, count, money, num, pct
30
 
31
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
32
  log = logging.getLogger("bit.app")
33
 
34
  MAX_COMPARE = 6
35
+
36
  GLOSSARY = [
37
  ("SHARPE", "Annualized mean excess return divided by return volatility. "
38
  "Above 1 is good; above 3 usually means a bug."),
 
43
  ("MAE / MFE", "Worst and best unrealized excursion while the trade was open."),
44
  ("WALK-FORWARD", "Train on a rolling window, test on the next unseen window, repeat."),
45
  ("OOS", "Out of sample: data the parameters never saw during fitting."),
46
+ ("BASELINE", "A naive forecast (random walk, drift, seasonal). If a learned "
47
+ "model cannot beat these, it has not earned its inference cost."),
48
  ]
49
 
50
+ METHOD_NOTES = """
51
+ - Fills execute at the **next bar's open**. A decision at bar `t` can never trade at bar `t`.
52
+ - Strategies are checked for lookahead by perturbing future prices up *and* down
53
+ and asserting past outputs do not move.
54
+ - Costs are **on by default**: commission per side plus a slippage model, both
55
+ booked onto every trade row. `gross − costs = net` holds exactly.
56
+ - The locked holdout is excluded from every parameter-selection path, not merely
57
+ reported separately.
58
+ - The store holds raw model outputs only. Trading rules are applied live, per run.
59
+ - Catalog rows all use one canonical config (costs on, walk-forward 12/3/3,
60
+ 6-month holdout) so they are comparable with each other.
61
+ """
62
+
63
 
64
  # --------------------------------------------------------------------------
65
+ # Presentation helpers
66
  # --------------------------------------------------------------------------
67
 
68
 
69
+ def context_chip_text(req: RunRequest) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  mode = {"walk_forward": "WALK-FORWARD", "holdout": "HOLDOUT",
71
  "split": "SPLIT", "none": "NO SPLIT"}.get(req.validation_mode, "")
72
  try:
 
74
  span = f"{s.date()} → {e.date()}"
75
  except Exception:
76
  span = req.date_range
77
+ return f"{req.asset} · {req.timeframe.upper()} · {span} · {mode}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
 
80
  def trades_frame(rec: RunRecord | None) -> pd.DataFrame:
 
88
  "Entry": t["entry_ts"].dt.strftime("%Y-%m-%d %H:%M"),
89
  "Exit": t["exit_ts"].dt.strftime("%Y-%m-%d %H:%M"),
90
  "Side": t["side"].str.upper(),
91
+ "Entry px": t["entry_px"].round(2), "Exit px": t["exit_px"].round(2),
 
92
  "Size": t["size"].round(4),
93
+ "Gross": t["gross_pnl"].round(2), "Costs": t["costs"].round(2),
94
+ "Net": t["net_pnl"].round(2), "R": t["r_multiple"].round(2),
95
+ "Bars": t["duration_bars"], "MAE": (t["mae"] * 100).round(1),
96
+ "Segment": t["segment"], "Trigger": t["trigger"],
 
 
 
 
97
  })
98
 
99
 
100
+ def trades_head(rec: RunRecord | None) -> str:
101
+ if rec is None or rec.result.trades.empty:
102
+ return C.micro("no trades")
103
+ return C.micro(f"{len(rec.result.trades)} total · costs paid "
104
+ f"{money(rec.result.costs_paid)} · fills at next bar open")
105
+
106
+
107
  def report_markdown(rec: RunRecord | None) -> str:
108
  if rec is None:
109
  return "_Run a backtest to generate the report._"
110
+ import json
111
+ from dataclasses import asdict
112
+
113
  r, req = rec.result, rec.request
114
  a, o, h = r.metrics_all, r.metrics_oos, r.metrics_holdout
115
+ bench = (float(r.benchmark_equity.iloc[-1] / r.benchmark_equity.iloc[0] - 1.0)
116
+ if len(r.benchmark_equity) else float("nan"))
117
  ratio = (o.sharpe / r.metrics_is.sharpe) if r.metrics_is.sharpe else float("nan")
118
  grade, checks = runtime.overfit_verdict(rec)
119
 
120
  lines = [
121
  f"### {rec.label}",
122
+ f"`RUN {rec.run_id} · {rec.created_at} · {req.validation_mode.upper()} · "
123
+ f"COSTS {'ON' if req.costs_on else 'OFF'}`", "",
 
124
  f"Over {a.bars} bars and {a.trade_count} trades the strategy returns "
125
  f"**{pct(a.total_return)}** (CAGR {pct(a.cagr)}, Sharpe {num(a.sharpe)}) "
126
  f"against **{pct(bench)}** for buy and hold, with a maximum drawdown of "
127
  f"{pct(a.max_drawdown)}. Modelled costs of {money(r.costs_paid)} are already "
128
+ f"deducted — the costed number is the real one.", "",
 
129
  (f"Out-of-sample Sharpe is {num(o.sharpe)}, which is {num(ratio)} of the "
130
  f"in-sample figure." if o.bars else
131
  "**No out-of-sample period was produced for this configuration**, so every "
132
  "number above is in-sample. Widen the date range or shorten the training "
133
+ "window before reading anything into it."),
 
 
 
 
 
134
  "",
135
+ (f"On the locked holdout — {h.bars} bars no parameter choice ever touched — "
136
+ f"it returns {pct(h.total_return)} at Sharpe {num(h.sharpe)}." if h else ""),
137
+ "", f"**Verdict: {grade}**", "",
138
  ]
139
  lines += [f"- {mark} {text}" for mark, text in checks]
140
  lines += ["", "#### Config snapshot", "```json",
141
+ json.dumps(asdict(req), indent=2, sort_keys=True), "```"]
142
  return "\n".join(lines)
143
 
144
 
145
+ def build_overview(rec: RunRecord, *, log_scale: bool, cvd: bool):
146
+ r = rec.result
147
+ bpy = config.bars_per_year(rec.request.asset, rec.request.timeframe)
148
+ window = {"1d": 90, "1h": 24 * 30, "15m": 4 * 24 * 14}.get(rec.request.timeframe, 90)
149
+ costs_html = (
150
+ C.note(f"<b>COSTS PAID TOTAL: {money(r.costs_paid)}</b>. "
151
+ "The costed number is the real one.")
152
+ if rec.request.costs_on else
153
+ C.note("<b>COSTS ARE OFF.</b> These numbers are not achievable.", danger=True))
154
+ return (
155
+ charts.equity_curve(r.equity, r.benchmark_equity, plan=r.plan,
156
+ log_scale=log_scale, cvd=cvd),
157
+ charts.regime_strip(r.prices),
158
+ charts.underwater_chart(r.equity),
159
+ charts.rolling_sharpe_chart(r.equity, window, bpy),
160
+ charts.price_with_trades(r.prices, r.trades, cvd=cvd),
161
+ charts.pnl_histogram(r.trades, cvd=cvd),
162
+ charts.holding_period_histogram(r.trades),
163
+ charts.mae_mfe_scatter(r.trades, cvd=cvd),
164
+ costs_html,
165
+ )
166
 
167
 
168
  def collect_request(strategy, asset, timeframe, date_range, model_slug,
 
173
  preset = strategies.PRESETS.get(strategy)
174
  params = {}
175
  if preset:
176
+ for (key, _l, _d, _lo, _hi), value in zip(preset.params, (p1, p2, p3)):
177
  if value is not None:
178
  params[key] = value
179
  return RunRequest(
 
182
  costs_on=bool(costs_on), commission_bps=float(commission_bps),
183
  slippage_bps=float(slippage_bps),
184
  slippage_model="volume_scaled" if slippage_model == "Volume-scaled" else "fixed",
185
+ sizing_mode={"Fixed %": "fixed_pct",
186
+ "Vol-target 15% ann.": "vol_target"}.get(sizing_mode, "fixed_pct"),
187
  size_pct=float(size_pct), leverage=float(leverage),
188
  sl_pct=(float(sl_pct) / 100.0 if sl_pct else None),
189
  tp_pct=(float(tp_pct) / 100.0 if tp_pct else None),
 
196
  )
197
 
198
 
199
+ # --------------------------------------------------------------------------
200
+ # App
201
+ # --------------------------------------------------------------------------
202
+
203
+
204
  def build_app() -> gr.Blocks:
205
+ store = runtime.get_store()
206
+ all_assets = runtime.available_assets()
207
+ all_tfs = list(config.TIMEFRAMES)
208
+ all_strategies = [p.name for p in strategies.PRESETS.values()]
209
+ all_models = sorted(config.SEED_MODELS)
210
 
211
+ with gr.Blocks(theme=theme.bit_theme(), css=theme.full_css(),
212
+ title="Bit · Backtest Lab", analytics_enabled=False,
213
+ fill_height=True) as demo:
214
 
215
+ history = gr.State([])
216
+ current = gr.State(None)
 
217
 
218
+ top_bar = gr.HTML(C.top_bar())
219
 
220
  with gr.Row(equal_height=False):
221
+ # ------------------------- LEFT -------------------------
222
+ with gr.Column(scale=2, min_width=270):
223
+ gr.HTML('<div class="bit-zone-title">Strategy Builder</div>')
224
 
225
  with gr.Accordion("1 · STRATEGY", open=True, elem_classes="bit-accordion"):
226
+ strategy = gr.Dropdown(all_strategies, value="SMA Crossover",
227
+ label="Preset", interactive=True)
 
 
228
  preset_note = gr.HTML("")
229
  p1 = gr.Number(label="Fast MA", value=20, precision=4)
230
  p2 = gr.Number(label="Slow MA", value=50, precision=4)
231
  p3 = gr.Number(label="—", value=None, visible=False, precision=4)
232
+ model_slug = gr.Dropdown(runtime.available_models(), value=None,
233
+ label="Forecast model", visible=False)
234
+
235
+ with gr.Accordion("2 · UNIVERSE & DATA", open=True,
236
+ elem_classes="bit-accordion"):
237
+ asset = gr.Dropdown(all_assets, value="BTC-USD", label="Asset")
238
+ timeframe = gr.Radio(all_tfs, value="1d", label="Timeframe")
239
+ date_range = gr.Radio(["1Y", "3Y", "5Y", "Max"], value="3Y",
240
+ label="Date range")
 
 
 
241
  coverage_note = gr.HTML("")
242
 
243
+ with gr.Accordion("3 · COSTS & EXECUTION", open=False,
244
+ elem_classes="bit-accordion"):
245
  costs_on = gr.Checkbox(value=True, label="Costs on")
246
+ gr.HTML(C.note("Costs on. Turning these off is how strategies "
247
+ "lie to you."))
248
  commission_bps = gr.Number(value=10.0, label="Commission bps / side")
249
  slippage_bps = gr.Number(value=5.0, label="Slippage bps")
250
+ slippage_model = gr.Radio(["Fixed bps", "Volume-scaled"],
251
  value="Fixed bps", label="Slippage model")
252
+ gr.Radio(["Next bar open"], value="Next bar open", label="Fill",
253
+ interactive=False,
254
  info="Next-bar-open execution is enforced by the engine.")
255
 
256
+ with gr.Accordion("4 · SIZING & RISK", open=False,
257
+ elem_classes="bit-accordion"):
258
+ sizing_mode = gr.Radio(["Fixed %", "Vol-target 15% ann."],
259
  value="Fixed %", label="Sizing")
260
  size_pct = gr.Slider(0.05, 1.0, value=1.0, step=0.05,
261
+ label="Position size")
262
  leverage = gr.Slider(1.0, 3.0, value=1.0, step=0.5, label="Leverage")
263
  sl_pct = gr.Number(value=None, label="Stop loss %")
264
  tp_pct = gr.Number(value=None, label="Take profit %")
265
  trail_pct = gr.Number(value=None, label="Trailing stop %")
266
 
267
+ with gr.Accordion("5 · VALIDATION", open=False,
268
+ elem_classes="bit-accordion"):
269
  validation_mode = gr.Radio(
270
+ ["Walk-forward", "Simple split", "Holdout only", "None"],
271
  value="Walk-forward", label="Mode")
272
  train_m = gr.Number(value=12, label="Train months", precision=0)
273
  test_m = gr.Number(value=3, label="Test months", precision=0)
 
284
  share_out = gr.Textbox(label="Share token", visible=False,
285
  show_copy_button=True, lines=2)
286
 
287
+ # ------------------------ CENTER ------------------------
288
+ with gr.Column(scale=8, min_width=560):
 
289
  stat_band = gr.HTML("")
 
290
 
291
  with gr.Tabs():
292
+ # ---------------- COMPARE (landing) ----------------
293
+ with gr.Tab("Compare"):
294
+ gr.HTML('<div class="bit-zone-title">Everything this Space '
295
+ 'has computed<span class="bit-micro">CATALOG · '
296
+ 'PRECOMPUTED FROM THE SIGNAL STORE</span></div>')
297
+ catalog_meta = gr.HTML("")
298
+
299
+ with gr.Tabs():
300
+ with gr.Tab("Leaderboard"):
301
+ podium = gr.HTML("")
302
+ with gr.Row():
303
+ f_metric = gr.Dropdown(
304
+ list(CT.RANK_METRICS), value="OOS Sharpe",
305
+ label="Rank by", scale=2)
306
+ f_top = gr.Slider(5, 50, value=15, step=5,
307
+ label="Show top", scale=2)
308
+ f_min_trades = gr.Slider(
309
+ 0, 100, value=0, step=5,
310
+ label="Min trades", scale=2)
311
+ # Collapsed by default: an unfiltered board is the
312
+ # useful default view, and an open wall of chips
313
+ # pushes the table and charts below the fold.
314
+ with gr.Accordion("FILTERS · ALL COMBINATIONS SHOWN",
315
+ open=False,
316
+ elem_classes="bit-accordion"):
317
+ with gr.Row():
318
+ f_assets = gr.CheckboxGroup(all_assets, value=[],
319
+ label="Assets")
320
+ f_tfs = gr.CheckboxGroup(all_tfs, value=[],
321
+ label="Timeframes")
322
+ f_strats = gr.CheckboxGroup(
323
+ list(catalog.CATALOG_STRATEGIES), value=[],
324
+ label="Strategies")
325
+ f_models = gr.CheckboxGroup(all_models, value=[],
326
+ label="Models")
327
+ with gr.Row():
328
+ f_oos = gr.Checkbox(
329
+ value=True, label="Require out-of-sample")
330
+ f_hide_base = gr.Checkbox(
331
+ value=False, label="Hide baseline models")
332
+ reset_btn = gr.Button(
333
+ "Reset filters", size="sm",
334
+ elem_classes="bit-ghost-btn")
335
+ lb_meta = gr.HTML("")
336
+ lb_table = gr.Dataframe(pd.DataFrame(), interactive=False,
337
+ wrap=False, max_height=430,
338
+ elem_classes="bit-table")
339
+ gr.HTML('<div class="bit-zone-title">Returns over time'
340
+ '<span class="bit-micro">TOP RANKED · '
341
+ 'CUMULATIVE, COSTS INCLUDED</span></div>')
342
+ lb_overlay = gr.Plot()
343
+ gr.HTML('<div class="bit-zone-title">Risk vs return'
344
+ '<span class="bit-micro">MARKER AREA = TRADE '
345
+ 'COUNT</span></div>')
346
+ lb_scatter = gr.Plot()
347
+
348
+ with gr.Tab("Models"):
349
+ models_tf = gr.Radio(["all"] + all_tfs, value="1d",
350
+ label="Timeframe")
351
+ models_note = gr.HTML("")
352
+ with gr.Row():
353
+ acc_plot = gr.Plot()
354
+ cal_plot = gr.Plot()
355
+ gr.HTML('<div class="bit-zone-title">Best result per '
356
+ 'model<span class="bit-micro">ACROSS EVERY '
357
+ 'STRATEGY AND ASSET</span></div>')
358
+ model_bars = gr.Plot()
359
+ score_table = gr.Dataframe(pd.DataFrame(),
360
+ interactive=False,
361
+ max_height=380,
362
+ elem_classes="bit-table")
363
+
364
+ with gr.Tab("Signals"):
365
+ with gr.Row():
366
+ sig_asset = gr.Dropdown(all_assets, value="BTC-USD",
367
+ label="Asset")
368
+ sig_tf = gr.Radio(all_tfs, value="1d",
369
+ label="Timeframe")
370
+ sig_panel = gr.HTML("")
371
+ gr.HTML(C.micro(
372
+ "every model's latest stored forecast for this "
373
+ "slice · weighted by realised accuracy"))
374
+
375
+ with gr.Tab("Run history"):
376
+ gr.HTML(C.micro("session runs and runs saved to the "
377
+ "signal store"))
378
+ runs_refresh = gr.Button("Refresh from store", size="sm",
379
+ elem_classes="bit-ghost-btn")
380
+ runs_table = gr.Dataframe(pd.DataFrame(),
381
+ interactive=False,
382
+ max_height=430,
383
+ elem_classes="bit-table")
384
+
385
+ # ---------------- OVERVIEW ----------------
386
  with gr.Tab("Overview"):
387
+ empty_state = gr.HTML(C.empty_state())
388
+ equity_plot = gr.Plot()
389
  with gr.Row():
390
  log_scale = gr.Checkbox(value=False, label="Log scale")
391
  cvd = gr.Checkbox(value=False, label="Colorblind-safe prices")
392
+ regime_plot = gr.Plot()
393
  with gr.Row():
394
+ underwater_plot = gr.Plot()
395
+ rolling_plot = gr.Plot()
396
+ price_plot = gr.Plot()
397
  with gr.Row():
398
+ pnl_plot = gr.Plot()
399
+ hold_plot = gr.Plot()
400
+ mae_plot = gr.Plot()
401
  costs_note = gr.HTML("")
402
 
403
  with gr.Tab("Trades"):
404
+ trades_head_html = gr.HTML("")
405
+ trades_table = gr.Dataframe(pd.DataFrame(), interactive=False,
406
+ wrap=False, max_height=520,
407
+ elem_classes="bit-table")
 
408
  export_btn = gr.Button("Export CSV →", size="sm",
409
  elem_classes="bit-ghost-btn")
410
  export_file = gr.File(label="trades.csv", visible=False)
411
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  with gr.Tab("Robustness"):
413
  verdict_html = gr.HTML("")
414
  with gr.Row():
415
+ wf_plot = gr.Plot()
416
+ mc_plot = gr.Plot()
417
+ robust_btn = gr.Button("Run sensitivity + slippage stress",
418
+ size="sm", elem_classes="bit-ghost-btn")
419
  with gr.Row():
420
+ sens_plot = gr.Plot()
421
+ slip_plot = gr.Plot()
 
 
 
422
 
423
  with gr.Tab("Report"):
424
  report_md = gr.Markdown("_Run a backtest to generate the report._")
425
+ report_equity = gr.Plot()
426
  with gr.Row():
427
+ save_run_btn = gr.Button("Save run to store", size="sm",
428
  elem_classes="bit-ghost-btn")
429
  copy_cfg_btn = gr.Button("Copy share link", size="sm",
430
  elem_classes="bit-ghost-btn")
431
  save_note = gr.HTML("")
432
 
433
  with gr.Tab("Coverage"):
434
+ coverage_kpis = gr.HTML("")
435
+ coverage_table = gr.Dataframe(pd.DataFrame(), interactive=False,
436
+ max_height=380,
437
+ elem_classes="bit-table")
438
+ gr.HTML('<div class="bit-zone-title">Extend coverage'
439
+ '<span class="bit-micro">RUNS ON YOUR OWN GPU QUOTA'
440
+ '</span></div>')
441
  extend_panel = gr.HTML("")
442
  with gr.Row():
443
+ ext_model = gr.Dropdown(all_models, label="Model", scale=2)
444
+ ext_asset = gr.Dropdown(list(config.ASSETS), label="Asset",
445
+ scale=2)
446
+ ext_tf = gr.Dropdown(all_tfs, value="1d",
447
+ label="Timeframe", scale=1)
 
448
  with gr.Row():
449
  ext_start = gr.Textbox(label="Start (YYYY-MM-DD)", scale=2)
450
  ext_end = gr.Textbox(label="End (YYYY-MM-DD)", scale=2)
 
454
  extend_btn = gr.Button("Extend coverage", size="sm",
455
  elem_classes="bit-run-btn")
456
  extend_out = gr.HTML("")
457
+ gr.HTML('<div class="bit-zone-title">Add model</div>')
 
 
458
  with gr.Row():
459
  add_family = gr.Dropdown(
460
+ list(config.ALLOWED_ADAPTER_FAMILIES), value="chronos",
461
+ label="Adapter family", scale=1)
462
  add_model_id = gr.Textbox(label="HF model id (owner/name)",
463
  scale=2)
464
  add_btn = gr.Button("Smoke test & add", size="sm",
465
  elem_classes="bit-ghost-btn", scale=1)
466
  add_out = gr.HTML("")
467
 
468
+ # ------------------------- RIGHT -------------------------
469
+ with gr.Column(scale=2, min_width=240):
470
+ gr.HTML('<div class="bit-zone-title">Run Manager</div>')
 
471
  try:
472
  gr.LoginButton(value="Sign in with Hugging Face", size="sm")
473
  except Exception:
474
+ gr.HTML(C.note("Sign-in appears when this runs on a Space."))
475
+ history_html = gr.HTML(C.micro("no runs yet in this session"))
 
 
 
476
  with gr.Accordion("METRICS GLOSSARY", open=False,
477
  elem_classes="bit-accordion"):
478
+ gr.HTML(C.glossary(GLOSSARY))
479
  with gr.Accordion("HOW RESULTS ARE COMPUTED", open=False,
480
  elem_classes="bit-accordion"):
481
+ gr.Markdown(METHOD_NOTES)
482
+
483
+ gr.HTML(C.footer())
484
+
485
+ # ==================== wiring ====================
486
+
487
+ builder = [strategy, asset, timeframe, date_range, model_slug,
488
+ p1, p2, p3, costs_on, commission_bps, slippage_bps,
489
+ slippage_model, sizing_mode, size_pct, leverage,
490
+ sl_pct, tp_pct, trail_pct,
491
+ validation_mode, train_m, test_m, roll_m, holdout_m]
492
+ overview_out = [equity_plot, regime_plot, underwater_plot, rolling_plot,
493
+ price_plot, pnl_plot, hold_plot, mae_plot, costs_note]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
 
495
  def on_strategy_change(name):
496
  preset = strategies.PRESETS.get(name)
497
  if preset is None:
498
+ return gr.update(), gr.update(), gr.update(), gr.update(), ""
499
+ note = ("" if preset.available else
500
+ C.note(f"<b>{name}</b> is unavailable. {preset.unavailable_reason}",
501
+ danger=True))
 
 
502
  ups = []
503
  for i in range(3):
504
  if i < len(preset.params):
505
+ key, label, default, _lo, _hi = preset.params[i]
506
  ups.append(gr.update(label=label, value=default, visible=True))
507
  else:
508
  ups.append(gr.update(visible=False, value=None))
509
+ models = runtime.available_models()
510
+ return (*ups, gr.update(visible=preset.needs_signals, choices=models,
511
+ value=(models[0] if models and preset.needs_signals
512
+ else None)), note)
 
513
 
514
+ strategy.change(on_strategy_change, [strategy],
515
+ [p1, p2, p3, model_slug, preset_note])
516
 
517
  def on_universe_change(a, tf):
518
  cov = runtime.price_coverage_for(a, tf)
519
  models = runtime.available_models(a, tf)
520
  if cov is None:
521
+ html = C.note(f"No cached price coverage for {a} {tf}.", danger=True)
 
522
  else:
523
+ html = C.micro(f"cached {cov[0]} → {cov[1]} · "
524
+ + (f"{len(models)} models" if models else "no signals"))
525
+ return html, gr.update(choices=models, value=(models[0] if models else None))
 
 
526
 
527
  asset.change(on_universe_change, [asset, timeframe], [coverage_note, model_slug])
528
+ timeframe.change(on_universe_change, [asset, timeframe],
529
+ [coverage_note, model_slug])
530
 
531
  def do_run(hist, *vals, progress=gr.Progress()):
532
  progress(0.05, desc="Reading cached slices")
533
  req = collect_request(*vals)
534
  try:
535
+ progress(0.4, desc="Simulating trades")
536
  rec = runtime.execute(req)
537
  except (RunError, ValueError) as e:
538
+ return (hist, None, C.top_bar("RUN FAILED", "warn"),
539
+ C.note(str(e), danger=True), gr.update(visible=True),
540
+ *(gr.update(),) * 9, "", pd.DataFrame(),
541
+ C.micro("no runs yet in this session"),
542
  "_Run failed._", None)
543
 
544
+ progress(0.8, desc="Building charts")
545
  hist = ([rec] + list(hist))[:40]
546
+ return (hist, rec,
547
+ C.top_bar(f"RUN {rec.run_id} COMPLETE", "ok",
548
+ context=context_chip_text(req),
549
+ elapsed=f"{rec.elapsed_s:.1f}s"),
550
+ C.stat_band(rec), gr.update(visible=False),
551
+ *build_overview(rec, log_scale=False, cvd=False),
552
+ trades_head(rec), trades_frame(rec),
553
+ C.run_history(hist), report_markdown(rec),
554
+ charts.equity_curve(rec.result.equity, rec.result.benchmark_equity,
555
+ plan=rec.result.plan))
556
+
557
+ run_out = [history, current, top_bar, stat_band, empty_state, *overview_out,
558
+ trades_head_html, trades_table, history_html, report_md,
559
+ report_equity]
560
+ run_btn.click(do_run, [history, *builder], run_out)
 
 
 
 
 
 
 
 
561
 
562
  def load_example():
563
+ return ("Chronos Forecast Follower", "BTC-USD", "1d", "3Y",
564
+ gr.update(value=0.005, label="Entry edge", visible=True),
565
+ gr.update(value=0.0, label="Exit edge", visible=True),
566
+ gr.update(visible=False, value=None),
567
+ gr.update(visible=True, choices=runtime.available_models(),
568
+ value="chronos-bolt-small"),
569
  True, "Walk-forward")
570
 
571
  example_btn.click(
572
  load_example, None,
573
+ [strategy, asset, timeframe, date_range, p1, p2, p3, model_slug,
574
+ costs_on, validation_mode],
575
+ ).then(do_run, [history, *builder], run_out)
576
 
577
  def replot(rec, log_s, colorblind):
578
  if rec is None:
579
  return (gr.update(),) * 9
580
  return build_overview(rec, log_scale=log_s, cvd=colorblind)
581
 
582
+ log_scale.change(replot, [current, log_scale, cvd], overview_out)
583
+ cvd.change(replot, [current, log_scale, cvd], overview_out)
 
 
 
 
584
 
585
+ share_btn.click(lambda *v: gr.update(value=collect_request(*v).encode(),
586
+ visible=True), builder, [share_out])
587
  copy_cfg_btn.click(lambda r: gr.update(value=r.request.encode(), visible=True)
588
  if r else gr.update(), [current], [share_out])
589
 
 
598
 
599
  def do_save(rec):
600
  if rec is None:
601
+ return C.note("Nothing to save yet.")
602
  try:
603
+ rid = runtime.save_run_summary(rec, push=True)
604
+ return C.note(f"Run <b>{rid}</b> saved to the signal store. "
605
+ "It now appears in Run history for everyone.")
606
  except Exception as e:
607
+ return C.note(f"Could not save: {e}", danger=True)
608
 
609
  save_run_btn.click(do_save, [current], [save_note])
610
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
611
  def do_robustness(rec):
612
  if rec is None:
613
  e = charts.empty_figure("run a backtest first")
614
+ return "", e, e
615
  grade, checks = runtime.overfit_verdict(rec)
616
+ items = "".join(f'<div class="bit-gloss-def">{m} {t}</div>'
617
+ for m, t in checks)
618
+ html = (f'<div class="bit-panel">{C.panel_head("Overfit verdict", grade)}'
 
 
619
  f'{items}</div>')
620
+ return (html, charts.walk_forward_bars(rec.result.windows),
621
+ charts.monte_carlo_cone(charts.monte_carlo_paths(rec.result.trades)))
 
 
622
 
623
+ current.change(do_robustness, [current], [verdict_html, wf_plot, mc_plot])
 
624
 
625
  def do_sweep(rec, progress=gr.Progress()):
626
  if rec is None:
 
633
  sens = charts.empty_figure("this preset has fewer than two parameters")
634
  else:
635
  progress(0.1, desc="Parameter sweep")
636
+ bx = req.params.get(keys[0], 20)
637
+ by = req.params.get(keys[1], 50)
638
+ xs = sorted({max(2, int(bx * m)) for m in (0.5, 0.75, 1.0, 1.5, 2.0)})
639
+ ys = sorted({max(3, int(by * m)) for m in (0.5, 0.75, 1.0, 1.5, 2.0)})
640
+ sens = charts.parameter_sensitivity(
641
+ runtime.parameter_sweep(req, keys[0], xs, keys[1], ys),
642
+ x=keys[0], y=keys[1])
643
  progress(0.7, desc="Slippage stress")
644
+ return sens, charts.slippage_stress(runtime.slippage_stress(req))
 
645
 
646
  robust_btn.click(do_sweep, [current], [sens_plot, slip_plot])
647
 
648
+ # ---- Compare tab ----
649
+ lb_filters = [f_assets, f_tfs, f_strats, f_models, f_metric,
650
+ f_min_trades, f_hide_base, f_oos, f_top]
651
+ lb_out = [podium, lb_table, lb_overlay, lb_scatter, lb_meta]
652
+
653
+ def refresh_leaderboard(assets_, tfs, strats, models, metric,
654
+ min_trades, hide_base, req_oos, top_n):
655
+ return CT.build_leaderboard_view(
656
+ runtime.get_store(), assets=assets_, timeframes=tfs,
657
+ strategies_=strats, models=models, metric_label=metric,
658
+ min_trades=min_trades, hide_baselines=hide_base,
659
+ require_oos=req_oos, top_n=top_n)
660
+
661
+ for ctrl in lb_filters:
662
+ ctrl.change(refresh_leaderboard, lb_filters, lb_out)
663
+
664
+ reset_btn.click(lambda: ([], [], [], [], "OOS Sharpe", 0, False, True, 15),
665
+ None, lb_filters).then(refresh_leaderboard, lb_filters, lb_out)
666
 
667
+ models_out = [models_note, acc_plot, cal_plot, model_bars, score_table]
668
+ models_tf.change(lambda tf: CT.build_models_view(runtime.get_store(), tf),
669
+ [models_tf], models_out)
670
+
671
+ def refresh_signals(a, tf):
672
+ return CT.build_signals_view(runtime.get_store(), a, tf)
673
+
674
+ sig_asset.change(refresh_signals, [sig_asset, sig_tf], [sig_panel])
675
+ sig_tf.change(refresh_signals, [sig_asset, sig_tf], [sig_panel])
676
+
677
+ def refresh_runs(hist):
678
+ saved = catalog.load_saved_runs(runtime.get_store())
679
+ return CT.runs_table(hist, saved)
680
+
681
+ runs_refresh.click(refresh_runs, [history], [runs_table])
682
+ history.change(refresh_runs, [history], [runs_table])
683
+
684
+ # ---- Coverage / extension ----
685
+ estimate_btn.click(extension.estimate_ui,
686
+ [ext_model, ext_asset, ext_tf, ext_start, ext_end],
687
+ [extend_out])
688
  extend_btn.click(extension.extend_ui,
689
  [ext_model, ext_asset, ext_tf, ext_start, ext_end],
690
  [extend_out, coverage_table])
691
  add_btn.click(extension.add_model_ui, [add_family, add_model_id],
692
  [add_out, coverage_table])
693
 
694
+ # ---- Load ----
695
  def on_load(request: gr.Request):
696
+ st = runtime.get_store()
697
+ meta = catalog.catalog_meta(st)
698
+ meta_html = C.micro(
699
+ f"{meta.get('leaderboard_rows', 0)} combinations · "
700
+ f"{meta.get('scorecard_rows', 0)} model slices · generated "
701
+ f"{str(meta.get('generated_at', ''))[:16].replace('T', ' ')} · "
702
+ f"{meta.get('canonical_config', '')}") if meta else C.micro("catalog not built")
703
+
704
+ lb_view = CT.build_leaderboard_view(
705
+ st, assets=None, timeframes=None, strategies_=None, models=None,
706
+ metric_label="OOS Sharpe", min_trades=0, hide_baselines=False,
707
+ require_oos=True, top_n=15)
708
+ models_view = CT.build_models_view(st, "1d")
709
+ sig = CT.build_signals_view(st, "BTC-USD", "1d")
710
+ cov_cells = runtime.coverage_map()
711
+
712
  restored = [gr.update()] * 4
 
713
  try:
714
  token = dict(request.query_params).get("cfg") if request else None
715
  except Exception:
 
720
  restored = [gr.update(value=req.strategy), gr.update(value=req.asset),
721
  gr.update(value=req.timeframe),
722
  gr.update(value=req.date_range)]
 
723
  except Exception as e:
724
+ log.warning("share link rejected: %s", e)
725
+
726
+ return (meta_html, *lb_view, *models_view, sig,
727
+ C.coverage_summary(cov_cells), runtime.coverage_frame(),
728
+ extension.status_html(),
729
+ CT.runs_table([], catalog.load_saved_runs(st)), *restored)
730
 
731
  demo.load(on_load, None,
732
+ [catalog_meta, *lb_out, *models_out, sig_panel,
733
+ coverage_kpis, coverage_table, extend_panel, runs_table,
734
  strategy, asset, timeframe, date_range])
 
735
  demo.load(lambda: on_universe_change("BTC-USD", "1d"), None,
736
  [coverage_note, model_slug])
737
 
738
  return demo
739
 
740
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
741
  demo = build_app()
742
 
743
  if __name__ == "__main__":
744
  gr.set_static_paths(paths=theme.static_paths())
745
  demo.queue(max_size=32).launch(
746
+ server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)),
747
+ show_api=False)
 
 
scripts/seed_store.py CHANGED
@@ -28,7 +28,7 @@ import pandas as pd
28
 
29
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
30
 
31
- from src import comparisons, config # noqa: E402
32
  from src.adapters import build_windows, get_adapter # noqa: E402
33
  from src.data import refresh # noqa: E402
34
  from src.store import SignalStore # noqa: E402
@@ -61,32 +61,37 @@ EQUITIES = ["SPY", "QQQ", "NVDA"]
61
 
62
 
63
  def plan_v1() -> list[SeedTarget]:
64
- """The v1 seed: crypto-first, honest about provider depth limits.
 
65
 
66
- Daily coverage is real for every asset on both Chronos models. Hourly is
67
- real for crypto on the small model. 15-minute coverage is placeholder-only
68
- in v1 -- the inference cost is large and the provider depth for equities is
69
- 60 days, so it is labelled rather than faked as real.
70
  """
71
  targets: list[SeedTarget] = []
72
-
73
- for asset in CRYPTO + EQUITIES:
74
- targets.append(SeedTarget("chronos-bolt-small", asset, "1d", 3.0))
75
- for asset in CRYPTO:
76
- targets.append(SeedTarget("chronos-bolt-base", asset, "1d", 3.0))
77
-
78
- # Hourly: crypto has full depth from the exchange; equities are capped at
79
- # the provider's ~730 days, recorded as a boundary in the manifest.
80
- for asset in CRYPTO:
81
- targets.append(SeedTarget("chronos-bolt-small", asset, "1h", 1.0))
 
 
 
 
 
 
 
 
 
 
82
  for asset in EQUITIES:
83
  targets.append(SeedTarget("chronos-bolt-small", asset, "1h", 1.5))
84
 
85
- # 15-minute crypto. Batched Chronos-Bolt inference turned out to cost about
86
- # a millisecond per step on this hardware, so these are real rather than
87
- # placeholder -- the v1 seed ships with no synthetic slices at all.
88
- for asset in CRYPTO:
89
- targets.append(SeedTarget("chronos-bolt-small", asset, "15m", 0.25))
90
  return targets
91
 
92
 
@@ -301,6 +306,11 @@ def main(argv=None) -> int:
301
  )
302
  log.info("%s", results[-1])
303
 
 
 
 
 
 
304
  if args.push and not args.offline:
305
  log.info("== pushing to %s ==", args.repo)
306
  oid = store.flush(f"Seed store ({args.plan})")
 
28
 
29
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
30
 
31
+ from src import catalog, comparisons, config # noqa: E402
32
  from src.adapters import build_windows, get_adapter # noqa: E402
33
  from src.data import refresh # noqa: E402
34
  from src.store import SignalStore # noqa: E402
 
61
 
62
 
63
  def plan_v1() -> list[SeedTarget]:
64
+ """The v1 seed: every seedable model on daily bars for the whole universe,
65
+ plus intraday coverage for the fast Chronos-Bolt family on crypto.
66
 
67
+ Daily is the comparison backbone -- every model sees exactly the same bars
68
+ on every asset, so leaderboard differences are the model, not the coverage.
69
+ Intraday is added where inference is cheap enough to be honest about.
 
70
  """
71
  targets: list[SeedTarget] = []
72
+ all_assets = CRYPTO + EQUITIES
73
+
74
+ # Backbone: every model x every asset, daily.
75
+ for model in config.SEEDABLE_MODELS:
76
+ for asset in all_assets:
77
+ targets.append(SeedTarget(model, asset, "1d", 3.0))
78
+
79
+ # Intraday: the bolt family plus baselines on crypto.
80
+ intraday_models = [m for m in config.SEEDABLE_MODELS
81
+ if m.startswith("chronos-bolt") or m.startswith("baseline")]
82
+ for model in intraday_models:
83
+ for asset in CRYPTO:
84
+ targets.append(SeedTarget(model, asset, "1h", 1.0))
85
+
86
+ # 15-minute: the small model and the naive baseline, crypto only.
87
+ for model in ("chronos-bolt-small", "baseline-naive"):
88
+ for asset in CRYPTO:
89
+ targets.append(SeedTarget(model, asset, "15m", 0.25))
90
+
91
+ # Hourly equities for the reference model, capped by provider depth.
92
  for asset in EQUITIES:
93
  targets.append(SeedTarget("chronos-bolt-small", asset, "1h", 1.5))
94
 
 
 
 
 
 
95
  return targets
96
 
97
 
 
306
  )
307
  log.info("%s", results[-1])
308
 
309
+ log.info("== building catalog ==")
310
+ cat = catalog.build(store)
311
+ results.append(cat.summary())
312
+ log.info("%s", cat.summary())
313
+
314
  if args.push and not args.offline:
315
  log.info("== pushing to %s ==", args.repo)
316
  oid = store.flush(f"Seed store ({args.plan})")
src/adapters.py CHANGED
@@ -132,6 +132,19 @@ def _default_device() -> str:
132
  return "cpu"
133
 
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  # --------------------------------------------------------------------------
136
  # Chronos / Chronos-Bolt
137
  # --------------------------------------------------------------------------
@@ -170,6 +183,24 @@ class ChronosAdapter(ForecastAdapter):
170
  )
171
  return self
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  def predict(self, context_windows: np.ndarray) -> Forecast:
174
  if self._model is None:
175
  self.load()
@@ -178,15 +209,24 @@ class ChronosAdapter(ForecastAdapter):
178
  ctx = np.asarray(context_windows, dtype="float32")
179
  if ctx.ndim == 1:
180
  ctx = ctx[None, :]
181
- tensors = [torch.tensor(row) for row in ctx]
182
 
183
  q_levels = list(DEFAULT_QUANTILES)
184
- with torch.inference_mode():
185
- quantiles, _mean = self._model.predict_quantiles(
186
- tensors, prediction_length=1, quantile_levels=q_levels,
187
- )
188
- # (n_series, prediction_length, n_quantiles) -> horizon 1
189
- arr = quantiles.float().cpu().numpy()[:, 0, :]
 
 
 
 
 
 
 
 
 
 
190
  return Forecast(q10=arr[:, 0], q50=arr[:, 1], q90=arr[:, 2],
191
  context_len=ctx.shape[1])
192
 
@@ -254,6 +294,82 @@ class TimesFMAdapter(ForecastAdapter):
254
  context_len=ctx.shape[1])
255
 
256
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  # --------------------------------------------------------------------------
258
  # Placeholder (no GPU / no model available)
259
  # --------------------------------------------------------------------------
@@ -308,6 +424,7 @@ class PlaceholderAdapter(ForecastAdapter):
308
  _FAMILIES: dict[str, type[ForecastAdapter]] = {
309
  "chronos": ChronosAdapter,
310
  "timesfm": TimesFMAdapter,
 
311
  "placeholder": PlaceholderAdapter,
312
  }
313
 
 
132
  return "cpu"
133
 
134
 
135
+ def _release(device: str) -> None:
136
+ """Free accelerator memory between inference chunks."""
137
+ try:
138
+ import torch
139
+
140
+ if device == "cuda":
141
+ torch.cuda.empty_cache()
142
+ elif device == "mps" and hasattr(torch, "mps"):
143
+ torch.mps.empty_cache()
144
+ except Exception:
145
+ pass
146
+
147
+
148
  # --------------------------------------------------------------------------
149
  # Chronos / Chronos-Bolt
150
  # --------------------------------------------------------------------------
 
183
  )
184
  return self
185
 
186
+ # Bolt emits quantiles directly and is happy with wide batches. The
187
+ # original T5 Chronos *samples* forecast paths instead, so its peak memory
188
+ # is batch x num_samples x context and a wide batch OOMs a GPU outright.
189
+ # Chunking here rather than at the call site means every caller -- seed
190
+ # script, ZeroGPU function, tests -- is protected without knowing which
191
+ # variant it holds.
192
+ BOLT_CHUNK = 256
193
+ T5_CHUNK = 16
194
+ T5_NUM_SAMPLES = 20
195
+
196
+ @property
197
+ def _is_bolt(self) -> bool:
198
+ return "bolt" in (self.model_id or "").lower()
199
+
200
+ @property
201
+ def chunk_size(self) -> int:
202
+ return self.BOLT_CHUNK if self._is_bolt else self.T5_CHUNK
203
+
204
  def predict(self, context_windows: np.ndarray) -> Forecast:
205
  if self._model is None:
206
  self.load()
 
209
  ctx = np.asarray(context_windows, dtype="float32")
210
  if ctx.ndim == 1:
211
  ctx = ctx[None, :]
 
212
 
213
  q_levels = list(DEFAULT_QUANTILES)
214
+ extra = {} if self._is_bolt else {"num_samples": self.T5_NUM_SAMPLES}
215
+ step = max(1, self.chunk_size)
216
+ parts = []
217
+
218
+ for start in range(0, len(ctx), step):
219
+ block = ctx[start:start + step]
220
+ tensors = [torch.tensor(row) for row in block]
221
+ with torch.inference_mode():
222
+ quantiles, _mean = self._model.predict_quantiles(
223
+ tensors, prediction_length=1, quantile_levels=q_levels, **extra,
224
+ )
225
+ parts.append(quantiles.float().cpu().numpy()[:, 0, :])
226
+ del quantiles, tensors
227
+ _release(self.device)
228
+
229
+ arr = np.vstack(parts)
230
  return Forecast(q10=arr[:, 0], q50=arr[:, 1], q90=arr[:, 2],
231
  context_len=ctx.shape[1])
232
 
 
294
  context_len=ctx.shape[1])
295
 
296
 
297
+ # --------------------------------------------------------------------------
298
+ # Naive baselines
299
+ # --------------------------------------------------------------------------
300
+
301
+
302
+ class BaselineAdapter(ForecastAdapter):
303
+ """Classical forecasting baselines, exposed as first-class models.
304
+
305
+ These exist so the leaderboard always carries a floor. A large pretrained
306
+ forecaster that cannot beat "tomorrow looks like today" has not earned its
307
+ inference cost, and burying that comparison would be the single easiest way
308
+ to make this whole Space misleading.
309
+
310
+ The method is selected by the model id: `baseline/naive`, `baseline/drift`,
311
+ or `baseline/seasonal`. All three are deterministic and causal -- each uses
312
+ only the trailing window, which ends at the bar being forecast.
313
+
314
+ The interval is a Gaussian band around the point forecast, scaled by the
315
+ window's own realised volatility, so coverage is comparable with a
316
+ learned model's q10-q90 rather than arbitrarily wide.
317
+ """
318
+
319
+ family = "baseline"
320
+ adapter_version = "1"
321
+ Z90 = 1.2815515655446004 # normal 90th percentile
322
+
323
+ def __init__(self, model_id: str, revision: str | None = None,
324
+ context_len: int = 128, device: str | None = None,
325
+ season: int = 7):
326
+ super().__init__(model_id, revision, context_len, device)
327
+ self.season = season
328
+ self.method = (model_id.split("/")[-1] or "naive").lower()
329
+ if self.method not in ("naive", "drift", "seasonal"):
330
+ raise AdapterError(
331
+ f"unknown baseline method {self.method!r}; "
332
+ "use baseline/naive, baseline/drift or baseline/seasonal"
333
+ )
334
+
335
+ def load(self, model_id: str | None = None, revision: str | None = None):
336
+ if model_id:
337
+ self.model_id = model_id
338
+ self.method = model_id.split("/")[-1].lower()
339
+ # Baselines have no weights, so the "revision" is the adapter's own
340
+ # version -- still pinned, still reproducible.
341
+ self._resolved_revision = f"baseline-{self.adapter_version}"
342
+ return self
343
+
344
+ def resolve_revision(self) -> str:
345
+ self._resolved_revision = f"baseline-{self.adapter_version}"
346
+ return self._resolved_revision
347
+
348
+ def predict(self, context_windows: np.ndarray) -> Forecast:
349
+ ctx = np.asarray(context_windows, dtype="float64")
350
+ if ctx.ndim == 1:
351
+ ctx = ctx[None, :]
352
+ last = ctx[:, -1]
353
+
354
+ if self.method == "naive":
355
+ point = last
356
+ elif self.method == "drift":
357
+ # Average per-step change across the window, added once.
358
+ steps = ctx.shape[1] - 1
359
+ point = last + (ctx[:, -1] - ctx[:, 0]) / max(steps, 1)
360
+ else: # seasonal
361
+ k = min(self.season, ctx.shape[1])
362
+ point = ctx[:, -k]
363
+
364
+ # One-step-ahead uncertainty from the window's own step volatility.
365
+ sigma = np.std(np.diff(ctx, axis=1), axis=1)
366
+ sigma = np.where(np.isfinite(sigma) & (sigma > 0), sigma,
367
+ np.abs(last) * 1e-4)
368
+ band = self.Z90 * sigma
369
+ return Forecast(q10=point - band, q50=point, q90=point + band,
370
+ context_len=ctx.shape[1])
371
+
372
+
373
  # --------------------------------------------------------------------------
374
  # Placeholder (no GPU / no model available)
375
  # --------------------------------------------------------------------------
 
424
  _FAMILIES: dict[str, type[ForecastAdapter]] = {
425
  "chronos": ChronosAdapter,
426
  "timesfm": TimesFMAdapter,
427
+ "baseline": BaselineAdapter,
428
  "placeholder": PlaceholderAdapter,
429
  }
430
 
src/catalog.py ADDED
@@ -0,0 +1,521 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The catalog: everything this Space has ever computed, in one place.
2
+
3
+ The Comparison tab used to see only the runs made in the current browser
4
+ session. That cannot answer the question people actually have -- *which
5
+ strategy, on which model, on which asset, has ever worked best?* -- because the
6
+ answer lives across sessions, users and machines.
7
+
8
+ So the catalog is **precomputed into the store** rather than recomputed live:
9
+
10
+ * `comparisons/leaderboard.parquet` -- one row per
11
+ (strategy x model x asset x timeframe) with the full IS / OOS / holdout metric
12
+ set, all produced under one fixed canonical config so rows are comparable.
13
+ * `comparisons/equity_curves.parquet` -- long-format cumulative returns,
14
+ resampled to daily, so "returns over time" for dozens of algorithms renders
15
+ instantly instead of re-running dozens of backtests per page load.
16
+ * `comparisons/signal_scorecard.parquet` -- per-model forecast quality:
17
+ calibration, directional accuracy against naive baselines, pinball loss.
18
+
19
+ All three are regenerated after any coverage extension, so the catalog never
20
+ describes a store that no longer exists. Session runs and saved `runs/*.json`
21
+ are merged on top at read time.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import logging
28
+ import time
29
+ from dataclasses import dataclass, field
30
+
31
+ import numpy as np
32
+ import pandas as pd
33
+
34
+ from . import config, strategies
35
+ from .engine import BacktestConfig, Costs, Validation, run_backtest
36
+ from .metrics import (
37
+ calibration_coverage,
38
+ calibration_error,
39
+ directional_accuracy,
40
+ pinball_loss,
41
+ )
42
+ from .store import SignalStore
43
+
44
+ log = logging.getLogger("bit.catalog")
45
+
46
+ LEADERBOARD = "comparisons/leaderboard.parquet"
47
+ EQUITY_CURVES = "comparisons/equity_curves.parquet"
48
+ SCORECARD = "comparisons/signal_scorecard.parquet"
49
+ CATALOG_INDEX = "comparisons/catalog.json"
50
+
51
+ # Strategies that take part in the catalog sweep. `Buy & Hold` is included
52
+ # deliberately: a strategy that cannot beat holding the asset has not earned
53
+ # its complexity, and the leaderboard should show that side by side.
54
+ CATALOG_STRATEGIES = (
55
+ "Buy & Hold (benchmark)",
56
+ "SMA Crossover",
57
+ "RSI Mean Reversion",
58
+ "Bollinger Breakout",
59
+ "MACD Momentum",
60
+ "Sentiment-Gated Momentum",
61
+ "Chronos Forecast Follower",
62
+ )
63
+
64
+ # Every catalog row is produced under exactly this config. Changing it
65
+ # invalidates every published comparison, which is why it lives in one place.
66
+ CANONICAL = dict(
67
+ costs=Costs(enabled=True),
68
+ validation=Validation(mode="walk_forward", train_months=12, test_months=3,
69
+ roll_months=3, holdout_months=6),
70
+ )
71
+
72
+ CURVE_POINTS_MAX = 800
73
+
74
+ # Below this many closed trades, a Sharpe ratio is noise dressed as a result.
75
+ # The leaderboard still shows these rows -- hiding them would be its own kind of
76
+ # lie -- but flags them and filters them out of the default ranked view.
77
+ MIN_MEANINGFUL_TRADES = 20
78
+
79
+
80
+ def canonical_config(asset: str, timeframe: str, strategy: str) -> BacktestConfig:
81
+ return BacktestConfig(
82
+ asset=asset, timeframe=timeframe, strategy=strategy,
83
+ params=strategies.defaults_for(strategy), **CANONICAL,
84
+ )
85
+
86
+
87
+ def entry_key(strategy: str, model_slug: str, asset: str, timeframe: str) -> str:
88
+ return f"{strategy}|{model_slug or '-'}|{asset}|{timeframe}"
89
+
90
+
91
+ # --------------------------------------------------------------------------
92
+ # Building
93
+ # --------------------------------------------------------------------------
94
+
95
+
96
+ @dataclass
97
+ class BuildReport:
98
+ rows: int = 0
99
+ curves: int = 0
100
+ scorecard_rows: int = 0
101
+ skipped: list[str] = field(default_factory=list)
102
+ failed: list[str] = field(default_factory=list)
103
+ elapsed_s: float = 0.0
104
+
105
+ def summary(self) -> str:
106
+ return (f"catalog: {self.rows} leaderboard rows, {self.curves} curves, "
107
+ f"{self.scorecard_rows} scorecard rows, {len(self.skipped)} skipped, "
108
+ f"{len(self.failed)} failed in {self.elapsed_s:.1f}s")
109
+
110
+
111
+ def _resample_curve(equity: pd.Series) -> pd.DataFrame:
112
+ """Normalised cumulative return, thinned to a comparable daily series."""
113
+ if equity is None or len(equity) < 2:
114
+ return pd.DataFrame(columns=["ts", "cum_return"])
115
+ cum = equity / float(equity.iloc[0]) - 1.0
116
+ # Daily is the common denominator across 15m / 1h / 1d runs, so every
117
+ # series in the overlay shares an x-axis granularity.
118
+ daily = cum.resample("1D").last().dropna()
119
+ if len(daily) > CURVE_POINTS_MAX:
120
+ step = int(np.ceil(len(daily) / CURVE_POINTS_MAX))
121
+ daily = daily.iloc[::step]
122
+ return pd.DataFrame({"ts": daily.index, "cum_return": daily.to_numpy()})
123
+
124
+
125
+ def _scorecard_row(prices: pd.DataFrame, signals: pd.DataFrame,
126
+ model_slug: str, asset: str, timeframe: str) -> dict | None:
127
+ """Forecast quality for one model slice, independent of any trading rule."""
128
+ if signals.empty or prices.empty:
129
+ return None
130
+ close = prices["close"]
131
+ aligned = signals.reindex(close.index).dropna(subset=["q50"])
132
+ if aligned.empty:
133
+ return None
134
+
135
+ actual_next = close.shift(-1).reindex(aligned.index)
136
+ valid = actual_next.notna()
137
+ if valid.sum() < 30:
138
+ return None
139
+ idx = valid.index[valid]
140
+ actual_next = actual_next[valid]
141
+ q10, q50, q90 = aligned.loc[idx, "q10"], aligned.loc[idx, "q50"], aligned.loc[idx, "q90"]
142
+ ref = close.reindex(idx)
143
+
144
+ prev_move = close.diff().reindex(idx).fillna(0.0)
145
+ rng = np.random.default_rng(0)
146
+ coin = pd.Series(rng.choice([-1.0, 1.0], size=len(idx)), index=idx)
147
+
148
+ cov = calibration_coverage(actual_next, q10, q90)
149
+ acc = directional_accuracy(actual_next, q50, ref)
150
+ mom = directional_accuracy(actual_next, ref + prev_move, ref)
151
+ rnd = directional_accuracy(actual_next, ref + coin, ref)
152
+
153
+ spec = config.SEED_MODELS.get(model_slug)
154
+ return {
155
+ "model_slug": model_slug,
156
+ "model_display": spec.display if spec else model_slug,
157
+ "family": spec.family if spec else "unknown",
158
+ "is_baseline": bool(spec and spec.family == "baseline"),
159
+ "asset": asset, "timeframe": timeframe, "n": int(valid.sum()),
160
+ "coverage_q10_q90": cov,
161
+ "calibration_error": calibration_error(cov, 0.80),
162
+ "directional_accuracy": acc,
163
+ "beats_momentum": (acc - mom) if pd.notna(acc) and pd.notna(mom) else float("nan"),
164
+ "beats_random": (acc - rnd) if pd.notna(acc) and pd.notna(rnd) else float("nan"),
165
+ "baseline_momentum": mom, "baseline_random": rnd,
166
+ "pinball_q50": pinball_loss(actual_next, q50, 0.50),
167
+ "band_width_pct": float(((q90 - q10) / ref.abs().clip(lower=1e-9)).mean()),
168
+ }
169
+
170
+
171
+ def build(store: SignalStore, *, write: bool = True,
172
+ strategies_subset: tuple[str, ...] = CATALOG_STRATEGIES,
173
+ progress=None) -> BuildReport:
174
+ """Sweep every covered combination and write the catalog artifacts."""
175
+ t0 = time.perf_counter()
176
+ rep = BuildReport()
177
+ manifest = store.load_manifest()
178
+
179
+ price_keys = sorted(manifest.prices)
180
+ rows: list[dict] = []
181
+ curves: list[pd.DataFrame] = []
182
+ scorecard: list[dict] = []
183
+
184
+ total = len(price_keys)
185
+ for n, pk in enumerate(price_keys):
186
+ asset, timeframe = pk.split("|")
187
+ if progress:
188
+ progress((n + 1) / max(total, 1), desc=f"catalog {asset} {timeframe}")
189
+
190
+ prices = store.get_prices(asset, timeframe)
191
+ if len(prices) < 120:
192
+ rep.skipped.append(f"{pk}: only {len(prices)} bars")
193
+ continue
194
+ bpy = config.bars_per_year(asset, timeframe)
195
+
196
+ models = sorted({e.model_slug for e in
197
+ manifest.find_signals(asset=asset, timeframe=timeframe)})
198
+ signal_cache: dict[str, pd.DataFrame] = {}
199
+ for m in models:
200
+ sig = store.get_signals(m, asset, timeframe)
201
+ signal_cache[m] = sig
202
+ sc = _scorecard_row(prices, sig, m, asset, timeframe)
203
+ if sc:
204
+ scorecard.append(sc)
205
+
206
+ for strategy in strategies_subset:
207
+ preset = strategies.PRESETS.get(strategy)
208
+ if preset is None or not preset.available:
209
+ continue
210
+ # Signal strategies fan out over models; the rest run once.
211
+ targets = models if preset.needs_signals else [""]
212
+ if preset.needs_signals and not models:
213
+ rep.skipped.append(f"{strategy}|{pk}: no model coverage")
214
+ continue
215
+
216
+ for model_slug in targets:
217
+ key = entry_key(strategy, model_slug, asset, timeframe)
218
+ try:
219
+ cfg = canonical_config(asset, timeframe, strategy)
220
+ out = strategies.build(strategy, prices, cfg.params,
221
+ signal_cache.get(model_slug, pd.DataFrame()))
222
+ res = run_backtest(prices, out, cfg, bars_per_year=bpy)
223
+ except Exception as e:
224
+ log.warning("catalog cell failed %s: %s", key, e)
225
+ rep.failed.append(f"{key}: {type(e).__name__}")
226
+ continue
227
+
228
+ a, i, o = res.metrics_all, res.metrics_is, res.metrics_oos
229
+ h = res.metrics_holdout
230
+ spec = config.SEED_MODELS.get(model_slug)
231
+ bench = (float(res.benchmark_equity.iloc[-1] /
232
+ res.benchmark_equity.iloc[0] - 1.0)
233
+ if len(res.benchmark_equity) else float("nan"))
234
+ rows.append({
235
+ "key": key,
236
+ "strategy": strategy,
237
+ "model_slug": model_slug,
238
+ "model_display": spec.display if spec else ("—" if not model_slug else model_slug),
239
+ "is_baseline_model": bool(spec and spec.family == "baseline"),
240
+ "asset": asset, "timeframe": timeframe,
241
+ "bars": a.bars, "trades": a.trade_count,
242
+ "total_return": a.total_return, "cagr": a.cagr,
243
+ "sharpe": a.sharpe, "sortino": a.sortino,
244
+ "max_drawdown": a.max_drawdown, "volatility": a.volatility,
245
+ "win_rate": a.win_rate, "profit_factor": a.profit_factor,
246
+ "exposure": a.exposure, "costs_paid": res.costs_paid,
247
+ "is_sharpe": i.sharpe if i.bars else float("nan"),
248
+ "oos_sharpe": o.sharpe if o.bars else float("nan"),
249
+ "oos_return": o.total_return if o.bars else float("nan"),
250
+ "oos_max_drawdown": o.max_drawdown if o.bars else float("nan"),
251
+ "oos_trades": o.trade_count if o.bars else 0,
252
+ "has_oos": bool(o.bars),
253
+ "holdout_sharpe": h.sharpe if h else float("nan"),
254
+ "holdout_return": h.total_return if h else float("nan"),
255
+ "has_holdout": h is not None,
256
+ "oos_is_ratio": (o.sharpe / i.sharpe) if (i.bars and i.sharpe) else float("nan"),
257
+ "benchmark_return": bench,
258
+ "excess_vs_hold": a.total_return - bench if pd.notna(bench) else float("nan"),
259
+ "significant": bool(a.trade_count >= MIN_MEANINGFUL_TRADES),
260
+ "wf_windows": len(res.windows),
261
+ "wf_positive": sum(1 for w in res.windows if w.metrics.total_return > 0),
262
+ "generated_at": pd.Timestamp.now(tz="UTC").isoformat(),
263
+ })
264
+
265
+ curve = _resample_curve(res.equity)
266
+ if not curve.empty:
267
+ curve["key"] = key
268
+ curve["strategy"] = strategy
269
+ curve["asset"] = asset
270
+ curve["timeframe"] = timeframe
271
+ curve["model_slug"] = model_slug
272
+ curves.append(curve)
273
+
274
+ lb = pd.DataFrame(rows)
275
+ ec = pd.concat(curves, ignore_index=True) if curves else pd.DataFrame(
276
+ columns=["ts", "cum_return", "key", "strategy", "asset", "timeframe", "model_slug"])
277
+ sc = pd.DataFrame(scorecard)
278
+
279
+ rep.rows, rep.curves, rep.scorecard_rows = len(lb), len(curves), len(sc)
280
+ rep.elapsed_s = time.perf_counter() - t0
281
+
282
+ if write:
283
+ if not lb.empty:
284
+ store.write_table(LEADERBOARD, lb)
285
+ if not ec.empty:
286
+ store.write_table(EQUITY_CURVES, ec)
287
+ if not sc.empty:
288
+ store.write_table(SCORECARD, sc)
289
+ store.write_json(CATALOG_INDEX, {
290
+ "generated_at": pd.Timestamp.now(tz="UTC").isoformat(),
291
+ "leaderboard_rows": len(lb),
292
+ "equity_curve_series": len(curves),
293
+ "scorecard_rows": len(sc),
294
+ "strategies": list(strategies_subset),
295
+ "canonical_config": "costs on; walk-forward 12/3/3; 6mo locked holdout",
296
+ "skipped": rep.skipped[:50],
297
+ "failed": rep.failed[:50],
298
+ })
299
+ return rep
300
+
301
+
302
+ # --------------------------------------------------------------------------
303
+ # Reading
304
+ # --------------------------------------------------------------------------
305
+
306
+
307
+ def _read(store: SignalStore, path: str) -> pd.DataFrame:
308
+ df = store.read_parquet(path)
309
+ return df if df is not None else pd.DataFrame()
310
+
311
+
312
+ def load_leaderboard(store: SignalStore) -> pd.DataFrame:
313
+ return _read(store, LEADERBOARD)
314
+
315
+
316
+ def load_equity_curves(store: SignalStore) -> pd.DataFrame:
317
+ df = _read(store, EQUITY_CURVES)
318
+ if not df.empty:
319
+ df["ts"] = pd.to_datetime(df["ts"], utc=True)
320
+ return df
321
+
322
+
323
+ def load_scorecard(store: SignalStore) -> pd.DataFrame:
324
+ return _read(store, SCORECARD)
325
+
326
+
327
+ def catalog_meta(store: SignalStore) -> dict:
328
+ p = store._fetch(CATALOG_INDEX)
329
+ if p is None:
330
+ return {}
331
+ try:
332
+ return json.loads(p.read_text())
333
+ except Exception:
334
+ return {}
335
+
336
+
337
+ def load_saved_runs(store: SignalStore) -> pd.DataFrame:
338
+ """Runs persisted to `runs/` -- across every session and every user."""
339
+ rows = []
340
+ try:
341
+ from huggingface_hub import HfApi
342
+
343
+ if store.offline:
344
+ import pathlib
345
+
346
+ files = [f"runs/{p.name}" for p in
347
+ (store.local_root / "runs").glob("*.json")] \
348
+ if (store.local_root / "runs").exists() else []
349
+ else:
350
+ api = HfApi(token=store.token)
351
+ files = [f for f in api.list_repo_files(
352
+ store.repo_id, repo_type=config.STORE_REPO_TYPE)
353
+ if f.startswith("runs/") and f.endswith(".json")]
354
+ except Exception as e:
355
+ log.warning("could not list saved runs: %s", e)
356
+ return pd.DataFrame()
357
+
358
+ for f in files[:500]:
359
+ p = store._fetch(f)
360
+ if p is None:
361
+ continue
362
+ try:
363
+ d = json.loads(p.read_text())
364
+ except Exception:
365
+ continue
366
+ cfg = d.get("config", {})
367
+ m = (d.get("metrics") or {})
368
+ allm, oos = m.get("all") or {}, m.get("oos") or {}
369
+ rows.append({
370
+ "run_id": d.get("run_id", ""), "label": d.get("label", ""),
371
+ "created_at": d.get("created_at", ""),
372
+ "strategy": cfg.get("strategy", ""), "asset": cfg.get("asset", ""),
373
+ "timeframe": cfg.get("timeframe", ""),
374
+ "model_slug": cfg.get("model_slug", ""),
375
+ "total_return": allm.get("total_return", float("nan")),
376
+ "sharpe": allm.get("sharpe", float("nan")),
377
+ "oos_sharpe": oos.get("sharpe", float("nan")),
378
+ "max_drawdown": allm.get("max_drawdown", float("nan")),
379
+ "trades": allm.get("trade_count", 0),
380
+ "costs_paid": allm.get("costs_paid", float("nan")),
381
+ "share_token": d.get("share_token", ""),
382
+ "source": "saved run",
383
+ })
384
+ return pd.DataFrame(rows)
385
+
386
+
387
+ # --------------------------------------------------------------------------
388
+ # Views the UI asks for
389
+ # --------------------------------------------------------------------------
390
+
391
+
392
+ def rank(df: pd.DataFrame, by: str = "oos_sharpe", top: int | None = None,
393
+ ascending: bool = False, significant_only: bool = False) -> pd.DataFrame:
394
+ """Sort the leaderboard.
395
+
396
+ `significant_only` drops rows with too few trades to mean anything. A
397
+ Sharpe of 4 on 13 trades outranks everything real if you let it, so the
398
+ default ranked view uses this and says so.
399
+ """
400
+ if df.empty or by not in df.columns:
401
+ return df
402
+ out = df
403
+ if significant_only and "significant" in out.columns:
404
+ out = out[out["significant"]]
405
+ out = out.sort_values(by, ascending=ascending, na_position="last")
406
+ return out.head(top) if top else out
407
+
408
+
409
+ def filter_leaderboard(df: pd.DataFrame, *, assets=None, timeframes=None,
410
+ strategies_=None, models=None, min_trades: int = 0,
411
+ require_oos: bool = False,
412
+ hide_baselines: bool = False) -> pd.DataFrame:
413
+ if df.empty:
414
+ return df
415
+ out = df
416
+ if assets:
417
+ out = out[out["asset"].isin(assets)]
418
+ if timeframes:
419
+ out = out[out["timeframe"].isin(timeframes)]
420
+ if strategies_:
421
+ out = out[out["strategy"].isin(strategies_)]
422
+ if models:
423
+ out = out[out["model_slug"].isin(models) | (out["model_slug"] == "")]
424
+ if min_trades:
425
+ out = out[out["trades"] >= min_trades]
426
+ if require_oos and "has_oos" in out.columns:
427
+ out = out[out["has_oos"]]
428
+ if hide_baselines and "is_baseline_model" in out.columns:
429
+ out = out[~out["is_baseline_model"]]
430
+ return out
431
+
432
+
433
+ def curves_for(curves: pd.DataFrame, keys: list[str]) -> dict[str, pd.Series]:
434
+ """Pull named cumulative-return series out of the stored long frame."""
435
+ if curves.empty or not keys:
436
+ return {}
437
+ sub = curves[curves["key"].isin(keys)]
438
+ out: dict[str, pd.Series] = {}
439
+ for key, g in sub.groupby("key"):
440
+ g = g.sort_values("ts")
441
+ out[str(key)] = pd.Series(g["cum_return"].to_numpy(),
442
+ index=pd.DatetimeIndex(g["ts"]))
443
+ return out
444
+
445
+
446
+ def model_consensus(scorecard: pd.DataFrame, store: SignalStore,
447
+ asset: str, timeframe: str) -> pd.DataFrame:
448
+ """Every model's latest forecast for one asset, plus how much to trust it.
449
+
450
+ This is the Signal Aggregator view: each model's most recent direction and
451
+ implied move, weighted by how well that model has actually been calibrated
452
+ on this slice, so a confident-but-miscalibrated model does not dominate.
453
+ """
454
+ manifest = store.load_manifest()
455
+ models = sorted({e.model_slug for e in
456
+ manifest.find_signals(asset=asset, timeframe=timeframe)})
457
+ prices = store.get_prices(asset, timeframe)
458
+ if prices.empty or not models:
459
+ return pd.DataFrame()
460
+
461
+ last_close = float(prices["close"].iloc[-1])
462
+ rows = []
463
+ for m in models:
464
+ sig = store.get_signals(m, asset, timeframe)
465
+ if sig.empty:
466
+ continue
467
+ last = sig.iloc[-1]
468
+ ref = float(prices["close"].reindex([sig.index[-1]]).iloc[0]) \
469
+ if sig.index[-1] in prices.index else last_close
470
+ edge = float(last["q50"]) / ref - 1.0
471
+
472
+ sc = scorecard[(scorecard.get("model_slug") == m)
473
+ & (scorecard.get("asset") == asset)
474
+ & (scorecard.get("timeframe") == timeframe)] \
475
+ if not scorecard.empty else pd.DataFrame()
476
+ acc = float(sc["directional_accuracy"].iloc[0]) if len(sc) else float("nan")
477
+ cal_err = float(sc["calibration_error"].iloc[0]) if len(sc) else float("nan")
478
+
479
+ # Weight = how much better than a coin flip this model has been here.
480
+ weight = max(0.0, (acc - 0.5) * 2) if pd.notna(acc) else 0.0
481
+ spec = config.SEED_MODELS.get(m)
482
+ rows.append({
483
+ "model_slug": m,
484
+ "model": spec.display if spec else m,
485
+ "is_baseline": bool(spec and spec.family == "baseline"),
486
+ "as_of": sig.index[-1],
487
+ "reference_px": ref,
488
+ "q10": float(last["q10"]), "q50": float(last["q50"]),
489
+ "q90": float(last["q90"]),
490
+ "edge": edge,
491
+ "direction": "LONG" if edge > 0.0005 else ("SHORT" if edge < -0.0005 else "FLAT"),
492
+ "band_width": (float(last["q90"]) - float(last["q10"])) / max(abs(ref), 1e-9),
493
+ "directional_accuracy": acc,
494
+ "calibration_error": cal_err,
495
+ "weight": weight,
496
+ })
497
+
498
+ df = pd.DataFrame(rows)
499
+ if df.empty:
500
+ return df
501
+ return df.sort_values("weight", ascending=False).reset_index(drop=True)
502
+
503
+
504
+ def consensus_verdict(consensus: pd.DataFrame) -> dict:
505
+ """Weighted aggregate of the per-model directions."""
506
+ if consensus.empty:
507
+ return {"direction": "NO DATA", "confidence": 0.0, "edge": 0.0,
508
+ "n_models": 0, "agree": 0}
509
+ w = consensus["weight"].fillna(0.0)
510
+ if w.sum() <= 0:
511
+ w = pd.Series(1.0, index=consensus.index)
512
+ edge = float((consensus["edge"] * w).sum() / w.sum())
513
+ direction = "LONG" if edge > 0.0005 else ("SHORT" if edge < -0.0005 else "FLAT")
514
+ agree = int((consensus["direction"] == direction).sum())
515
+ return {
516
+ "direction": direction,
517
+ "edge": edge,
518
+ "confidence": float(agree / len(consensus)),
519
+ "n_models": int(len(consensus)),
520
+ "agree": agree,
521
+ }
src/charts.py CHANGED
@@ -613,3 +613,164 @@ def monte_carlo_paths(trades: pd.DataFrame, n_paths: int = 1000, seed: int = 0)
613
  for i in range(n_paths):
614
  out[i] = np.cumprod(1.0 + rng.permutation(rets)) - 1.0
615
  return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
613
  for i in range(n_paths):
614
  out[i] = np.cumprod(1.0 + rng.permutation(rets)) - 1.0
615
  return out
616
+
617
+
618
+ # --------------------------------------------------------------------------
619
+ # Catalog / global comparison
620
+ # --------------------------------------------------------------------------
621
+
622
+
623
+ def multi_return_overlay(curves: dict[str, pd.Series], *, height: int = 420,
624
+ highlight: str | None = None,
625
+ max_series: int = 24) -> go.Figure:
626
+ """Cumulative return of many algorithms on one shared axis.
627
+
628
+ Series arrive already normalised to cumulative return by the catalog, so
629
+ nothing is re-based here and every line is directly comparable. Beyond
630
+ `max_series` the chart stops being readable, so extras are dropped and the
631
+ caller is expected to say so rather than silently truncating.
632
+ """
633
+ if not curves:
634
+ return empty_figure("select rows to plot", height)
635
+
636
+ fig = go.Figure()
637
+ items = list(curves.items())[:max_series]
638
+ for i, (name, series) in enumerate(items):
639
+ if series is None or len(series) == 0:
640
+ continue
641
+ is_hl = highlight is not None and name == highlight
642
+ color = SERIES_COLORS[i % len(SERIES_COLORS)]
643
+ fig.add_trace(go.Scatter(
644
+ x=series.index, y=(series * 100.0).to_numpy(), name=name[:40],
645
+ line=dict(color=PALETTE["amber_strong"] if is_hl else color,
646
+ width=2.4 if is_hl else 1.3),
647
+ opacity=1.0 if (is_hl or highlight is None) else 0.45,
648
+ hovertemplate=f"{name}<br>%{{y:.1f}}%<extra></extra>",
649
+ ))
650
+ fig.add_hline(y=0, line=dict(color=PALETTE["border"], width=1))
651
+ fig.update_yaxes(ticksuffix="%")
652
+ return _base_layout(fig, height, showlegend=True, margin=(8, 8, 30, 8))
653
+
654
+
655
+ def risk_return_scatter(df: pd.DataFrame, *, height: int = 380,
656
+ x: str = "max_drawdown", y: str = "total_return",
657
+ size: str = "trades", color_by: str = "strategy") -> go.Figure:
658
+ """Where every catalog row sits in risk/return space.
659
+
660
+ Marker area encodes trade count, so a spectacular result built on four
661
+ trades looks as small as it deserves to.
662
+ """
663
+ if df is None or df.empty:
664
+ return empty_figure("no catalog rows", height)
665
+ d = df.dropna(subset=[x, y]).copy()
666
+ if d.empty:
667
+ return empty_figure("no plottable rows", height)
668
+
669
+ d["_size"] = d[size].fillna(0).clip(lower=1) ** 0.5 if size in d.columns else 4
670
+ groups = list(dict.fromkeys(d[color_by])) if color_by in d.columns else ["all"]
671
+
672
+ fig = go.Figure()
673
+ for i, g in enumerate(groups):
674
+ sub = d[d[color_by] == g] if color_by in d.columns else d
675
+ if sub.empty:
676
+ continue
677
+ label = sub.get("model_display", pd.Series([""] * len(sub), index=sub.index))
678
+ fig.add_trace(go.Scatter(
679
+ x=(sub[x] * 100).to_numpy(), y=(sub[y] * 100).to_numpy(),
680
+ mode="markers", name=str(g)[:26],
681
+ marker=dict(color=SERIES_COLORS[i % len(SERIES_COLORS)],
682
+ size=sub["_size"].to_numpy(), sizemode="area",
683
+ sizeref=max(d["_size"].max() ** 2 / 900, 1e-9), sizemin=4,
684
+ line=dict(width=0.5, color=PALETTE["border"])),
685
+ customdata=np.stack([sub["asset"], sub["timeframe"], label,
686
+ sub.get("trades", pd.Series(0, index=sub.index))], axis=-1),
687
+ hovertemplate=("%{customdata[0]} · %{customdata[1]} · %{customdata[2]}<br>"
688
+ "drawdown %{x:.1f}% · return %{y:.1f}%<br>"
689
+ "%{customdata[3]} trades<extra></extra>"),
690
+ ))
691
+ fig.add_hline(y=0, line=dict(color=PALETTE["border"], width=1))
692
+ fig.update_xaxes(title=dict(text="MAX DRAWDOWN", font=dict(size=9)), ticksuffix="%")
693
+ fig.update_yaxes(title=dict(text="TOTAL RETURN", font=dict(size=9)), ticksuffix="%")
694
+ return _base_layout(fig, height, showlegend=True, margin=(8, 8, 30, 34))
695
+
696
+
697
+ def model_accuracy_bars(scorecard: pd.DataFrame, *, height: int = 320,
698
+ timeframe: str | None = None) -> go.Figure:
699
+ """Directional accuracy per model, with the coin-flip line drawn in.
700
+
701
+ Baselines are coloured differently on purpose: the interesting question is
702
+ not which model scores highest, it is whether any learned model clears the
703
+ naive ones at all.
704
+ """
705
+ if scorecard is None or scorecard.empty:
706
+ return empty_figure("no scorecard rows", height)
707
+ d = scorecard
708
+ if timeframe:
709
+ d = d[d["timeframe"] == timeframe]
710
+ d = d.dropna(subset=["directional_accuracy"])
711
+ if d.empty:
712
+ return empty_figure("no directional calls recorded", height)
713
+
714
+ agg = (d.groupby(["model_display", "is_baseline"])["directional_accuracy"]
715
+ .mean().reset_index().sort_values("directional_accuracy"))
716
+ colors = [PALETTE["mute_blue"] if b else PALETTE["amber_strong"]
717
+ for b in agg["is_baseline"]]
718
+ fig = go.Figure(go.Bar(
719
+ x=(agg["directional_accuracy"] * 100).to_numpy(),
720
+ y=agg["model_display"].to_numpy(), orientation="h",
721
+ marker_color=colors,
722
+ hovertemplate="%{y}: %{x:.1f}% of directional calls correct<extra></extra>",
723
+ ))
724
+ fig.add_vline(x=50, line=dict(color=PALETTE["down"], width=1.5, dash="dot"),
725
+ annotation_text="COIN FLIP", annotation_position="top",
726
+ annotation_font=dict(family=FONT, size=9, color=PALETTE["down"]))
727
+ fig.update_xaxes(ticksuffix="%", range=[
728
+ max(0, float((agg["directional_accuracy"] * 100).min()) - 4),
729
+ float((agg["directional_accuracy"] * 100).max()) + 4])
730
+ return _base_layout(fig, height, margin=(8, 8, 20, 8))
731
+
732
+
733
+ def calibration_scatter(scorecard: pd.DataFrame, *, height: int = 320) -> go.Figure:
734
+ """Band coverage against the 80% nominal line -- who is actually calibrated."""
735
+ if scorecard is None or scorecard.empty:
736
+ return empty_figure("no scorecard rows", height)
737
+ d = scorecard.dropna(subset=["coverage_q10_q90"])
738
+ if d.empty:
739
+ return empty_figure("no calibration data", height)
740
+
741
+ fig = go.Figure()
742
+ for i, (name, sub) in enumerate(d.groupby("model_display")):
743
+ fig.add_trace(go.Scatter(
744
+ x=sub["timeframe"], y=(sub["coverage_q10_q90"] * 100).to_numpy(),
745
+ mode="markers", name=str(name)[:24],
746
+ marker=dict(size=9, color=SERIES_COLORS[i % len(SERIES_COLORS)],
747
+ symbol="diamond" if sub["is_baseline"].iloc[0] else "circle"),
748
+ customdata=sub[["asset"]].to_numpy(),
749
+ hovertemplate="%{customdata[0]}<br>coverage %{y:.1f}%<extra></extra>",
750
+ ))
751
+ fig.add_hline(y=80, line=dict(color=PALETTE["moss_strong"], width=1.5, dash="dot"),
752
+ annotation_text="NOMINAL 80%", annotation_position="top left",
753
+ annotation_font=dict(family=FONT, size=9, color=PALETTE["moss_strong"]))
754
+ fig.update_yaxes(ticksuffix="%", title=dict(text="q10–q90 COVERAGE", font=dict(size=9)))
755
+ return _base_layout(fig, height, showlegend=True, margin=(8, 8, 30, 34))
756
+
757
+
758
+ def model_leaderboard_bars(df: pd.DataFrame, *, height: int = 320,
759
+ metric: str = "oos_sharpe") -> go.Figure:
760
+ """Best result each model achieved, side by side."""
761
+ if df is None or df.empty or metric not in df.columns:
762
+ return empty_figure("no catalog rows", height)
763
+ d = df[df.get("model_slug", "") != ""].dropna(subset=[metric])
764
+ if d.empty:
765
+ return empty_figure("no model-driven rows", height)
766
+ agg = (d.groupby(["model_display", "is_baseline_model"])[metric]
767
+ .max().reset_index().sort_values(metric))
768
+ colors = [PALETTE["mute_blue"] if b else PALETTE["amber_strong"]
769
+ for b in agg["is_baseline_model"]]
770
+ fig = go.Figure(go.Bar(
771
+ x=agg[metric].to_numpy(), y=agg["model_display"].to_numpy(),
772
+ orientation="h", marker_color=colors,
773
+ hovertemplate="%{y}: best " + metric.replace("_", " ") + " %{x:.2f}<extra></extra>",
774
+ ))
775
+ fig.add_vline(x=0, line=dict(color=PALETTE["border"], width=1))
776
+ return _base_layout(fig, height, margin=(8, 8, 8, 8))
src/config.py CHANGED
@@ -158,18 +158,49 @@ class ModelSpec:
158
  SEED_MODELS: dict[str, ModelSpec] = {
159
  m.slug: m
160
  for m in [
 
 
 
 
 
 
161
  ModelSpec("chronos-bolt-small", "amazon/chronos-bolt-small", "chronos",
162
  "Chronos-Bolt Small", context_len=512),
163
  ModelSpec("chronos-bolt-base", "amazon/chronos-bolt-base", "chronos",
164
  "Chronos-Bolt Base", context_len=512),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  ModelSpec("timesfm-2-500m", "google/timesfm-2.0-500m-pytorch", "timesfm",
166
  "TimesFM 2.0 500M", context_len=512),
167
  ]
168
  }
169
 
 
 
 
 
 
 
 
 
 
 
170
  # Adapter families a user may pick from in the "Add model" flow. Restricting to
171
  # a fixed set is what keeps arbitrary model code from ever being executed.
172
- ALLOWED_ADAPTER_FAMILIES = ("chronos", "timesfm")
173
 
174
 
175
  # --------------------------------------------------------------------------
 
158
  SEED_MODELS: dict[str, ModelSpec] = {
159
  m.slug: m
160
  for m in [
161
+ # Chronos-Bolt: the fast encoder-decoder family. All four sizes share one
162
+ # adapter, so comparing them isolates model capacity from everything else.
163
+ ModelSpec("chronos-bolt-tiny", "amazon/chronos-bolt-tiny", "chronos",
164
+ "Chronos-Bolt Tiny", context_len=512),
165
+ ModelSpec("chronos-bolt-mini", "amazon/chronos-bolt-mini", "chronos",
166
+ "Chronos-Bolt Mini", context_len=512),
167
  ModelSpec("chronos-bolt-small", "amazon/chronos-bolt-small", "chronos",
168
  "Chronos-Bolt Small", context_len=512),
169
  ModelSpec("chronos-bolt-base", "amazon/chronos-bolt-base", "chronos",
170
  "Chronos-Bolt Base", context_len=512),
171
+ # Original Chronos (T5-based, sampling rather than direct quantiles).
172
+ ModelSpec("chronos-t5-small", "amazon/chronos-t5-small", "chronos",
173
+ "Chronos T5 Small", context_len=512),
174
+
175
+ # Naive baselines, deliberately first-class. A forecasting model that
176
+ # cannot beat "tomorrow looks like today" is not worth deploying, and
177
+ # the leaderboard should make that impossible to miss.
178
+ ModelSpec("baseline-naive", "baseline/naive", "baseline",
179
+ "Baseline · Random walk", context_len=128),
180
+ ModelSpec("baseline-drift", "baseline/drift", "baseline",
181
+ "Baseline · Drift", context_len=128),
182
+ ModelSpec("baseline-seasonal", "baseline/seasonal", "baseline",
183
+ "Baseline · Seasonal naive", context_len=128),
184
+
185
+ # Registered but unseeded: the timesfm package is heavy and optional.
186
  ModelSpec("timesfm-2-500m", "google/timesfm-2.0-500m-pytorch", "timesfm",
187
  "TimesFM 2.0 500M", context_len=512),
188
  ]
189
  }
190
 
191
+ # Models the seed plan actually runs. TimesFM is excluded until its dependency
192
+ # is pinned in requirements.txt.
193
+ SEEDABLE_MODELS = tuple(k for k in SEED_MODELS if not k.startswith("timesfm"))
194
+
195
+ BASELINE_MODELS = tuple(k for k, v in SEED_MODELS.items() if v.family == "baseline")
196
+
197
+
198
+ def is_baseline(model_slug: str) -> bool:
199
+ return model_slug in BASELINE_MODELS
200
+
201
  # Adapter families a user may pick from in the "Add model" flow. Restricting to
202
  # a fixed set is what keeps arbitrary model code from ever being executed.
203
+ ALLOWED_ADAPTER_FAMILIES = ("chronos", "timesfm", "baseline")
204
 
205
 
206
  # --------------------------------------------------------------------------
src/extension.py CHANGED
@@ -24,7 +24,7 @@ from dataclasses import dataclass
24
  import gradio as gr
25
  import pandas as pd
26
 
27
- from . import comparisons, config, runtime
28
  from .adapters import AdapterError, ModelNotAllowed, build_windows, get_adapter, validate_model_id
29
  from .store import _utc
30
 
@@ -244,10 +244,17 @@ def extend_coverage(model_slug: str, asset: str, timeframe: str, start, end,
244
  )
245
  if progress:
246
  progress(0.9, desc="Regenerating comparison tables")
 
 
 
247
  try:
248
  comparisons.regenerate(store, assets=[asset])
249
  except Exception:
250
  log.exception("comparison regeneration failed (coverage still written)")
 
 
 
 
251
  oid = store.flush(f"Extend {model_slug}/{asset}/{timeframe} by @{username}")
252
 
253
  runtime.cache_clear()
 
24
  import gradio as gr
25
  import pandas as pd
26
 
27
+ from . import catalog, comparisons, config, runtime
28
  from .adapters import AdapterError, ModelNotAllowed, build_windows, get_adapter, validate_model_id
29
  from .store import _utc
30
 
 
244
  )
245
  if progress:
246
  progress(0.9, desc="Regenerating comparison tables")
247
+ # Coverage just changed, so every derived view is now stale. Both are
248
+ # rebuilt inside the same write lock, before the commit, so readers
249
+ # never see new signals alongside an old leaderboard.
250
  try:
251
  comparisons.regenerate(store, assets=[asset])
252
  except Exception:
253
  log.exception("comparison regeneration failed (coverage still written)")
254
+ try:
255
+ catalog.build(store)
256
+ except Exception:
257
+ log.exception("catalog rebuild failed (coverage still written)")
258
  oid = store.flush(f"Extend {model_slug}/{asset}/{timeframe} by @{username}")
259
 
260
  runtime.cache_clear()
src/metrics.py CHANGED
@@ -240,14 +240,21 @@ def calibration_error(coverage: float, nominal: float = 0.80) -> float:
240
 
241
  def directional_accuracy(actual_next: pd.Series, predicted_next: pd.Series,
242
  reference: pd.Series) -> float:
243
- """Share of bars where the predicted direction matched the realised one."""
 
 
 
 
 
 
 
244
  df = pd.concat([actual_next, predicted_next, reference], axis=1).dropna()
245
  if df.empty:
246
  return float("nan")
247
  a, p, ref = df.iloc[:, 0], df.iloc[:, 1], df.iloc[:, 2]
248
  actual_dir = np.sign(a - ref)
249
  pred_dir = np.sign(p - ref)
250
- mask = actual_dir != 0
251
  if not mask.any():
252
  return float("nan")
253
  return float((actual_dir[mask] == pred_dir[mask]).mean())
 
240
 
241
  def directional_accuracy(actual_next: pd.Series, predicted_next: pd.Series,
242
  reference: pd.Series) -> float:
243
+ """Share of *directional calls* that matched the realised direction.
244
+
245
+ Bars where the forecast is exactly flat are excluded, not counted as
246
+ misses. A random-walk forecast predicts "no change" every bar; it is never
247
+ wrong about direction because it never claims one. Scoring it 0% would say
248
+ it is always wrong, which is a different and false statement. When a model
249
+ never takes a side, its directional accuracy is undefined and returns NaN.
250
+ """
251
  df = pd.concat([actual_next, predicted_next, reference], axis=1).dropna()
252
  if df.empty:
253
  return float("nan")
254
  a, p, ref = df.iloc[:, 0], df.iloc[:, 1], df.iloc[:, 2]
255
  actual_dir = np.sign(a - ref)
256
  pred_dir = np.sign(p - ref)
257
+ mask = (actual_dir != 0) & (pred_dir != 0)
258
  if not mask.any():
259
  return float("nan")
260
  return float((actual_dir[mask] == pred_dir[mask]).mean())
src/runtime.py CHANGED
@@ -462,8 +462,12 @@ def overfit_verdict(rec: RunRecord) -> tuple[str, list[tuple[str, str]]]:
462
  return grade, checks
463
 
464
 
465
- def save_run_summary(rec: RunRecord) -> str:
466
- """Write a shareable run summary into the store's runs/ folder."""
 
 
 
 
467
  payload = {
468
  "run_id": rec.run_id, "label": rec.label, "created_at": rec.created_at,
469
  "config": asdict(rec.request), "share_token": rec.request.encode(),
@@ -476,5 +480,11 @@ def save_run_summary(rec: RunRecord) -> str:
476
  if rec.result.metrics_holdout else None,
477
  },
478
  }
479
- get_store().write_json(f"runs/{rec.run_id}.json", payload)
 
 
 
 
 
 
480
  return rec.run_id
 
462
  return grade, checks
463
 
464
 
465
+ def save_run_summary(rec: RunRecord, *, push: bool = False) -> str:
466
+ """Write a shareable run summary into the store's runs/ folder.
467
+
468
+ With `push`, the summary is committed immediately so it shows up in the
469
+ global Run history for everyone rather than waiting for the next batch.
470
+ """
471
  payload = {
472
  "run_id": rec.run_id, "label": rec.label, "created_at": rec.created_at,
473
  "config": asdict(rec.request), "share_token": rec.request.encode(),
 
480
  if rec.result.metrics_holdout else None,
481
  },
482
  }
483
+ store = get_store()
484
+ store.write_json(f"runs/{rec.run_id}.json", payload)
485
+ if push:
486
+ try:
487
+ store.flush(f"Save run {rec.run_id}: {rec.label}")
488
+ except Exception as e:
489
+ log.warning("run summary staged but not pushed: %s", e)
490
  return rec.run_id
src/ui/compare_tab.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The global Compare interface.
2
+
3
+ Answers the question the per-session Comparison tab could not: *of everything
4
+ this Space has ever computed, what actually worked?*
5
+
6
+ It reads the precomputed catalog rather than re-running backtests, so a view
7
+ over 168 strategy/model/asset/timeframe combinations opens instantly. Three
8
+ data sources are merged:
9
+
10
+ * the catalog sweep in the signal store (every combination, one canonical config)
11
+ * `runs/*.json` -- runs people explicitly saved, across sessions and users
12
+ * the current browser session's runs
13
+
14
+ Two editorial decisions are deliberate. Low-trade rows are flagged and excluded
15
+ from the default ranking, because a Sharpe of 4 on 13 trades will otherwise sit
16
+ on top of the board forever. And naive baselines are shown next to learned
17
+ models everywhere, because "did this beat doing nothing clever?" is the only
18
+ question that matters first.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import pandas as pd
24
+
25
+ from .. import catalog, charts
26
+ from .format import EM, count, money, num, pct
27
+ from . import components as C
28
+
29
+ RANK_METRICS = {
30
+ "OOS Sharpe": "oos_sharpe",
31
+ "Total return": "total_return",
32
+ "CAGR": "cagr",
33
+ "Sharpe (all)": "sharpe",
34
+ "Sortino": "sortino",
35
+ "Profit factor": "profit_factor",
36
+ "Excess vs buy & hold": "excess_vs_hold",
37
+ "Holdout Sharpe": "holdout_sharpe",
38
+ "Max drawdown (least bad)": "max_drawdown",
39
+ }
40
+
41
+ TABLE_COLUMNS = [
42
+ ("strategy", "Strategy"), ("model_display", "Model"), ("asset", "Asset"),
43
+ ("timeframe", "TF"), ("oos_sharpe", "OOS Sharpe"), ("sharpe", "Sharpe"),
44
+ ("total_return", "Return"), ("cagr", "CAGR"), ("max_drawdown", "Max DD"),
45
+ ("win_rate", "Win%"), ("profit_factor", "PF"), ("trades", "Trades"),
46
+ ("excess_vs_hold", "vs Hold"), ("holdout_sharpe", "Holdout"),
47
+ ("costs_paid", "Costs"),
48
+ ]
49
+
50
+
51
+ def leaderboard_table(df: pd.DataFrame) -> pd.DataFrame:
52
+ """Format catalog rows for display, flagging thin evidence."""
53
+ if df is None or df.empty:
54
+ return pd.DataFrame(columns=[label for _, label in TABLE_COLUMNS] + ["Note"])
55
+ out = pd.DataFrame()
56
+ for col, label in TABLE_COLUMNS:
57
+ if col not in df.columns:
58
+ continue
59
+ s = df[col]
60
+ if col in ("total_return", "cagr", "max_drawdown", "excess_vs_hold"):
61
+ out[label] = s.map(lambda v: pct(v))
62
+ elif col == "win_rate":
63
+ out[label] = s.map(lambda v: pct(v, 0, signed=False))
64
+ elif col in ("oos_sharpe", "sharpe", "profit_factor", "holdout_sharpe"):
65
+ out[label] = s.map(lambda v: num(v))
66
+ elif col == "costs_paid":
67
+ out[label] = s.map(money)
68
+ elif col == "trades":
69
+ out[label] = s.map(count)
70
+ else:
71
+ out[label] = s.fillna(EM)
72
+ if "significant" in df.columns:
73
+ out["Note"] = df["significant"].map(
74
+ lambda ok: "" if ok else f"< {catalog.MIN_MEANINGFUL_TRADES} trades")
75
+ return out.reset_index(drop=True)
76
+
77
+
78
+ def scorecard_table(sc: pd.DataFrame) -> pd.DataFrame:
79
+ cols = ["Model", "Asset", "TF", "n", "Coverage", "Cal. error",
80
+ "Dir. accuracy", "vs momentum", "vs random", "Band width"]
81
+ if sc is None or sc.empty:
82
+ return pd.DataFrame(columns=cols)
83
+ out = pd.DataFrame({
84
+ "Model": sc["model_display"],
85
+ "Asset": sc["asset"], "TF": sc["timeframe"],
86
+ "n": sc["n"].map(count),
87
+ "Coverage": sc["coverage_q10_q90"].map(lambda v: pct(v, 1, signed=False)),
88
+ "Cal. error": sc["calibration_error"].map(lambda v: pct(v, 1)),
89
+ "Dir. accuracy": sc["directional_accuracy"].map(
90
+ lambda v: pct(v, 1, signed=False)),
91
+ "vs momentum": sc["beats_momentum"].map(lambda v: pct(v, 1)),
92
+ "vs random": sc["beats_random"].map(lambda v: pct(v, 1)),
93
+ "Band width": sc["band_width_pct"].map(lambda v: pct(v, 2, signed=False)),
94
+ })
95
+ return out.reset_index(drop=True)
96
+
97
+
98
+ def runs_table(session_runs, saved: pd.DataFrame) -> pd.DataFrame:
99
+ """Session runs and store-persisted runs in one list."""
100
+ cols = ["When", "Source", "Label", "Strategy", "Asset", "TF",
101
+ "Return", "Sharpe", "OOS Sharpe", "Max DD", "Trades"]
102
+ rows = []
103
+ for r in (session_runs or []):
104
+ m = r.result.metrics_all
105
+ rows.append({
106
+ "When": r.created_at.replace("T", " ")[:16], "Source": "this session",
107
+ "Label": r.label, "Strategy": r.request.strategy,
108
+ "Asset": r.request.asset, "TF": r.request.timeframe,
109
+ "Return": pct(m.total_return), "Sharpe": num(m.sharpe),
110
+ "OOS Sharpe": num(r.result.metrics_oos.sharpe)
111
+ if r.result.metrics_oos.bars else EM,
112
+ "Max DD": pct(m.max_drawdown), "Trades": count(m.trade_count),
113
+ })
114
+ if saved is not None and not saved.empty:
115
+ for _, r in saved.iterrows():
116
+ rows.append({
117
+ "When": str(r.get("created_at", "")).replace("T", " ")[:16],
118
+ "Source": "signal store", "Label": r.get("label", ""),
119
+ "Strategy": r.get("strategy", ""), "Asset": r.get("asset", ""),
120
+ "TF": r.get("timeframe", ""),
121
+ "Return": pct(r.get("total_return")), "Sharpe": num(r.get("sharpe")),
122
+ "OOS Sharpe": num(r.get("oos_sharpe")),
123
+ "Max DD": pct(r.get("max_drawdown")),
124
+ "Trades": count(r.get("trades")),
125
+ })
126
+ if not rows:
127
+ return pd.DataFrame(columns=cols)
128
+ return pd.DataFrame(rows, columns=cols).sort_values("When", ascending=False)
129
+
130
+
131
+ # --------------------------------------------------------------------------
132
+ # View builders
133
+ # --------------------------------------------------------------------------
134
+
135
+
136
+ def build_leaderboard_view(store, *, assets, timeframes, strategies_, models,
137
+ metric_label, min_trades, hide_baselines,
138
+ require_oos, top_n):
139
+ """Everything the Leaderboard sub-tab renders, in one pass."""
140
+ lb = catalog.load_leaderboard(store)
141
+ if lb.empty:
142
+ empty = charts.empty_figure("catalog not generated yet")
143
+ return (C.note("The catalog has not been generated yet. Run "
144
+ "<code>scripts/seed_store.py</code> or extend coverage.",
145
+ danger=True),
146
+ pd.DataFrame(), empty, empty, C.micro("no rows"))
147
+
148
+ metric = RANK_METRICS.get(metric_label, "oos_sharpe")
149
+ filtered = catalog.filter_leaderboard(
150
+ lb, assets=assets or None, timeframes=timeframes or None,
151
+ strategies_=strategies_ or None, models=models or None,
152
+ min_trades=int(min_trades or 0), require_oos=bool(require_oos),
153
+ hide_baselines=bool(hide_baselines))
154
+
155
+ # Every metric here ranks high-to-low, drawdown included: drawdowns are
156
+ # stored as negative numbers, so -0.10 sorts above -0.50 already and
157
+ # "least bad" falls out of the default ordering.
158
+ ascending = False
159
+ top = int(top_n or 25)
160
+
161
+ # Ranking prefers evidence. Rows with too few trades are excluded, and only
162
+ # if that leaves nothing at all do we fall back to showing thin results --
163
+ # an empty board would be less honest than a flagged one.
164
+ ranked = catalog.rank(filtered, metric, top=top, ascending=ascending,
165
+ significant_only=True)
166
+ if ranked.empty:
167
+ ranked = catalog.rank(filtered, metric, top=top, ascending=ascending)
168
+
169
+ curves = catalog.curves_for(catalog.load_equity_curves(store),
170
+ list(ranked["key"])[:12])
171
+ labels = {}
172
+ for _, r in ranked.iterrows():
173
+ k = r["key"]
174
+ if k in curves:
175
+ name = f"{r['strategy']} · {r['asset']} {r['timeframe']}"
176
+ if r.get("model_slug"):
177
+ name += f" · {r['model_display']}"
178
+ labels[name] = curves[k]
179
+
180
+ thin = int((~filtered["significant"]).sum()) if "significant" in filtered else 0
181
+ meta = C.micro(
182
+ f"{len(filtered)} of {len(lb)} rows match · ranked by {metric_label} · "
183
+ f"showing top {len(ranked)}"
184
+ + (f" · {thin} rows below {catalog.MIN_MEANINGFUL_TRADES} trades excluded "
185
+ f"from ranking" if thin else ""))
186
+
187
+ return (C.podium(ranked, metric),
188
+ leaderboard_table(ranked),
189
+ charts.multi_return_overlay(labels),
190
+ charts.risk_return_scatter(filtered),
191
+ meta)
192
+
193
+
194
+ def build_models_view(store, timeframe: str | None):
195
+ sc = catalog.load_scorecard(store)
196
+ lb = catalog.load_leaderboard(store)
197
+ tf = None if timeframe in (None, "", "all") else timeframe
198
+ sub = sc if tf is None else sc[sc["timeframe"] == tf]
199
+ return (C.scorecard_note(sub),
200
+ charts.model_accuracy_bars(sc, timeframe=tf),
201
+ charts.calibration_scatter(sub),
202
+ charts.model_leaderboard_bars(lb),
203
+ scorecard_table(sub.sort_values("directional_accuracy", ascending=False)
204
+ if not sub.empty else sub))
205
+
206
+
207
+ def build_signals_view(store, asset: str, timeframe: str):
208
+ sc = catalog.load_scorecard(store)
209
+ cons = catalog.model_consensus(sc, store, asset, timeframe)
210
+ verdict = catalog.consensus_verdict(cons)
211
+ return C.consensus_panel(cons, verdict, asset, timeframe)
src/ui/components.py ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTML fragments in the Bit design language.
2
+
3
+ Gradio gives us controls; the design needs panels, chips, stat bands and
4
+ micro-labels that Gradio has no equivalent for. Those live here as small pure
5
+ functions returning markup, so `app.py` stays layout and wiring only.
6
+
7
+ Every fragment uses design-system tokens (`var(--...)`) rather than raw colour,
8
+ so a theme change in `assets/tokens/colors.css` propagates without edits here.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import pandas as pd
14
+
15
+ from .. import config
16
+ from .format import EM, arrow, count, esc, money, num, pct, seg, sharpe_tone, tone
17
+
18
+ # --------------------------------------------------------------------------
19
+ # Primitives
20
+ # --------------------------------------------------------------------------
21
+
22
+
23
+ def micro(text: str, color: str = "var(--text-tertiary)") -> str:
24
+ return f'<div class="bit-micro" style="color:{color}">{esc(text)}</div>'
25
+
26
+
27
+ def chip(text: str, kind: str = "") -> str:
28
+ cls = {"ok": " bit-chip-ok", "run": " bit-chip-run",
29
+ "warn": " bit-chip-warn", "accent": " bit-chip-accent"}.get(kind, "")
30
+ return f'<span class="bit-chip{cls}">{esc(text)}</span>'
31
+
32
+
33
+ def note(text: str, danger: bool = False) -> str:
34
+ cls = "bit-note bit-note-danger" if danger else "bit-note"
35
+ return f'<div class="{cls}">{text}</div>'
36
+
37
+
38
+ def panel_head(title: str, meta: str = "", right: str = "") -> str:
39
+ m = f'<span class="bit-micro">{esc(meta)}</span>' if meta else ""
40
+ r = f'<span style="margin-left:auto">{right}</span>' if right else ""
41
+ return (f'<div class="bit-panel-head"><span class="bit-h3">{esc(title)}</span>'
42
+ f'{m}{r}</div>')
43
+
44
+
45
+ def panel(title: str, body: str, meta: str = "", right: str = "") -> str:
46
+ return f'<div class="bit-panel">{panel_head(title, meta, right)}{body}</div>'
47
+
48
+
49
+ def kbd(text: str) -> str:
50
+ return f'<span class="bit-kbd">{esc(text)}</span>'
51
+
52
+
53
+ # --------------------------------------------------------------------------
54
+ # Top bar & footer
55
+ # --------------------------------------------------------------------------
56
+
57
+
58
+ def top_bar(status_text: str = "NO RUN LOADED", status_kind: str = "",
59
+ context: str = "", elapsed: str = "") -> str:
60
+ ctx = f'<span class="bit-chip bit-chip-ctx">{esc(context)}</span>' if context else ""
61
+ el = f'<span class="bit-micro" style="margin:0 4px">{esc(elapsed)}</span>' if elapsed else ""
62
+ return f"""
63
+ <div class="bit-topbar">
64
+ <span class="bit-mark"></span>
65
+ <span class="bit-wordmark">BIT</span>
66
+ <span class="bit-slash">/</span>
67
+ <span class="bit-h1">Backtest Lab</span>
68
+ {ctx}
69
+ <span class="bit-spacer"></span>
70
+ {el}{chip(status_text, status_kind)}
71
+ <a class="bit-chip bit-link" target="_blank" rel="noopener"
72
+ href="https://huggingface.co/datasets/{config.STORE_REPO}">SIGNAL STORE ↗</a>
73
+ <a class="bit-chip bit-link" target="_blank" rel="noopener"
74
+ href="https://huggingface.co/spaces/{config.SPACE_REPO}">SPACE ↗</a>
75
+ </div>"""
76
+
77
+
78
+ def footer() -> str:
79
+ return f"""
80
+ <div class="bit-footer">
81
+ <span>{esc(config.DISCLAIMER)}</span>
82
+ <span class="bit-footer-right">BITTRADING BACKTEST LAB v1.1.0</span>
83
+ </div>"""
84
+
85
+
86
+ # --------------------------------------------------------------------------
87
+ # Empty & loading states
88
+ # --------------------------------------------------------------------------
89
+
90
+
91
+ def empty_state() -> str:
92
+ return f"""
93
+ <div class="bit-empty">
94
+ <div class="bit-empty-glyph">◴</div>
95
+ <div class="bit-h2">No run loaded</div>
96
+ <div class="bit-empty-copy">
97
+ Configure a strategy on the left, or start from a worked example and edit it.
98
+ </div>
99
+ <div class="bit-kbd-row">
100
+ {kbd("RUN BACKTEST")}{kbd("COSTS DEFAULT ON")}{kbd("FILLS AT NEXT BAR OPEN")}
101
+ </div>
102
+ </div>"""
103
+
104
+
105
+ STAGE_LABELS = ("Reading cached slices", "Simulating trades",
106
+ "Walking forward", "Computing robustness")
107
+
108
+
109
+ def loading_stages(active: int = 0) -> str:
110
+ rows = []
111
+ for i, label in enumerate(STAGE_LABELS):
112
+ mark = "✓" if i < active else ("▸" if i == active else "·")
113
+ color = ("var(--accent-moss-strong)" if i < active else
114
+ "var(--text-primary)" if i == active else "var(--text-tertiary)")
115
+ rows.append(f'<div style="color:{color};font-size:11px">{mark} {esc(label)}</div>')
116
+ return f'<div class="bit-panel">{"".join(rows)}</div>'
117
+
118
+
119
+ # --------------------------------------------------------------------------
120
+ # Stat band
121
+ # --------------------------------------------------------------------------
122
+
123
+
124
+ def stat_band(rec) -> str:
125
+ """The design's stat band: big mono number, tiny label, IS/OOS underneath."""
126
+ if rec is None:
127
+ return ""
128
+ r = rec.result
129
+ a, i, o = r.metrics_all, r.metrics_is, r.metrics_oos
130
+
131
+ def isoos(fmt, key, *fargs):
132
+ return (f"IS {seg(i, fmt, getattr(i, key), *fargs)} · "
133
+ f"OOS {seg(o, fmt, getattr(o, key), *fargs)}")
134
+
135
+ bench = (float(r.benchmark_equity.iloc[-1] / r.benchmark_equity.iloc[0] - 1.0)
136
+ if len(r.benchmark_equity) else float("nan"))
137
+ gap = a.total_return - bench if pd.notna(bench) else float("nan")
138
+
139
+ cells = [
140
+ ("Total return", f"{pct(a.total_return)}{arrow(a.total_return)}",
141
+ tone(a.total_return), isoos(pct, "total_return"),
142
+ "Cumulative return of the strategy equity curve, costs included."),
143
+ ("CAGR", pct(a.cagr), tone(a.cagr), isoos(pct, "cagr"),
144
+ "Compound annual growth rate implied by the equity curve."),
145
+ ("Sharpe", num(a.sharpe), tone(a.sharpe), isoos(num, "sharpe"),
146
+ "Annualized mean excess return over return volatility."),
147
+ ("Sortino", num(a.sortino), tone(a.sortino), isoos(num, "sortino"),
148
+ "Sharpe with only downside deviation in the denominator."),
149
+ ("Max drawdown", pct(a.max_drawdown), "bit-down",
150
+ isoos(pct, "max_drawdown"), "Worst peak-to-trough decline."),
151
+ ("Win rate", pct(a.win_rate, 0, signed=False), "",
152
+ f"IS {seg(i, pct, i.win_rate, 0, False)} · OOS {seg(o, pct, o.win_rate, 0, False)}",
153
+ "Share of closed trades with positive net P&L."),
154
+ ("Profit factor", num(a.profit_factor), tone(a.profit_factor - 1.0),
155
+ isoos(num, "profit_factor"), "Gross profit over gross loss."),
156
+ ("Trades", count(a.trade_count), "",
157
+ f"IS {seg(i, count, i.trade_count)} · OOS {seg(o, count, o.trade_count)}",
158
+ "Closed round-trip trades."),
159
+ ("Exposure", pct(a.exposure, 0, signed=False), "",
160
+ f"IS {seg(i, pct, i.exposure, 0, False)} · OOS {seg(o, pct, o.exposure, 0, False)}",
161
+ "Fraction of bars holding a position."),
162
+ ("vs buy & hold", f"{pct(gap)}{arrow(gap)}", tone(gap),
163
+ f"costs paid {money(r.costs_paid)}",
164
+ "Strategy return minus buy-and-hold over the same window."),
165
+ ]
166
+
167
+ html = ['<div class="bit-statband">']
168
+ for label, value, cls, sub, tip in cells:
169
+ html.append(
170
+ f'<div class="bit-stat" title="{esc(tip)}">'
171
+ f'<div class="bit-stat-label">{esc(label)}</div>'
172
+ f'<div class="bit-stat-value {cls}">{value}</div>'
173
+ f'<div class="bit-stat-sub">{sub}</div></div>')
174
+ html.append("</div>")
175
+
176
+ for n in getattr(r.plan, "notes", []):
177
+ html.append(note(esc(n), danger=True))
178
+ if r.metrics_holdout is not None:
179
+ h = r.metrics_holdout
180
+ ok = h.total_return > 0
181
+ html.append(note(
182
+ f'<b>LOCKED HOLDOUT</b> · return {pct(h.total_return)} · '
183
+ f'Sharpe {num(h.sharpe)} · {h.bars} bars never used for any parameter '
184
+ f'choice.' + ("" if ok else " <b>It loses money here.</b>"),
185
+ danger=not ok))
186
+ return "".join(html)
187
+
188
+
189
+ # --------------------------------------------------------------------------
190
+ # Run manager
191
+ # --------------------------------------------------------------------------
192
+
193
+
194
+ def run_card(rec, selected: bool = False) -> str:
195
+ s = rec.sharpe
196
+ color = sharpe_tone(s)
197
+ mark = "✓" if selected else ""
198
+ return (
199
+ f'<div class="bit-run-card{" bit-run-card-sel" if selected else ""}">'
200
+ f'<div class="bit-run-top">'
201
+ f'<span class="bit-run-name">{esc(rec.label[:36])}</span>'
202
+ f'<span class="bit-run-mark">{mark}</span></div>'
203
+ f'<div class="bit-micro">{esc(rec.meta)}</div>'
204
+ f'<div class="bit-run-sharpe" style="color:{color}">'
205
+ f'SHARPE {num(s)}<span class="bit-run-ret">{pct(rec.result.metrics_all.total_return)}</span>'
206
+ f'</div></div>')
207
+
208
+
209
+ def run_history(hist, selected_ids: set[str] | None = None) -> str:
210
+ if not hist:
211
+ return micro("no runs yet in this session")
212
+ sel = selected_ids or set()
213
+ head = micro(f"{len(hist)} run{'s' if len(hist) != 1 else ''} this session")
214
+ return head + "".join(run_card(r, r.run_id in sel) for r in hist[:14])
215
+
216
+
217
+ def glossary(items) -> str:
218
+ rows = "".join(
219
+ f'<div class="bit-gloss"><div class="bit-micro">{esc(t)}</div>'
220
+ f'<div class="bit-gloss-def">{esc(d)}</div></div>'
221
+ for t, d in items)
222
+ return f'<div class="bit-panel">{rows}</div>'
223
+
224
+
225
+ # --------------------------------------------------------------------------
226
+ # Coverage
227
+ # --------------------------------------------------------------------------
228
+
229
+
230
+ def coverage_summary(cells) -> str:
231
+ if not cells:
232
+ return note("The signal store has no coverage yet.", danger=True)
233
+ models = sorted({c.model_slug for c in cells})
234
+ assets = sorted({c.asset for c in cells})
235
+ tfs = sorted({c.timeframe for c in cells})
236
+ rows = sum(c.rows for c in cells)
237
+ ph = sum(1 for c in cells if c.is_placeholder)
238
+ body = (
239
+ f'<div class="bit-kpi-row">'
240
+ f'{_kpi(len(cells), "slices")}{_kpi(len(models), "models")}'
241
+ f'{_kpi(len(assets), "assets")}{_kpi(len(tfs), "timeframes")}'
242
+ f'{_kpi(f"{rows:,}", "signal rows")}'
243
+ f'{_kpi(ph, "placeholder", danger=ph > 0)}</div>')
244
+ return panel("Coverage", body, meta="SIGNAL STORE")
245
+
246
+
247
+ def _kpi(value, label, danger: bool = False) -> str:
248
+ color = "var(--fin-down)" if danger else "var(--text-primary)"
249
+ return (f'<div class="bit-kpi"><div class="bit-kpi-value" style="color:{color}">'
250
+ f'{esc(value)}</div><div class="bit-stat-label">{esc(label)}</div></div>')
251
+
252
+
253
+ # --------------------------------------------------------------------------
254
+ # Leaderboard & consensus (the global compare interface)
255
+ # --------------------------------------------------------------------------
256
+
257
+
258
+ def podium(df: pd.DataFrame, metric: str = "oos_sharpe") -> str:
259
+ """Top three rows of the leaderboard, called out above the table."""
260
+ if df is None or df.empty:
261
+ return micro("no catalog rows yet")
262
+ top = df.head(3)
263
+ cards = []
264
+ for rank, (_, r) in enumerate(top.iterrows(), start=1):
265
+ val = r.get(metric)
266
+ model = r.get("model_display") or EM
267
+ cards.append(
268
+ f'<div class="bit-podium bit-podium-{rank}">'
269
+ f'<div class="bit-podium-rank">#{rank}</div>'
270
+ f'<div class="bit-podium-name">{esc(r.get("strategy", ""))}</div>'
271
+ f'<div class="bit-micro">{esc(r.get("asset", ""))} · '
272
+ f'{esc(r.get("timeframe", ""))}'
273
+ + (f' · {esc(model)}' if r.get("model_slug") else "") + '</div>'
274
+ f'<div class="bit-podium-value" style="color:{sharpe_tone(val)}">'
275
+ f'{num(val)}<span class="bit-podium-unit">OOS SHARPE</span></div>'
276
+ f'<div class="bit-micro">ret {pct(r.get("total_return"))} · '
277
+ f'dd {pct(r.get("max_drawdown"))} · {count(r.get("trades"))} trades</div>'
278
+ f'</div>')
279
+ return f'<div class="bit-podium-row">{"".join(cards)}</div>'
280
+
281
+
282
+ def consensus_panel(consensus: pd.DataFrame, verdict: dict,
283
+ asset: str, timeframe: str) -> str:
284
+ """Signal Aggregator: every model's latest call for one asset, plus a
285
+ calibration-weighted consensus.
286
+
287
+ Weight is how far a model's realised directional accuracy sits above a coin
288
+ flip on this exact slice, so a confident but historically wrong model does
289
+ not get to shout.
290
+ """
291
+ if consensus is None or consensus.empty:
292
+ return note(f"No model signals cached for {esc(asset)} {esc(timeframe)}.")
293
+
294
+ rows = []
295
+ for _, r in consensus.iterrows():
296
+ d = r["direction"]
297
+ dcolor = ("var(--fin-up-strong)" if d == "LONG" else
298
+ "var(--fin-down-strong)" if d == "SHORT" else "var(--text-tertiary)")
299
+ glyph = "▲" if d == "LONG" else ("▼" if d == "SHORT" else "■")
300
+ tag = ' <span class="bit-tag">BASELINE</span>' if r["is_baseline"] else ""
301
+ acc = r["directional_accuracy"]
302
+ rows.append(
303
+ f'<div class="bit-sig-row">'
304
+ f'<span class="bit-sig-name">{esc(r["model"])}{tag}</span>'
305
+ f'<span class="bit-sig-dir" style="color:{dcolor}">{glyph} {d}</span>'
306
+ f'<span class="bit-sig-edge" style="color:{dcolor}">{pct(r["edge"], 2)}</span>'
307
+ f'<span class="bit-sig-acc">acc {pct(acc, 0, signed=False)}</span>'
308
+ f'<span class="bit-sig-w">w{num(r["weight"])}</span>'
309
+ f'</div>')
310
+
311
+ d = verdict["direction"]
312
+ vcolor = ("var(--fin-up-strong)" if d == "LONG" else
313
+ "var(--fin-down-strong)" if d == "SHORT" else "var(--text-secondary)")
314
+ glyph = "▲" if d == "LONG" else ("▼" if d == "SHORT" else "■")
315
+ conf = verdict["confidence"]
316
+
317
+ verdict_box = (
318
+ f'<div class="bit-consensus">'
319
+ f'<div class="bit-micro">{esc(asset)} · {esc(timeframe)}</div>'
320
+ f'<div class="bit-consensus-dir" style="color:{vcolor}">{d} {glyph}</div>'
321
+ f'<div class="bit-micro">WEIGHTED EDGE {pct(verdict["edge"], 2)}</div>'
322
+ f'<div class="bit-conf-track"><div class="bit-conf-fill" '
323
+ f'style="width:{conf * 100:.0f}%;background:{vcolor}"></div></div>'
324
+ f'<div class="bit-micro">{verdict["agree"]}/{verdict["n_models"]} MODELS AGREE</div>'
325
+ f'</div>')
326
+
327
+ return panel(
328
+ "Signal aggregator",
329
+ f'<div class="bit-sig-grid"><div class="bit-sig-list">{"".join(rows)}</div>'
330
+ f'{verdict_box}</div>'
331
+ + micro("weight = realised directional accuracy above a coin flip, "
332
+ "on this asset and timeframe"),
333
+ meta=f"{len(consensus)} MODELS",
334
+ )
335
+
336
+
337
+ def scorecard_note(sc: pd.DataFrame) -> str:
338
+ """One honest sentence about whether the models beat the baselines."""
339
+ if sc is None or sc.empty:
340
+ return ""
341
+ learned = sc[~sc["is_baseline"]] if "is_baseline" in sc.columns else sc
342
+ base = sc[sc["is_baseline"]] if "is_baseline" in sc.columns else pd.DataFrame()
343
+ if learned.empty or base.empty:
344
+ return ""
345
+ la = learned["directional_accuracy"].mean()
346
+ ba = base["directional_accuracy"].mean()
347
+ if pd.isna(la) or pd.isna(ba):
348
+ return ""
349
+ delta = la - ba
350
+ if delta > 0.01:
351
+ return note(f"Learned models call direction correctly "
352
+ f"{pct(la, 1, signed=False)} of the time against "
353
+ f"{pct(ba, 1, signed=False)} for naive baselines "
354
+ f"({pct(delta, 1)} better).")
355
+ return note(f"Learned models call direction correctly "
356
+ f"{pct(la, 1, signed=False)} of the time; naive baselines manage "
357
+ f"{pct(ba, 1, signed=False)}. <b>The models are not beating the "
358
+ f"baselines on direction.</b> Read the leaderboard with that in mind.",
359
+ danger=True)
src/ui/format.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Value formatting shared by every part of the UI.
2
+
3
+ One rule runs through all of it: a number that does not exist must never render
4
+ as a number that does. Empty segments become an em dash, not `0.00`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import pandas as pd
10
+
11
+ EM = "—"
12
+
13
+
14
+ def pct(v, digits: int = 1, signed: bool = True) -> str:
15
+ if v is None or pd.isna(v):
16
+ return EM
17
+ return f"{v * 100:+.{digits}f}%" if signed else f"{v * 100:.{digits}f}%"
18
+
19
+
20
+ def num(v, digits: int = 2) -> str:
21
+ if v is None or pd.isna(v):
22
+ return EM
23
+ return f"{v:.{digits}f}"
24
+
25
+
26
+ def money(v) -> str:
27
+ if v is None or pd.isna(v):
28
+ return EM
29
+ return f"${v:,.0f}"
30
+
31
+
32
+ def count(v) -> str:
33
+ if v is None or pd.isna(v):
34
+ return EM
35
+ return f"{int(v):,}"
36
+
37
+
38
+ def seg(metrics, fmt, *args, **kwargs) -> str:
39
+ """Format a segment metric, or an em dash when that segment has no bars.
40
+
41
+ "The out-of-sample Sharpe is zero" and "there is no out-of-sample period"
42
+ are different claims. Only one of them is ever true here.
43
+ """
44
+ if metrics is None or getattr(metrics, "bars", 0) == 0:
45
+ return EM
46
+ return fmt(*args, **kwargs)
47
+
48
+
49
+ def tone(v) -> str:
50
+ """CSS class for a signed value."""
51
+ if v is None or pd.isna(v) or v == 0:
52
+ return ""
53
+ return "bit-up" if v > 0 else "bit-down"
54
+
55
+
56
+ def arrow(v) -> str:
57
+ """Direction as a glyph, so colour is never the only encoding."""
58
+ if v is None or pd.isna(v) or v == 0:
59
+ return ""
60
+ return " ▲" if v > 0 else " ▼"
61
+
62
+
63
+ def sharpe_tone(v) -> str:
64
+ if v is None or pd.isna(v):
65
+ return "var(--text-tertiary)"
66
+ if v >= 1.0:
67
+ return "var(--accent-moss-strong)"
68
+ if v < 0:
69
+ return "var(--fin-down)"
70
+ return "var(--text-secondary)"
71
+
72
+
73
+ def esc(s) -> str:
74
+ """Minimal HTML escaping for values interpolated into markup."""
75
+ return (str(s).replace("&", "&amp;").replace("<", "&lt;")
76
+ .replace(">", "&gt;").replace('"', "&quot;"))
src/ui/theme.py CHANGED
@@ -52,100 +52,212 @@ def _tokens_css() -> str:
52
 
53
 
54
  SHELL_CSS = """
55
- /* ---------- app shell ---------- */
56
  .gradio-container{
57
  max-width:100% !important; padding:0 !important;
58
  background:var(--bg-canvas) !important;
59
  font-family:var(--font-body); font-weight:var(--weight-body);
60
- color:var(--text-primary);
61
  }
62
  .gradio-container *{ border-radius:var(--radius-sm) !important; }
63
  footer{ display:none !important; }
 
64
 
65
- /* ---------- typography ---------- */
66
  .bit-h1,.bit-h2,.bit-h3{
67
  font-family:var(--font-heading); font-weight:var(--weight-heading);
68
  letter-spacing:var(--tracking-wide); text-transform:uppercase;
69
  color:var(--text-primary); margin:0;
70
  }
71
- .bit-h1{ font-size:var(--text-lg); }
72
  .bit-h2{ font-size:var(--text-md); }
73
  .bit-h3{ font-size:var(--text-base); }
74
  .bit-micro{
75
  font-family:var(--font-tiny); font-size:var(--text-xs);
76
  letter-spacing:var(--tracking-wider); text-transform:uppercase;
77
- color:var(--text-tertiary);
78
  }
79
- .bit-mono{ font-family:var(--font-mono); font-size:var(--text-base); }
80
 
81
- /* ---------- panels ---------- */
82
- .bit-panel{
83
- background:var(--bg-panel); border:var(--border-width) solid var(--border-default);
84
- padding:var(--space-4); margin-bottom:var(--space-3);
 
 
 
85
  }
86
- .bit-panel-head{
87
- display:flex; align-items:center; gap:var(--space-3);
88
- border-bottom:var(--border-width) solid var(--border-subtle);
89
- padding-bottom:var(--space-2); margin-bottom:var(--space-3);
90
  }
91
-
92
- /* ---------- top bar ---------- */
93
- .bit-topbar{
94
- display:flex; align-items:center; gap:var(--space-4);
95
- background:var(--bg-panel); border-bottom:var(--border-width) solid var(--border-default);
96
- padding:var(--space-3) var(--space-4); position:sticky; top:0; z-index:var(--z-header);
97
  }
98
- .bit-mark{ width:22px; height:22px; background:var(--accent-amber); display:inline-block; }
99
  .bit-chip{
100
- display:inline-flex; align-items:center; gap:6px;
101
  font-family:var(--font-mono); font-size:var(--text-sm);
102
  border:var(--border-width) solid var(--border-default);
103
- padding:3px var(--space-2); color:var(--text-secondary);
104
  }
105
  .bit-chip-ok{ color:var(--accent-moss-strong); border-color:var(--accent-moss-dim); }
106
  .bit-chip-run{ color:var(--accent-amber-strong); border-color:var(--accent-amber-dim); }
107
  .bit-chip-warn{ color:var(--fin-down); border-color:var(--fin-down); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
- /* ---------- stat band ---------- */
110
- .bit-statband{ display:flex; flex-wrap:wrap; border:var(--border-width) solid var(--border-default); }
 
 
 
 
111
  .bit-stat{
112
- flex:1 1 150px; padding:var(--space-3) var(--space-4);
113
  border-right:var(--border-width) solid var(--border-subtle);
 
114
  }
115
- .bit-stat:last-child{ border-right:none; }
116
  .bit-stat-label{
117
  font-family:var(--font-tiny); font-size:var(--text-xs);
118
- letter-spacing:var(--tracking-wider); text-transform:uppercase; color:var(--text-tertiary);
 
119
  }
120
  .bit-stat-value{
121
  font-family:var(--font-mono); font-size:var(--text-xl);
122
- line-height:var(--leading-tight); color:var(--text-primary); margin:2px 0;
 
 
 
 
 
123
  }
124
- .bit-stat-sub{ font-family:var(--font-mono); font-size:var(--text-xs); color:var(--text-tertiary); }
125
  .bit-up{ color:var(--fin-up-strong); } .bit-down{ color:var(--fin-down-strong); }
126
 
127
- /* ---------- warnings / notes ---------- */
128
  .bit-note{
129
- font-family:var(--font-mono); font-size:var(--text-sm);
130
  border-left:var(--border-width-strong) solid var(--accent-amber);
131
  background:var(--bg-raised); padding:var(--space-2) var(--space-3);
132
- color:var(--text-secondary);
133
  }
134
  .bit-note-danger{ border-left-color:var(--fin-down); }
 
 
 
 
 
135
 
136
- /* ---------- footer disclaimer (always visible) ---------- */
137
- .bit-footer{
138
- position:sticky; bottom:0; z-index:var(--z-header);
139
- display:flex; justify-content:space-between; gap:var(--space-4);
140
- background:var(--bg-panel); border-top:var(--border-width) solid var(--border-default);
141
- padding:var(--space-2) var(--space-4);
142
- font-family:var(--font-mono); font-size:var(--text-sm); color:var(--text-tertiary);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  }
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
- /* ---------- gradio overrides ---------- */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  .gradio-container .tabs > .tab-nav{
147
  border-bottom:var(--border-width) solid var(--border-default) !important;
148
- background:transparent !important; gap:0 !important;
149
  }
150
  .gradio-container .tabs > .tab-nav > button{
151
  font-family:var(--font-heading) !important; text-transform:uppercase;
@@ -154,6 +266,7 @@ footer{ display:none !important; }
154
  border:none !important; border-bottom:2px solid transparent !important;
155
  padding:var(--space-2) var(--space-4) !important;
156
  }
 
157
  .gradio-container .tabs > .tab-nav > button.selected{
158
  color:var(--text-primary) !important;
159
  border-bottom-color:var(--accent-amber) !important;
@@ -161,7 +274,7 @@ footer{ display:none !important; }
161
  .gradio-container .form, .gradio-container .block{
162
  background:transparent !important; border:none !important;
163
  }
164
- /* Field captions only -- scoped so it never swallows radio/checkbox option text. */
165
  .block > label > span, .block > .form > label > span{
166
  font-family:var(--font-tiny) !important; font-size:var(--text-xs) !important;
167
  letter-spacing:var(--tracking-wider) !important; text-transform:uppercase;
@@ -172,8 +285,11 @@ input, select, textarea{
172
  background:var(--bg-sunken) !important; color:var(--text-primary) !important;
173
  border:var(--border-width) solid var(--border-default) !important;
174
  }
 
 
 
175
 
176
- /* Radio / checkbox options read as segmented chips, per the design. */
177
  .gradio-container fieldset label,
178
  .gradio-container .wrap label:has(input[type="radio"]),
179
  .gradio-container .wrap label:has(input[type="checkbox"]){
@@ -192,8 +308,7 @@ input, select, textarea{
192
  .gradio-container fieldset label.selected,
193
  .gradio-container fieldset label:has(input:checked),
194
  .gradio-container .wrap label:has(input[type="radio"]:checked){
195
- background:var(--accent-amber) !important;
196
- border-color:var(--accent-amber) !important;
197
  }
198
  .gradio-container fieldset label.selected span,
199
  .gradio-container fieldset label:has(input:checked) span,
@@ -203,6 +318,22 @@ input, select, textarea{
203
  .gradio-container input[type="radio"], .gradio-container input[type="checkbox"]{
204
  accent-color:var(--accent-amber);
205
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  .bit-run-btn{
207
  background:var(--accent-amber) !important; color:var(--stone-950) !important;
208
  font-family:var(--font-heading) !important; text-transform:uppercase;
@@ -214,29 +345,34 @@ input, select, textarea{
214
  border:var(--border-width) solid var(--border-default) !important;
215
  font-family:var(--font-mono) !important; font-size:var(--text-sm) !important;
216
  }
217
- .bit-accordion{ border:var(--border-width) solid var(--border-subtle) !important; }
 
218
 
219
- /* ---------- tables ---------- */
220
  .bit-table table{ font-family:var(--font-mono) !important; font-size:var(--text-sm) !important; }
221
  .bit-table thead th{
222
  font-family:var(--font-tiny) !important; font-size:var(--text-xs) !important;
223
  letter-spacing:var(--tracking-wider); text-transform:uppercase;
224
  color:var(--text-tertiary) !important; background:var(--bg-raised) !important;
 
225
  }
 
226
 
227
- /* ---------- empty state ---------- */
228
- .bit-empty{
229
- display:flex; flex-direction:column; align-items:center; justify-content:center;
230
- gap:var(--space-3); padding:var(--space-9) var(--space-4); text-align:center;
231
- border:var(--border-width) dashed var(--border-default); background:var(--bg-panel);
232
- }
233
- .bit-kbd{
234
- font-family:var(--font-mono); font-size:var(--text-xs); color:var(--text-tertiary);
235
- border:var(--border-width) solid var(--border-default); padding:2px 6px;
236
  }
 
 
237
  @media (max-width: 900px){
238
  .bit-statband{ flex-direction:column; }
239
- .bit-stat{ border-right:none; border-bottom:var(--border-width) solid var(--border-subtle); }
 
 
240
  }
241
  """
242
 
 
52
 
53
 
54
  SHELL_CSS = """
55
+ /* ================= app shell ================= */
56
  .gradio-container{
57
  max-width:100% !important; padding:0 !important;
58
  background:var(--bg-canvas) !important;
59
  font-family:var(--font-body); font-weight:var(--weight-body);
60
+ color:var(--text-primary); font-size:var(--text-base);
61
  }
62
  .gradio-container *{ border-radius:var(--radius-sm) !important; }
63
  footer{ display:none !important; }
64
+ .gap.svelte-vt1mxs, .gradio-container .gap{ gap:var(--space-2) !important; }
65
 
66
+ /* ================= typography ================= */
67
  .bit-h1,.bit-h2,.bit-h3{
68
  font-family:var(--font-heading); font-weight:var(--weight-heading);
69
  letter-spacing:var(--tracking-wide); text-transform:uppercase;
70
  color:var(--text-primary); margin:0;
71
  }
72
+ .bit-h1{ font-size:var(--text-md); }
73
  .bit-h2{ font-size:var(--text-md); }
74
  .bit-h3{ font-size:var(--text-base); }
75
  .bit-micro{
76
  font-family:var(--font-tiny); font-size:var(--text-xs);
77
  letter-spacing:var(--tracking-wider); text-transform:uppercase;
78
+ color:var(--text-tertiary); line-height:1.6;
79
  }
80
+ .bit-spacer{ flex:1; }
81
 
82
+ /* ================= top bar ================= */
83
+ .bit-topbar{
84
+ display:flex; align-items:center; gap:var(--space-3); flex-wrap:wrap;
85
+ background:var(--bg-panel);
86
+ border-bottom:var(--border-width) solid var(--border-default);
87
+ padding:var(--space-3) var(--space-4);
88
+ position:sticky; top:0; z-index:var(--z-header);
89
  }
90
+ .bit-mark{
91
+ width:20px; height:20px; background:var(--accent-amber);
92
+ display:inline-block; flex:0 0 20px;
93
+ box-shadow:inset 0 0 0 3px var(--bg-panel), inset 0 0 0 6px var(--accent-amber);
94
  }
95
+ .bit-wordmark{
96
+ font-family:var(--font-heading); font-size:var(--text-md); font-weight:500;
97
+ letter-spacing:var(--tracking-wide); color:var(--text-primary);
 
 
 
98
  }
99
+ .bit-slash{ color:var(--text-tertiary); }
100
  .bit-chip{
101
+ display:inline-flex; align-items:center; gap:6px; white-space:nowrap;
102
  font-family:var(--font-mono); font-size:var(--text-sm);
103
  border:var(--border-width) solid var(--border-default);
104
+ padding:3px var(--space-2); color:var(--text-secondary); background:transparent;
105
  }
106
  .bit-chip-ok{ color:var(--accent-moss-strong); border-color:var(--accent-moss-dim); }
107
  .bit-chip-run{ color:var(--accent-amber-strong); border-color:var(--accent-amber-dim); }
108
  .bit-chip-warn{ color:var(--fin-down); border-color:var(--fin-down); }
109
+ .bit-chip-accent{ color:var(--stone-950); background:var(--accent-amber);
110
+ border-color:var(--accent-amber); }
111
+ .bit-chip-ctx{ color:var(--text-tertiary); }
112
+ a.bit-link{ text-decoration:none; }
113
+ a.bit-link:hover{ color:var(--accent-amber-strong);
114
+ border-color:var(--accent-amber-dim); }
115
+
116
+ /* ================= panels ================= */
117
+ .bit-panel{
118
+ background:var(--bg-panel);
119
+ border:var(--border-width) solid var(--border-default);
120
+ padding:var(--space-4); margin-bottom:var(--space-3);
121
+ }
122
+ .bit-panel-head{
123
+ display:flex; align-items:baseline; gap:var(--space-3);
124
+ border-bottom:var(--border-width) solid var(--border-subtle);
125
+ padding-bottom:var(--space-2); margin-bottom:var(--space-3);
126
+ }
127
+ .bit-zone-title{
128
+ font-family:var(--font-heading); text-transform:uppercase;
129
+ letter-spacing:var(--tracking-wide); font-size:var(--text-md);
130
+ color:var(--text-primary); padding:var(--space-3) var(--space-1) var(--space-2);
131
+ display:flex; align-items:baseline; gap:var(--space-2);
132
+ }
133
+ .bit-zone-title .bit-micro{ margin-left:auto; }
134
 
135
+ /* ================= stat band ================= */
136
+ .bit-statband{
137
+ display:flex; flex-wrap:wrap;
138
+ border:var(--border-width) solid var(--border-default);
139
+ background:var(--bg-panel); margin-bottom:var(--space-3);
140
+ }
141
  .bit-stat{
142
+ flex:1 1 132px; padding:var(--space-3) var(--space-4);
143
  border-right:var(--border-width) solid var(--border-subtle);
144
+ border-bottom:var(--border-width) solid var(--border-subtle);
145
  }
 
146
  .bit-stat-label{
147
  font-family:var(--font-tiny); font-size:var(--text-xs);
148
+ letter-spacing:var(--tracking-wider); text-transform:uppercase;
149
+ color:var(--text-tertiary);
150
  }
151
  .bit-stat-value{
152
  font-family:var(--font-mono); font-size:var(--text-xl);
153
+ line-height:var(--leading-tight); color:var(--text-primary);
154
+ margin:3px 0 2px; letter-spacing:var(--tracking-tight);
155
+ }
156
+ .bit-stat-sub{
157
+ font-family:var(--font-mono); font-size:var(--text-xs);
158
+ color:var(--text-tertiary);
159
  }
 
160
  .bit-up{ color:var(--fin-up-strong); } .bit-down{ color:var(--fin-down-strong); }
161
 
162
+ /* ================= notes ================= */
163
  .bit-note{
164
+ font-family:var(--font-mono); font-size:var(--text-sm); line-height:1.5;
165
  border-left:var(--border-width-strong) solid var(--accent-amber);
166
  background:var(--bg-raised); padding:var(--space-2) var(--space-3);
167
+ color:var(--text-secondary); margin-bottom:var(--space-2);
168
  }
169
  .bit-note-danger{ border-left-color:var(--fin-down); }
170
+ .bit-tag{
171
+ font-family:var(--font-tiny); font-size:var(--text-2xs);
172
+ letter-spacing:var(--tracking-wider); border:1px solid var(--border-default);
173
+ padding:1px 4px; color:var(--text-tertiary); margin-left:4px;
174
+ }
175
 
176
+ /* ================= empty state ================= */
177
+ .bit-empty{
178
+ display:flex; flex-direction:column; align-items:center; justify-content:center;
179
+ gap:var(--space-3); padding:var(--space-8) var(--space-4); text-align:center;
180
+ border:var(--border-width) dashed var(--border-default); background:var(--bg-panel);
181
+ }
182
+ .bit-empty-glyph{ font-size:34px; color:var(--accent-amber-dim); line-height:1; }
183
+ .bit-empty-copy{ color:var(--text-secondary); max-width:46ch; font-size:var(--text-base); }
184
+ .bit-kbd-row{ display:flex; gap:var(--space-2); flex-wrap:wrap; justify-content:center; }
185
+ .bit-kbd{
186
+ font-family:var(--font-tiny); font-size:var(--text-xs);
187
+ letter-spacing:var(--tracking-wider); color:var(--text-tertiary);
188
+ border:var(--border-width) solid var(--border-default); padding:3px 7px;
189
+ }
190
+
191
+ /* ================= run manager ================= */
192
+ .bit-run-card{
193
+ border:var(--border-width) solid var(--border-subtle);
194
+ padding:var(--space-2); margin-bottom:var(--space-1); background:var(--bg-panel);
195
+ }
196
+ .bit-run-card-sel{ border-color:var(--accent-amber-dim); }
197
+ .bit-run-top{ display:flex; align-items:center; gap:var(--space-2); }
198
+ .bit-run-name{ font-size:var(--text-sm); color:var(--text-primary);
199
+ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
200
+ .bit-run-mark{ margin-left:auto; color:var(--accent-amber-strong); font-size:11px; }
201
+ .bit-run-sharpe{ font-family:var(--font-mono); font-size:var(--text-base);
202
+ display:flex; gap:var(--space-2); }
203
+ .bit-run-ret{ margin-left:auto; color:var(--text-tertiary); }
204
+ .bit-gloss{ margin-bottom:var(--space-2); }
205
+ .bit-gloss-def{ font-size:var(--text-sm); color:var(--text-secondary); line-height:1.45; }
206
+
207
+ /* ================= KPI row ================= */
208
+ .bit-kpi-row{ display:flex; flex-wrap:wrap; gap:var(--space-4); }
209
+ .bit-kpi{ min-width:78px; }
210
+ .bit-kpi-value{ font-family:var(--font-mono); font-size:var(--text-lg);
211
+ line-height:1.1; color:var(--text-primary); }
212
+
213
+ /* ================= podium ================= */
214
+ .bit-podium-row{ display:flex; gap:var(--space-3); flex-wrap:wrap;
215
+ margin-bottom:var(--space-3); }
216
+ .bit-podium{
217
+ flex:1 1 200px; border:var(--border-width) solid var(--border-default);
218
+ background:var(--bg-panel); padding:var(--space-3);
219
+ border-top:var(--border-width-strong) solid var(--border-strong);
220
  }
221
+ .bit-podium-1{ border-top-color:var(--accent-amber); }
222
+ .bit-podium-2{ border-top-color:var(--accent-moss-strong); }
223
+ .bit-podium-3{ border-top-color:var(--mute-teal); }
224
+ .bit-podium-rank{ font-family:var(--font-tiny); font-size:var(--text-xs);
225
+ color:var(--text-tertiary); letter-spacing:var(--tracking-wider); }
226
+ .bit-podium-name{ font-size:var(--text-md); color:var(--text-primary);
227
+ font-family:var(--font-heading); letter-spacing:var(--tracking-tight); }
228
+ .bit-podium-value{ font-family:var(--font-mono); font-size:var(--text-2xl);
229
+ line-height:1.1; margin-top:var(--space-2); display:flex;
230
+ align-items:baseline; gap:var(--space-2); }
231
+ .bit-podium-unit{ font-family:var(--font-tiny); font-size:var(--text-xs);
232
+ letter-spacing:var(--tracking-wider); color:var(--text-tertiary); }
233
 
234
+ /* ================= signal aggregator ================= */
235
+ .bit-sig-grid{ display:flex; gap:var(--space-4); flex-wrap:wrap; align-items:flex-start; }
236
+ .bit-sig-list{ flex:1 1 340px; min-width:0; }
237
+ .bit-sig-row{
238
+ display:flex; align-items:center; gap:var(--space-3);
239
+ border:var(--border-width) solid var(--border-subtle);
240
+ padding:5px var(--space-2); margin-bottom:3px;
241
+ font-family:var(--font-mono); font-size:var(--text-sm);
242
+ }
243
+ .bit-sig-name{ flex:1 1 auto; color:var(--text-secondary);
244
+ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
245
+ .bit-sig-dir{ width:74px; text-align:right; }
246
+ .bit-sig-edge{ width:64px; text-align:right; }
247
+ .bit-sig-acc,.bit-sig-w{ width:64px; text-align:right; color:var(--text-tertiary); }
248
+ .bit-consensus{
249
+ flex:0 0 210px; border:var(--border-width) solid var(--accent-moss-dim);
250
+ background:var(--bg-raised); padding:var(--space-3);
251
+ }
252
+ .bit-consensus-dir{ font-family:var(--font-heading); font-size:var(--text-2xl);
253
+ line-height:1.1; margin:var(--space-1) 0; letter-spacing:var(--tracking-tight); }
254
+ .bit-conf-track{ height:4px; background:var(--bg-sunken); margin:var(--space-2) 0 4px; }
255
+ .bit-conf-fill{ height:100%; }
256
+
257
+ /* ================= gradio overrides ================= */
258
  .gradio-container .tabs > .tab-nav{
259
  border-bottom:var(--border-width) solid var(--border-default) !important;
260
+ background:transparent !important; gap:0 !important; flex-wrap:wrap;
261
  }
262
  .gradio-container .tabs > .tab-nav > button{
263
  font-family:var(--font-heading) !important; text-transform:uppercase;
 
266
  border:none !important; border-bottom:2px solid transparent !important;
267
  padding:var(--space-2) var(--space-4) !important;
268
  }
269
+ .gradio-container .tabs > .tab-nav > button:hover{ color:var(--text-secondary) !important; }
270
  .gradio-container .tabs > .tab-nav > button.selected{
271
  color:var(--text-primary) !important;
272
  border-bottom-color:var(--accent-amber) !important;
 
274
  .gradio-container .form, .gradio-container .block{
275
  background:transparent !important; border:none !important;
276
  }
277
+ /* Field captions only -- scoped so it never swallows option text. */
278
  .block > label > span, .block > .form > label > span{
279
  font-family:var(--font-tiny) !important; font-size:var(--text-xs) !important;
280
  letter-spacing:var(--tracking-wider) !important; text-transform:uppercase;
 
285
  background:var(--bg-sunken) !important; color:var(--text-primary) !important;
286
  border:var(--border-width) solid var(--border-default) !important;
287
  }
288
+ input:focus, select:focus, textarea:focus{
289
+ outline:1px solid var(--focus-ring) !important; outline-offset:-1px;
290
+ }
291
 
292
+ /* Radio / checkbox options read as segmented chips. */
293
  .gradio-container fieldset label,
294
  .gradio-container .wrap label:has(input[type="radio"]),
295
  .gradio-container .wrap label:has(input[type="checkbox"]){
 
308
  .gradio-container fieldset label.selected,
309
  .gradio-container fieldset label:has(input:checked),
310
  .gradio-container .wrap label:has(input[type="radio"]:checked){
311
+ background:var(--accent-amber) !important; border-color:var(--accent-amber) !important;
 
312
  }
313
  .gradio-container fieldset label.selected span,
314
  .gradio-container fieldset label:has(input:checked) span,
 
318
  .gradio-container input[type="radio"], .gradio-container input[type="checkbox"]{
319
  accent-color:var(--accent-amber);
320
  }
321
+
322
+ /* Accordions read as the design's numbered sections. */
323
+ .gradio-container .label-wrap, .bit-accordion > .label-wrap{
324
+ font-family:var(--font-heading) !important; text-transform:uppercase;
325
+ letter-spacing:var(--tracking-wide); font-size:var(--text-base) !important;
326
+ color:var(--text-primary) !important;
327
+ border-bottom:var(--border-width) solid var(--border-subtle) !important;
328
+ padding:var(--space-2) 0 !important;
329
+ }
330
+ .bit-accordion{
331
+ border:none !important;
332
+ border-top:var(--border-width) solid var(--border-subtle) !important;
333
+ padding:0 !important; margin-bottom:var(--space-1) !important;
334
+ }
335
+
336
+ /* Buttons */
337
  .bit-run-btn{
338
  background:var(--accent-amber) !important; color:var(--stone-950) !important;
339
  font-family:var(--font-heading) !important; text-transform:uppercase;
 
345
  border:var(--border-width) solid var(--border-default) !important;
346
  font-family:var(--font-mono) !important; font-size:var(--text-sm) !important;
347
  }
348
+ .bit-ghost-btn:hover{ border-color:var(--border-strong) !important;
349
+ color:var(--text-primary) !important; }
350
 
351
+ /* Tables */
352
  .bit-table table{ font-family:var(--font-mono) !important; font-size:var(--text-sm) !important; }
353
  .bit-table thead th{
354
  font-family:var(--font-tiny) !important; font-size:var(--text-xs) !important;
355
  letter-spacing:var(--tracking-wider); text-transform:uppercase;
356
  color:var(--text-tertiary) !important; background:var(--bg-raised) !important;
357
+ border-bottom:var(--border-width) solid var(--border-default) !important;
358
  }
359
+ .bit-table tbody td{ border-color:var(--border-subtle) !important; }
360
 
361
+ /* Footer */
362
+ .bit-footer{
363
+ position:sticky; bottom:0; z-index:var(--z-header);
364
+ display:flex; justify-content:space-between; gap:var(--space-4);
365
+ background:var(--bg-panel); border-top:var(--border-width) solid var(--border-default);
366
+ padding:var(--space-2) var(--space-4);
367
+ font-family:var(--font-mono); font-size:var(--text-sm); color:var(--text-tertiary);
 
 
368
  }
369
+ .bit-footer-right{ white-space:nowrap; }
370
+
371
  @media (max-width: 900px){
372
  .bit-statband{ flex-direction:column; }
373
+ .bit-stat{ border-right:none; }
374
+ .bit-sig-grid{ flex-direction:column; }
375
+ .bit-consensus{ flex:1 1 auto; width:100%; }
376
  }
377
  """
378
 
tests/test_adapters.py CHANGED
@@ -60,7 +60,7 @@ def test_unknown_adapter_family_is_refused():
60
 
61
 
62
  def test_allowed_families_are_exactly_the_configured_set():
63
- assert set(config.ALLOWED_ADAPTER_FAMILIES) == {"chronos", "timesfm"}
64
 
65
 
66
  @pytest.mark.parametrize("bad", [
 
60
 
61
 
62
  def test_allowed_families_are_exactly_the_configured_set():
63
+ assert set(config.ALLOWED_ADAPTER_FAMILIES) == {"chronos", "timesfm", "baseline"}
64
 
65
 
66
  @pytest.mark.parametrize("bad", [
tests/test_catalog.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the catalog: the global comparison backend.
2
+
3
+ Covers the leaderboard sweep, precomputed equity curves, the signal scorecard,
4
+ the baseline adapters, and the consensus aggregator.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ import pytest
15
+
16
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
17
+
18
+ from src import catalog, charts, config
19
+ from src.adapters import AdapterError, BaselineAdapter, build_windows, get_adapter
20
+ from src.metrics import directional_accuracy
21
+ from src.store import SignalStore, validate_signal_frame
22
+ from src.ui import compare_tab as CT
23
+
24
+
25
+ def prices_frame(n=500, seed=3, start="2024-01-01"):
26
+ rng = np.random.default_rng(seed)
27
+ close = pd.Series(100 * np.exp(np.cumsum(rng.normal(0.0004, 0.02, n))))
28
+ ts = pd.date_range(start, periods=n, freq="D", tz="UTC")
29
+ return pd.DataFrame({
30
+ "ts": ts, "open": close * 0.999, "high": close * 1.02,
31
+ "low": close * 0.98, "close": close, "volume": 1000.0, "source": "test",
32
+ })
33
+
34
+
35
+ @pytest.fixture
36
+ def seeded_store(tmp_path):
37
+ """A small offline store with prices and two baseline models' signals."""
38
+ store = SignalStore(repo_id=None, local_root=tmp_path / "store", offline=True)
39
+ for asset in ("BTC-USD", "ETH-USD"):
40
+ px = prices_frame()
41
+ store.write_prices(asset, "1d", px)
42
+ close = px.set_index("ts")["close"]
43
+ for slug, method in (("baseline-naive", "naive"), ("baseline-drift", "drift")):
44
+ a = BaselineAdapter(f"baseline/{method}", context_len=100).load()
45
+ stamps, wins = build_windows(close, 100)
46
+ frame = a.predict(wins).as_frame(stamps, a.inference_version())
47
+ store.write_signals(slug, f"baseline/{method}", a.resolved_revision,
48
+ asset, "1d", frame,
49
+ inference_version=a.inference_version())
50
+ return store
51
+
52
+
53
+ # ---------------------------- baselines ----------------------------
54
+
55
+
56
+ @pytest.mark.parametrize("method", ["naive", "drift", "seasonal"])
57
+ def test_baselines_produce_valid_signals(method):
58
+ a = get_adapter("baseline", f"baseline/{method}", context_len=64).load()
59
+ s = pd.Series(np.linspace(100, 200, 300),
60
+ index=pd.date_range("2024-01-01", periods=300, freq="D", tz="UTC"))
61
+ stamps, wins = build_windows(s, 64)
62
+ out = validate_signal_frame(
63
+ a.predict(wins[:40]).as_frame(stamps[:40], a.inference_version()))
64
+ assert len(out) == 40
65
+ assert ((out["q10"] <= out["q50"]) & (out["q50"] <= out["q90"])).all()
66
+
67
+
68
+ def test_baseline_rejects_unknown_method():
69
+ with pytest.raises(AdapterError, match="unknown baseline method"):
70
+ get_adapter("baseline", "baseline/crystal-ball")
71
+
72
+
73
+ def test_naive_baseline_predicts_the_last_value():
74
+ a = BaselineAdapter("baseline/naive", context_len=10).load()
75
+ assert a.predict(np.arange(1, 11, dtype="float64")[None, :]).q50[0] == pytest.approx(10.0)
76
+
77
+
78
+ def test_drift_baseline_extrapolates_the_window_slope():
79
+ a = BaselineAdapter("baseline/drift", context_len=11).load()
80
+ assert a.predict(np.arange(0, 11, dtype="float64")[None, :]).q50[0] == pytest.approx(11.0)
81
+
82
+
83
+ def test_baselines_are_deterministic():
84
+ a = BaselineAdapter("baseline/drift", context_len=32).load()
85
+ _, wins = build_windows(pd.Series(
86
+ np.random.default_rng(1).normal(100, 5, 200),
87
+ index=pd.date_range("2024-01-01", periods=200, freq="D", tz="UTC")), 32)
88
+ assert np.allclose(a.predict(wins[:20]).q50, a.predict(wins[:20]).q50)
89
+
90
+
91
+ def test_baseline_revision_is_pinned():
92
+ a = BaselineAdapter("baseline/naive").load()
93
+ assert a.resolved_revision.startswith("baseline-")
94
+ assert a.inference_version() != config.PLACEHOLDER_VERSION
95
+
96
+
97
+ def test_flat_forecast_has_undefined_direction_not_zero_accuracy():
98
+ """A random walk never claims a direction, so accuracy is NaN, not 0%."""
99
+ idx = pd.date_range("2024-01-01", periods=80, freq="D", tz="UTC")
100
+ ref = pd.Series(np.linspace(100, 160, 80), index=idx)
101
+ assert pd.isna(directional_accuracy(ref.shift(-1).ffill(), ref.copy(), ref))
102
+
103
+
104
+ def test_chronos_chunking_is_smaller_for_sampling_models():
105
+ bolt = get_adapter("chronos", "amazon/chronos-bolt-small")
106
+ t5 = get_adapter("chronos", "amazon/chronos-t5-small")
107
+ assert bolt.chunk_size > t5.chunk_size
108
+ assert bolt._is_bolt and not t5._is_bolt
109
+
110
+
111
+ # ---------------------------- catalog build ----------------------------
112
+
113
+
114
+ def test_build_produces_all_three_artifacts(seeded_store):
115
+ rep = catalog.build(seeded_store)
116
+ assert rep.rows > 0 and rep.curves > 0 and rep.scorecard_rows > 0
117
+ assert not rep.failed
118
+ assert not catalog.load_leaderboard(seeded_store).empty
119
+ assert not catalog.load_equity_curves(seeded_store).empty
120
+ assert not catalog.load_scorecard(seeded_store).empty
121
+
122
+
123
+ def test_leaderboard_covers_signal_and_non_signal_strategies(seeded_store):
124
+ catalog.build(seeded_store)
125
+ lb = catalog.load_leaderboard(seeded_store)
126
+ assert (lb["model_slug"] == "").any()
127
+ assert (lb["model_slug"] != "").any()
128
+
129
+
130
+ def test_signal_strategies_fan_out_over_every_model(seeded_store):
131
+ catalog.build(seeded_store)
132
+ lb = catalog.load_leaderboard(seeded_store)
133
+ ff = lb[lb["strategy"] == "Chronos Forecast Follower"]
134
+ assert set(ff["model_slug"]) == {"baseline-naive", "baseline-drift"}
135
+
136
+
137
+ def test_every_row_carries_a_unique_key(seeded_store):
138
+ catalog.build(seeded_store)
139
+ assert catalog.load_leaderboard(seeded_store)["key"].is_unique
140
+
141
+
142
+ def test_thin_evidence_is_flagged(seeded_store):
143
+ catalog.build(seeded_store)
144
+ lb = catalog.load_leaderboard(seeded_store)
145
+ assert (lb["significant"] == (lb["trades"] >= catalog.MIN_MEANINGFUL_TRADES)).all()
146
+
147
+
148
+ def test_ranking_excludes_thin_rows_when_asked(seeded_store):
149
+ catalog.build(seeded_store)
150
+ lb = catalog.load_leaderboard(seeded_store)
151
+ strict = catalog.rank(lb, "oos_sharpe", significant_only=True)
152
+ assert (strict["trades"] >= catalog.MIN_MEANINGFUL_TRADES).all()
153
+
154
+
155
+ def test_least_bad_drawdown_ranks_first():
156
+ """Drawdowns are negative, so descending order puts the shallowest on top."""
157
+ df = pd.DataFrame({"max_drawdown": [-0.5, -0.1, -0.3],
158
+ "significant": True, "trades": 50})
159
+ assert catalog.rank(df, "max_drawdown").iloc[0]["max_drawdown"] == -0.1
160
+
161
+
162
+ def test_drawdown_ranking_in_the_compare_view_puts_shallowest_first(seeded_store):
163
+ catalog.build(seeded_store)
164
+ _p, table, *_ = CT.build_leaderboard_view(
165
+ seeded_store, assets=None, timeframes=None, strategies_=None, models=None,
166
+ metric_label="Max drawdown (least bad)", min_trades=0,
167
+ hide_baselines=False, require_oos=False, top_n=5)
168
+ vals = [float(v.rstrip("%").replace("+", "")) for v in table["Max DD"]]
169
+ assert vals == sorted(vals, reverse=True)
170
+
171
+
172
+ def test_curves_for_returns_named_series(seeded_store):
173
+ catalog.build(seeded_store)
174
+ lb = catalog.load_leaderboard(seeded_store)
175
+ curves = catalog.curves_for(catalog.load_equity_curves(seeded_store),
176
+ list(lb["key"])[:3])
177
+ assert len(curves) == 3
178
+ for s in curves.values():
179
+ assert isinstance(s, pd.Series) and len(s) > 0
180
+
181
+
182
+ def test_curves_for_unknown_key_is_empty(seeded_store):
183
+ catalog.build(seeded_store)
184
+ assert catalog.curves_for(catalog.load_equity_curves(seeded_store), ["nope"]) == {}
185
+
186
+
187
+ def test_filters_narrow_the_board(seeded_store):
188
+ catalog.build(seeded_store)
189
+ lb = catalog.load_leaderboard(seeded_store)
190
+ assert set(catalog.filter_leaderboard(lb, assets=["BTC-USD"])["asset"]) == {"BTC-USD"}
191
+ assert not catalog.filter_leaderboard(lb, hide_baselines=True)["is_baseline_model"].any()
192
+
193
+
194
+ def test_catalog_meta_records_the_canonical_config(seeded_store):
195
+ catalog.build(seeded_store)
196
+ meta = catalog.catalog_meta(seeded_store)
197
+ assert meta["leaderboard_rows"] > 0
198
+ assert "walk-forward" in meta["canonical_config"]
199
+
200
+
201
+ # ---------------------------- scorecard & consensus ----------------------------
202
+
203
+
204
+ def test_scorecard_labels_baselines(seeded_store):
205
+ catalog.build(seeded_store)
206
+ sc = catalog.load_scorecard(seeded_store)
207
+ assert sc["is_baseline"].all()
208
+ assert {"coverage_q10_q90", "directional_accuracy", "beats_momentum"} <= set(sc.columns)
209
+
210
+
211
+ def test_consensus_lists_every_model_with_a_direction(seeded_store):
212
+ catalog.build(seeded_store)
213
+ cons = catalog.model_consensus(catalog.load_scorecard(seeded_store),
214
+ seeded_store, "BTC-USD", "1d")
215
+ assert len(cons) == 2
216
+ assert set(cons["direction"]) <= {"LONG", "SHORT", "FLAT"}
217
+ assert (cons["weight"] >= 0).all()
218
+
219
+
220
+ def test_consensus_verdict_aggregates(seeded_store):
221
+ catalog.build(seeded_store)
222
+ cons = catalog.model_consensus(catalog.load_scorecard(seeded_store),
223
+ seeded_store, "BTC-USD", "1d")
224
+ v = catalog.consensus_verdict(cons)
225
+ assert v["direction"] in ("LONG", "SHORT", "FLAT")
226
+ assert 0.0 <= v["confidence"] <= 1.0
227
+ assert v["n_models"] == len(cons)
228
+
229
+
230
+ def test_consensus_on_an_uncovered_slice_is_empty(seeded_store):
231
+ sc = catalog.load_scorecard(seeded_store)
232
+ assert catalog.model_consensus(sc, seeded_store, "SOL-USD", "1d").empty
233
+ assert catalog.consensus_verdict(pd.DataFrame())["direction"] == "NO DATA"
234
+
235
+
236
+ # ---------------------------- compare tab ----------------------------
237
+
238
+
239
+ def test_leaderboard_view_renders_end_to_end(seeded_store):
240
+ catalog.build(seeded_store)
241
+ podium, table, overlay, scatter, meta = CT.build_leaderboard_view(
242
+ seeded_store, assets=None, timeframes=None, strategies_=None, models=None,
243
+ metric_label="OOS Sharpe", min_trades=0, hide_baselines=False,
244
+ require_oos=False, top_n=10)
245
+ assert "bit-podium" in podium
246
+ assert not table.empty
247
+ assert len(overlay.data) > 0 and len(scatter.data) > 0
248
+ assert "rows match" in meta
249
+
250
+
251
+ def test_leaderboard_view_handles_an_empty_catalog(tmp_path):
252
+ empty = SignalStore(repo_id=None, local_root=tmp_path / "s", offline=True)
253
+ podium, table, *_ = CT.build_leaderboard_view(
254
+ empty, assets=None, timeframes=None, strategies_=None, models=None,
255
+ metric_label="OOS Sharpe", min_trades=0, hide_baselines=False,
256
+ require_oos=False, top_n=10)
257
+ assert "not been generated" in podium and table.empty
258
+
259
+
260
+ def test_models_view_renders(seeded_store):
261
+ catalog.build(seeded_store)
262
+ _note, acc, cal, _bars, table = CT.build_models_view(seeded_store, "1d")
263
+ assert acc is not None and cal is not None
264
+ assert not table.empty
265
+
266
+
267
+ def test_signals_view_renders(seeded_store):
268
+ catalog.build(seeded_store)
269
+ html = CT.build_signals_view(seeded_store, "BTC-USD", "1d")
270
+ assert "bit-sig-row" in html or "No model signals" in html
271
+
272
+
273
+ def test_runs_table_merges_sources():
274
+ saved = pd.DataFrame([{
275
+ "created_at": "2026-01-01T00:00:00", "label": "saved one",
276
+ "strategy": "SMA Crossover", "asset": "BTC-USD", "timeframe": "1d",
277
+ "total_return": 0.2, "sharpe": 1.1, "oos_sharpe": 0.9,
278
+ "max_drawdown": -0.1, "trades": 30,
279
+ }])
280
+ out = CT.runs_table([], saved)
281
+ assert len(out) == 1 and out["Source"].iloc[0] == "signal store"
282
+
283
+
284
+ def test_leaderboard_table_flags_thin_rows():
285
+ df = pd.DataFrame([{
286
+ "strategy": "X", "model_display": "—", "asset": "BTC-USD", "timeframe": "1d",
287
+ "oos_sharpe": 4.0, "sharpe": 4.0, "total_return": 1.0, "cagr": 0.5,
288
+ "max_drawdown": -0.1, "win_rate": 0.6, "profit_factor": 2.0, "trades": 3,
289
+ "excess_vs_hold": 0.2, "holdout_sharpe": 1.0, "costs_paid": 10.0,
290
+ "significant": False,
291
+ }])
292
+ assert "trades" in CT.leaderboard_table(df)["Note"].iloc[0]
293
+
294
+
295
+ # ---------------------------- charts ----------------------------
296
+
297
+
298
+ def test_catalog_charts_survive_empty_input():
299
+ import plotly.graph_objects as go
300
+
301
+ for fig in (charts.multi_return_overlay({}),
302
+ charts.risk_return_scatter(pd.DataFrame()),
303
+ charts.model_accuracy_bars(pd.DataFrame()),
304
+ charts.calibration_scatter(pd.DataFrame()),
305
+ charts.model_leaderboard_bars(pd.DataFrame())):
306
+ assert isinstance(fig, go.Figure)
307
+
308
+
309
+ def test_overlay_caps_the_number_of_series():
310
+ idx = pd.date_range("2024-01-01", periods=50, freq="D", tz="UTC")
311
+ curves = {f"s{i}": pd.Series(np.linspace(0, 1, 50), index=idx) for i in range(40)}
312
+ assert len(charts.multi_return_overlay(curves, max_series=8).data) == 8
tests/test_ui.py CHANGED
@@ -18,6 +18,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
 
19
  import app as bitapp
20
  from src import charts, comparisons, config, runtime, strategies
 
 
21
  from src.runtime import RunRequest
22
 
23
 
@@ -70,14 +72,14 @@ def test_theme_css_carries_the_design_tokens():
70
 
71
 
72
  def test_disclaimer_is_present_in_the_footer():
73
- assert "not indicative of future results" in bitapp.FOOTER_HTML
74
- assert "Not financial advice" in bitapp.FOOTER_HTML or \
75
- "not a licensed investment adviser" in bitapp.FOOTER_HTML
76
 
77
 
78
  def test_empty_state_offers_the_worked_example():
79
- assert "No run loaded" in bitapp.EMPTY_HTML
80
- assert "worked example" in bitapp.EMPTY_HTML
81
 
82
 
83
  def test_glossary_covers_every_design_term():
@@ -92,29 +94,29 @@ def test_glossary_covers_every_design_term():
92
 
93
 
94
  def test_stat_band_values_match_engine_metrics(rec):
95
- html = bitapp.stat_band_html(rec)
96
  m = rec.result.metrics_all
97
 
98
- assert bitapp.pct(m.total_return) in html
99
- assert bitapp.pct(m.cagr) in html
100
- assert bitapp.num(m.sharpe) in html
101
- assert bitapp.num(m.sortino) in html
102
- assert bitapp.pct(m.max_drawdown) in html
103
- assert bitapp.num(m.profit_factor) in html
104
  assert f">{m.trade_count}<" in html
105
 
106
 
107
  def test_stat_band_shows_is_and_oos_for_every_stat(rec):
108
- html = bitapp.stat_band_html(rec)
109
  assert html.count("IS ") >= 9
110
  assert html.count("· OOS") >= 9
111
- assert bitapp.num(rec.result.metrics_oos.sharpe) in html
112
- assert bitapp.num(rec.result.metrics_is.sharpe) in html
113
 
114
 
115
  def test_stat_band_reports_costs_actually_paid(rec):
116
- html = bitapp.stat_band_html(rec)
117
- assert bitapp.money(rec.result.costs_paid) in html
118
  assert rec.result.costs_paid > 0, "costs default to ON, so this must be positive"
119
 
120
 
@@ -124,7 +126,7 @@ def test_empty_segment_renders_an_em_dash_not_a_zero():
124
  strategy="SMA Crossover", asset="BTC-USD", timeframe="1h", date_range="1Y"))
125
  if short.result.metrics_oos.bars:
126
  pytest.skip("this range did produce OOS windows")
127
- html = bitapp.stat_band_html(short)
128
  assert "· OOS —" in html
129
  assert "no out-of-sample windows" in html
130
 
@@ -141,8 +143,8 @@ def test_trade_table_rows_match_the_engine_trade_list(rec):
141
 
142
  def test_report_quotes_the_same_numbers_as_the_stat_band(rec):
143
  md = bitapp.report_markdown(rec)
144
- assert bitapp.pct(rec.result.metrics_all.total_return) in md
145
- assert bitapp.money(rec.result.costs_paid) in md
146
  assert rec.run_id in md
147
 
148
 
 
18
 
19
  import app as bitapp
20
  from src import charts, comparisons, config, runtime, strategies
21
+ from src.ui import components as C
22
+ from src.ui.format import money, num, pct
23
  from src.runtime import RunRequest
24
 
25
 
 
72
 
73
 
74
  def test_disclaimer_is_present_in_the_footer():
75
+ assert "not indicative of future results" in C.footer()
76
+ assert "Not financial advice" in C.footer() or \
77
+ "not a licensed investment adviser" in C.footer()
78
 
79
 
80
  def test_empty_state_offers_the_worked_example():
81
+ assert "No run loaded" in C.empty_state()
82
+ assert "worked example" in C.empty_state()
83
 
84
 
85
  def test_glossary_covers_every_design_term():
 
94
 
95
 
96
  def test_stat_band_values_match_engine_metrics(rec):
97
+ html = C.stat_band(rec)
98
  m = rec.result.metrics_all
99
 
100
+ assert pct(m.total_return) in html
101
+ assert pct(m.cagr) in html
102
+ assert num(m.sharpe) in html
103
+ assert num(m.sortino) in html
104
+ assert pct(m.max_drawdown) in html
105
+ assert num(m.profit_factor) in html
106
  assert f">{m.trade_count}<" in html
107
 
108
 
109
  def test_stat_band_shows_is_and_oos_for_every_stat(rec):
110
+ html = C.stat_band(rec)
111
  assert html.count("IS ") >= 9
112
  assert html.count("· OOS") >= 9
113
+ assert num(rec.result.metrics_oos.sharpe) in html
114
+ assert num(rec.result.metrics_is.sharpe) in html
115
 
116
 
117
  def test_stat_band_reports_costs_actually_paid(rec):
118
+ html = C.stat_band(rec)
119
+ assert money(rec.result.costs_paid) in html
120
  assert rec.result.costs_paid > 0, "costs default to ON, so this must be positive"
121
 
122
 
 
126
  strategy="SMA Crossover", asset="BTC-USD", timeframe="1h", date_range="1Y"))
127
  if short.result.metrics_oos.bars:
128
  pytest.skip("this range did produce OOS windows")
129
+ html = C.stat_band(short)
130
  assert "· OOS —" in html
131
  assert "no out-of-sample windows" in html
132
 
 
143
 
144
  def test_report_quotes_the_same_numbers_as_the_stat_band(rec):
145
  md = bitapp.report_markdown(rec)
146
+ assert pct(rec.result.metrics_all.total_return) in md
147
+ assert money(rec.result.costs_paid) in md
148
  assert rec.run_id in md
149
 
150