Spaces:
Running on Zero
Running on Zero
Backtest Lab v1.0.0
Browse files- .preview/components.html +540 -0
- app.py +412 -536
- scripts/seed_store.py +31 -21
- src/adapters.py +124 -7
- src/catalog.py +521 -0
- src/charts.py +161 -0
- src/config.py +32 -1
- src/extension.py +8 -1
- src/metrics.py +9 -2
- src/runtime.py +13 -3
- src/ui/compare_tab.py +211 -0
- src/ui/components.py +359 -0
- src/ui/format.py +76 -0
- src/ui/theme.py +194 -58
- tests/test_adapters.py +1 -1
- tests/test_catalog.py +312 -0
- tests/test_ui.py +22 -20
.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 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 10 |
-
|
| 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
|
| 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 |
-
#
|
| 45 |
# --------------------------------------------------------------------------
|
| 46 |
|
| 47 |
|
| 48 |
-
def
|
| 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
|
| 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 |
-
"
|
| 226 |
-
"
|
| 227 |
-
"
|
| 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 |
-
|
| 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"
|
| 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 |
-
|
| 271 |
return "\n".join(lines)
|
| 272 |
|
| 273 |
|
| 274 |
-
def
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 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,
|
| 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",
|
| 302 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
| 318 |
-
with gr.Blocks(theme=theme.bit_theme(), css=
|
| 319 |
-
|
|
|
|
| 320 |
|
| 321 |
-
history = gr.State([])
|
| 322 |
-
|
| 323 |
-
current = gr.State(None) # RunRecord
|
| 324 |
|
| 325 |
-
top_bar = gr.HTML(
|
| 326 |
|
| 327 |
with gr.Row(equal_height=False):
|
| 328 |
-
#
|
| 329 |
-
with gr.Column(scale=2, min_width=
|
| 330 |
-
gr.HTML('<div class="bit-
|
| 331 |
|
| 332 |
with gr.Accordion("1 · STRATEGY", open=True, elem_classes="bit-accordion"):
|
| 333 |
-
strategy = gr.Dropdown(
|
| 334 |
-
|
| 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 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 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,
|
|
|
|
| 356 |
costs_on = gr.Checkbox(value=True, label="Costs on")
|
| 357 |
-
gr.HTML(
|
| 358 |
-
|
| 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(
|
| 362 |
value="Fixed bps", label="Slippage model")
|
| 363 |
-
gr.Radio(
|
| 364 |
-
|
| 365 |
info="Next-bar-open execution is enforced by the engine.")
|
| 366 |
|
| 367 |
-
with gr.Accordion("4 · SIZING & RISK", open=False,
|
| 368 |
-
|
|
|
|
| 369 |
value="Fixed %", label="Sizing")
|
| 370 |
size_pct = gr.Slider(0.05, 1.0, value=1.0, step=0.05,
|
| 371 |
-
label="Position size
|
| 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,
|
|
|
|
| 378 |
validation_mode = gr.Radio(
|
| 379 |
-
|
| 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 |
-
#
|
| 397 |
-
with gr.Column(scale=
|
| 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 |
-
|
|
|
|
| 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(
|
| 409 |
with gr.Row():
|
| 410 |
-
underwater_plot = gr.Plot(
|
| 411 |
-
rolling_plot = gr.Plot(
|
| 412 |
-
price_plot = gr.Plot(
|
| 413 |
with gr.Row():
|
| 414 |
-
pnl_plot = gr.Plot(
|
| 415 |
-
hold_plot = gr.Plot(
|
| 416 |
-
mae_plot = gr.Plot(
|
| 417 |
costs_note = gr.HTML("")
|
| 418 |
|
| 419 |
with gr.Tab("Trades"):
|
| 420 |
-
|
| 421 |
-
trades_table = gr.Dataframe(
|
| 422 |
-
|
| 423 |
-
|
| 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(
|
| 450 |
-
mc_plot = gr.Plot(
|
|
|
|
|
|
|
| 451 |
with gr.Row():
|
| 452 |
-
sens_plot = gr.Plot(
|
| 453 |
-
slip_plot = gr.Plot(
|
| 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(
|
| 461 |
with gr.Row():
|
| 462 |
-
save_run_btn = gr.Button("Save run
|
| 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(
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
'
|
| 476 |
extend_panel = gr.HTML("")
|
| 477 |
with gr.Row():
|
| 478 |
-
ext_model = gr.Dropdown(
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 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 |
-
|
| 499 |
-
|
| 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 |
-
#
|
| 507 |
-
with gr.Column(scale=2, min_width=
|
| 508 |
-
gr.HTML('<div class="bit-
|
| 509 |
-
login_slot = gr.HTML("")
|
| 510 |
try:
|
| 511 |
gr.LoginButton(value="Sign in with Hugging Face", size="sm")
|
| 512 |
except Exception:
|
| 513 |
-
|
| 514 |
-
|
| 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(
|
| 521 |
with gr.Accordion("HOW RESULTS ARE COMPUTED", open=False,
|
| 522 |
elem_classes="bit-accordion"):
|
| 523 |
-
gr.Markdown(
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 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
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
else:
|
| 559 |
-
note = ""
|
| 560 |
ups = []
|
| 561 |
for i in range(3):
|
| 562 |
if i < len(preset.params):
|
| 563 |
-
key, label, default,
|
| 564 |
ups.append(gr.update(label=label, value=default, visible=True))
|
| 565 |
else:
|
| 566 |
ups.append(gr.update(visible=False, value=None))
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
return (*ups, model_up, note)
|
| 572 |
|
| 573 |
-
strategy.change(on_strategy_change, [strategy],
|
|
|
|
| 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 = (
|
| 580 |
-
f'for {a} {tf}. Pick another pair or extend coverage.</div>')
|
| 581 |
else:
|
| 582 |
-
html = (f
|
| 583 |
-
|
| 584 |
-
|
| 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],
|
|
|
|
| 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.
|
| 596 |
rec = runtime.execute(req)
|
| 597 |
except (RunError, ValueError) as e:
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
"_Run failed._", None)
|
| 603 |
|
| 604 |
-
progress(0.
|
| 605 |
hist = ([rec] + list(hist))[:40]
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 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 |
-
"
|
| 632 |
-
|
| 633 |
-
gr.update(value=
|
| 634 |
-
gr.update(
|
| 635 |
-
gr.update(
|
|
|
|
| 636 |
True, "Walk-forward")
|
| 637 |
|
| 638 |
example_btn.click(
|
| 639 |
load_example, None,
|
| 640 |
-
[strategy, asset, timeframe, date_range, p1, p2, p3,
|
| 641 |
-
|
|
|
|
| 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],
|
| 649 |
-
cvd.change(replot, [current, log_scale, cvd],
|
| 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(
|
|
|
|
| 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
|
| 671 |
try:
|
| 672 |
-
rid = runtime.save_run_summary(rec)
|
| 673 |
-
return (f
|
| 674 |
-
|
| 675 |
except Exception as e:
|
| 676 |
-
return f
|
| 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
|
| 726 |
grade, checks = runtime.overfit_verdict(rec)
|
| 727 |
-
items = "".join(
|
| 728 |
-
|
| 729 |
-
|
| 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 |
-
|
| 734 |
-
|
| 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 |
-
|
| 753 |
-
|
| 754 |
-
xs = sorted({max(2, int(
|
| 755 |
-
ys = sorted({max(3, int(
|
| 756 |
-
|
| 757 |
-
|
|
|
|
| 758 |
progress(0.7, desc="Slippage stress")
|
| 759 |
-
|
| 760 |
-
return sens, slip
|
| 761 |
|
| 762 |
robust_btn.click(do_sweep, [current], [sens_plot, slip_plot])
|
| 763 |
|
| 764 |
-
# ----
|
| 765 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 766 |
|
| 767 |
-
|
| 768 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 776 |
def on_load(request: gr.Request):
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 796 |
-
|
| 797 |
-
return (
|
| 798 |
-
|
|
|
|
|
|
|
| 799 |
|
| 800 |
demo.load(on_load, None,
|
| 801 |
-
[
|
|
|
|
| 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 |
-
|
| 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:
|
|
|
|
| 65 |
|
| 66 |
-
Daily
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
60 days, so it is labelled rather than faked as real.
|
| 70 |
"""
|
| 71 |
targets: list[SeedTarget] = []
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
for
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
# the
|
| 80 |
-
for
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("&", "&").replace("<", "<")
|
| 76 |
+
.replace(">", ">").replace('"', """))
|
src/ui/theme.py
CHANGED
|
@@ -52,100 +52,212 @@ def _tokens_css() -> str:
|
|
| 52 |
|
| 53 |
|
| 54 |
SHELL_CSS = """
|
| 55 |
-
/*
|
| 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 |
-
/*
|
| 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-
|
| 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-
|
| 80 |
|
| 81 |
-
/*
|
| 82 |
-
.bit-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
| 85 |
}
|
| 86 |
-
.bit-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
}
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 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-
|
| 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 |
-
/*
|
| 110 |
-
.bit-statband{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
.bit-stat{
|
| 112 |
-
flex:1 1
|
| 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;
|
|
|
|
| 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);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
/*
|
| 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 |
-
/*
|
| 137 |
-
.bit-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
-
/*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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-
|
|
|
|
| 218 |
|
| 219 |
-
/*
|
| 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 |
-
/*
|
| 228 |
-
.bit-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 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;
|
|
|
|
|
|
|
| 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
|
| 74 |
-
assert "Not financial advice" in
|
| 75 |
-
"not a licensed investment adviser" in
|
| 76 |
|
| 77 |
|
| 78 |
def test_empty_state_offers_the_worked_example():
|
| 79 |
-
assert "No run loaded" in
|
| 80 |
-
assert "worked example" in
|
| 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 =
|
| 96 |
m = rec.result.metrics_all
|
| 97 |
|
| 98 |
-
assert
|
| 99 |
-
assert
|
| 100 |
-
assert
|
| 101 |
-
assert
|
| 102 |
-
assert
|
| 103 |
-
assert
|
| 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 =
|
| 109 |
assert html.count("IS ") >= 9
|
| 110 |
assert html.count("· OOS") >= 9
|
| 111 |
-
assert
|
| 112 |
-
assert
|
| 113 |
|
| 114 |
|
| 115 |
def test_stat_band_reports_costs_actually_paid(rec):
|
| 116 |
-
html =
|
| 117 |
-
assert
|
| 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 =
|
| 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
|
| 145 |
-
assert
|
| 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 |
|