Bit-Trading-Company commited on
Commit
c201e66
·
verified ·
1 Parent(s): 05562d4

CI deploy 024e1919

Browse files
Files changed (5) hide show
  1. app.py +452 -557
  2. src/ui/bridge.py +159 -0
  3. src/ui/shell.py +485 -0
  4. src/ui/theme.py +45 -0
  5. tests/test_ui.py +174 -2
app.py CHANGED
@@ -1,22 +1,24 @@
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
@@ -25,56 +27,277 @@ 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."),
39
  ("SORTINO", "Sharpe with only downside deviation in the denominator."),
40
  ("MAX DRAWDOWN", "Worst peak-to-trough decline of the equity curve."),
41
- ("PROFIT FACTOR", "Gross profit over gross loss. Below 1.2 rarely survives real costs."),
42
- ("R-MULTIPLE", "Trade P&L expressed in units of initial risk."),
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:
73
- s, e = runtime.window_for(req.asset, req.timeframe, req.date_range)
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:
@@ -89,21 +312,14 @@ def trades_frame(rec: RunRecord | None) -> pd.DataFrame:
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._"
@@ -116,7 +332,6 @@ def report_markdown(rec: RunRecord | None) -> str:
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()} · "
@@ -124,19 +339,17 @@ def report_markdown(rec: RunRecord | None) -> str:
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)
@@ -145,12 +358,14 @@ def report_markdown(rec: RunRecord | None) -> str:
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),
@@ -165,37 +380,6 @@ def build_overview(rec: RunRecord, *, log_scale: bool, cvd: bool):
165
  )
166
 
167
 
168
- def collect_request(strategy, asset, timeframe, date_range, model_slug,
169
- p1, p2, p3, costs_on, commission_bps, slippage_bps,
170
- slippage_model, sizing_mode, size_pct, leverage,
171
- sl_pct, tp_pct, trail_pct,
172
- validation_mode, train_m, test_m, roll_m, holdout_m) -> RunRequest:
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(
180
- strategy=strategy, asset=asset, timeframe=timeframe, date_range=date_range,
181
- model_slug=model_slug or "", params=params,
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),
190
- trail_pct=(float(trail_pct) / 100.0 if trail_pct else None),
191
- validation_mode={"Walk-forward": "walk_forward", "Simple split": "split",
192
- "Holdout only": "holdout", "None": "none"}.get(
193
- validation_mode, "walk_forward"),
194
- train_months=int(train_m), test_months=int(test_m), roll_months=int(roll_m),
195
- holdout_months=int(holdout_m),
196
- )
197
-
198
-
199
  # --------------------------------------------------------------------------
200
  # App
201
  # --------------------------------------------------------------------------
@@ -205,190 +389,62 @@ 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)
274
- roll_m = gr.Number(value=3, label="Roll months", precision=0)
275
- holdout_m = gr.Number(value=6, label="OOS holdout months", precision=0)
276
-
277
- run_btn = gr.Button("▶ Run backtest", variant="primary",
278
- elem_classes="bit-run-btn")
279
- with gr.Row():
280
- example_btn = gr.Button("Load example", size="sm",
281
- elem_classes="bit-ghost-btn")
282
- share_btn = gr.Button("Share link", size="sm",
283
- elem_classes="bit-ghost-btn")
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()
@@ -401,48 +457,29 @@ def build_app() -> gr.Blocks:
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():
@@ -454,297 +491,155 @@ def build_app() -> gr.Blocks:
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
- # Only construct the real OAuth button when running on a Space.
472
- # Off-Space, Gradio "helpfully" mocks OAuth by calling HF's
473
- # whoami, which raises without a token -- that would make the
474
- # whole app unconstructable for anyone without credentials,
475
- # even though reading and backtesting are meant to be open.
476
- if os.environ.get("SPACE_ID"):
477
- try:
478
- gr.LoginButton(value="Sign in with Hugging Face", size="sm")
479
- except Exception:
480
- log.warning("LoginButton unavailable", exc_info=True)
481
- gr.HTML(C.note("Sign-in is temporarily unavailable."))
482
- else:
483
- gr.HTML(C.note(
484
- "Sign-in appears when this runs on a Hugging Face Space. "
485
- "Reading and backtesting work without an account."))
486
- history_html = gr.HTML(C.micro("no runs yet in this session"))
487
- with gr.Accordion("METRICS GLOSSARY", open=False,
488
- elem_classes="bit-accordion"):
489
- gr.HTML(C.glossary(GLOSSARY))
490
- with gr.Accordion("HOW RESULTS ARE COMPUTED", open=False,
491
- elem_classes="bit-accordion"):
492
- gr.Markdown(METHOD_NOTES)
493
-
494
- gr.HTML(C.footer())
495
-
496
- # ==================== wiring ====================
497
-
498
- builder = [strategy, asset, timeframe, date_range, model_slug,
499
- p1, p2, p3, costs_on, commission_bps, slippage_bps,
500
- slippage_model, sizing_mode, size_pct, leverage,
501
- sl_pct, tp_pct, trail_pct,
502
- validation_mode, train_m, test_m, roll_m, holdout_m]
503
  overview_out = [equity_plot, regime_plot, underwater_plot, rolling_plot,
504
  price_plot, pnl_plot, hold_plot, mae_plot, costs_note]
505
-
506
- def on_strategy_change(name):
507
- preset = strategies.PRESETS.get(name)
508
- if preset is None:
509
- return gr.update(), gr.update(), gr.update(), gr.update(), ""
510
- note = ("" if preset.available else
511
- C.note(f"<b>{name}</b> is unavailable. {preset.unavailable_reason}",
512
- danger=True))
513
- ups = []
514
- for i in range(3):
515
- if i < len(preset.params):
516
- key, label, default, _lo, _hi = preset.params[i]
517
- ups.append(gr.update(label=label, value=default, visible=True))
518
- else:
519
- ups.append(gr.update(visible=False, value=None))
520
- models = runtime.available_models()
521
- return (*ups, gr.update(visible=preset.needs_signals, choices=models,
522
- value=(models[0] if models and preset.needs_signals
523
- else None)), note)
524
-
525
- strategy.change(on_strategy_change, [strategy],
526
- [p1, p2, p3, model_slug, preset_note])
527
-
528
- def on_universe_change(a, tf):
529
- cov = runtime.price_coverage_for(a, tf)
530
- models = runtime.available_models(a, tf)
531
- if cov is None:
532
- html = C.note(f"No cached price coverage for {a} {tf}.", danger=True)
533
- else:
534
- html = C.micro(f"cached {cov[0]} → {cov[1]} · "
535
- + (f"{len(models)} models" if models else "no signals"))
536
- return html, gr.update(choices=models, value=(models[0] if models else None))
537
-
538
- asset.change(on_universe_change, [asset, timeframe], [coverage_note, model_slug])
539
- timeframe.change(on_universe_change, [asset, timeframe],
540
- [coverage_note, model_slug])
541
-
542
- def do_run(hist, *vals, progress=gr.Progress()):
543
- progress(0.05, desc="Reading cached slices")
544
- req = collect_request(*vals)
545
  try:
546
- progress(0.4, desc="Simulating trades")
547
- rec = runtime.execute(req)
548
- except (RunError, ValueError) as e:
549
- return (hist, None, C.top_bar("RUN FAILED", "warn"),
550
- C.note(str(e), danger=True), gr.update(visible=True),
551
- *(gr.update(),) * 9, "", pd.DataFrame(),
552
- C.micro("no runs yet in this session"),
553
- "_Run failed._", None)
554
-
555
- progress(0.8, desc="Building charts")
 
 
556
  hist = ([rec] + list(hist))[:40]
557
- return (hist, rec,
558
- C.top_bar(f"RUN {rec.run_id} COMPLETE", "ok",
559
- context=context_chip_text(req),
560
- elapsed=f"{rec.elapsed_s:.1f}s"),
561
- C.stat_band(rec), gr.update(visible=False),
562
- *build_overview(rec, log_scale=False, cvd=False),
563
- trades_head(rec), trades_frame(rec),
564
- C.run_history(hist), report_markdown(rec),
565
- charts.equity_curve(rec.result.equity, rec.result.benchmark_equity,
566
- plan=rec.result.plan))
567
-
568
- run_out = [history, current, top_bar, stat_band, empty_state, *overview_out,
569
- trades_head_html, trades_table, history_html, report_md,
570
- report_equity]
571
- run_btn.click(do_run, [history, *builder], run_out)
572
-
573
- def load_example():
574
- return ("Chronos Forecast Follower", "BTC-USD", "1d", "3Y",
575
- gr.update(value=0.005, label="Entry edge", visible=True),
576
- gr.update(value=0.0, label="Exit edge", visible=True),
577
- gr.update(visible=False, value=None),
578
- gr.update(visible=True, choices=runtime.available_models(),
579
- value="chronos-bolt-small"),
580
- True, "Walk-forward")
581
-
582
- example_btn.click(
583
- load_example, None,
584
- [strategy, asset, timeframe, date_range, p1, p2, p3, model_slug,
585
- costs_on, validation_mode],
586
- ).then(do_run, [history, *builder], run_out)
587
-
588
- def replot(rec, log_s, colorblind):
589
- if rec is None:
590
- return (gr.update(),) * 9
591
- return build_overview(rec, log_scale=log_s, cvd=colorblind)
592
-
593
- log_scale.change(replot, [current, log_scale, cvd], overview_out)
594
- cvd.change(replot, [current, log_scale, cvd], overview_out)
595
-
596
- share_btn.click(lambda *v: gr.update(value=collect_request(*v).encode(),
597
- visible=True), builder, [share_out])
598
- copy_cfg_btn.click(lambda r: gr.update(value=r.request.encode(), visible=True)
599
- if r else gr.update(), [current], [share_out])
600
-
601
- def do_export(rec):
602
- if rec is None or rec.result.trades.empty:
603
- return gr.update(visible=False)
604
- path = f"/tmp/trades_{rec.run_id}.csv"
605
- rec.result.trades.to_csv(path, index=False)
606
- return gr.update(value=path, visible=True)
607
-
608
- export_btn.click(do_export, [current], [export_file])
609
-
610
- def do_save(rec):
611
- if rec is None:
612
- return C.note("Nothing to save yet.")
613
- try:
614
- rid = runtime.save_run_summary(rec, push=True)
615
- return C.note(f"Run <b>{rid}</b> saved to the signal store. "
616
- "It now appears in Run history for everyone.")
617
- except Exception as e:
618
- return C.note(f"Could not save: {e}", danger=True)
619
-
620
- save_run_btn.click(do_save, [current], [save_note])
621
-
622
- def do_robustness(rec):
623
- if rec is None:
624
- e = charts.empty_figure("run a backtest first")
625
- return "", e, e
626
- grade, checks = runtime.overfit_verdict(rec)
627
- items = "".join(f'<div class="bit-gloss-def">{m} {t}</div>'
628
- for m, t in checks)
629
- html = (f'<div class="bit-panel">{C.panel_head("Overfit verdict", grade)}'
630
- f'{items}</div>')
631
- return (html, charts.walk_forward_bars(rec.result.windows),
632
- charts.monte_carlo_cone(charts.monte_carlo_paths(rec.result.trades)))
633
-
634
- current.change(do_robustness, [current], [verdict_html, wf_plot, mc_plot])
635
-
636
- def do_sweep(rec, progress=gr.Progress()):
637
- if rec is None:
638
- e = charts.empty_figure("run a backtest first")
639
- return e, e
640
- req = rec.request
641
- preset = strategies.PRESETS.get(req.strategy)
642
- keys = [p[0] for p in (preset.params if preset else [])][:2]
643
- if len(keys) < 2:
644
- sens = charts.empty_figure("this preset has fewer than two parameters")
645
- else:
646
- progress(0.1, desc="Parameter sweep")
647
- bx = req.params.get(keys[0], 20)
648
- by = req.params.get(keys[1], 50)
649
- xs = sorted({max(2, int(bx * m)) for m in (0.5, 0.75, 1.0, 1.5, 2.0)})
650
- ys = sorted({max(3, int(by * m)) for m in (0.5, 0.75, 1.0, 1.5, 2.0)})
651
- sens = charts.parameter_sensitivity(
652
- runtime.parameter_sweep(req, keys[0], xs, keys[1], ys),
653
- x=keys[0], y=keys[1])
654
- progress(0.7, desc="Slippage stress")
655
- return sens, charts.slippage_stress(runtime.slippage_stress(req))
656
-
657
- robust_btn.click(do_sweep, [current], [sens_plot, slip_plot])
658
-
659
- # ---- Compare tab ----
660
- lb_filters = [f_assets, f_tfs, f_strats, f_models, f_metric,
661
- f_min_trades, f_hide_base, f_oos, f_top]
662
- lb_out = [podium, lb_table, lb_overlay, lb_scatter, lb_meta]
663
-
664
- def refresh_leaderboard(assets_, tfs, strats, models, metric,
665
- min_trades, hide_base, req_oos, top_n):
666
- return CT.build_leaderboard_view(
667
- runtime.get_store(), assets=assets_, timeframes=tfs,
668
- strategies_=strats, models=models, metric_label=metric,
669
- min_trades=min_trades, hide_baselines=hide_base,
670
- require_oos=req_oos, top_n=top_n)
671
-
672
- for ctrl in lb_filters:
673
- ctrl.change(refresh_leaderboard, lb_filters, lb_out)
674
-
675
- reset_btn.click(lambda: ([], [], [], [], "OOS Sharpe", 0, False, True, 15),
676
- None, lb_filters).then(refresh_leaderboard, lb_filters, lb_out)
677
-
678
- models_out = [models_note, acc_plot, cal_plot, model_bars, score_table]
679
- models_tf.change(lambda tf: CT.build_models_view(runtime.get_store(), tf),
680
- [models_tf], models_out)
681
-
682
- def refresh_signals(a, tf):
683
- return CT.build_signals_view(runtime.get_store(), a, tf)
684
-
685
- sig_asset.change(refresh_signals, [sig_asset, sig_tf], [sig_panel])
686
- sig_tf.change(refresh_signals, [sig_asset, sig_tf], [sig_panel])
687
-
688
- def refresh_runs(hist):
689
- saved = catalog.load_saved_runs(runtime.get_store())
690
- return CT.runs_table(hist, saved)
691
-
692
- runs_refresh.click(refresh_runs, [history], [runs_table])
693
- history.change(refresh_runs, [history], [runs_table])
694
-
695
- # ---- Coverage / extension ----
696
  estimate_btn.click(extension.estimate_ui,
697
  [ext_model, ext_asset, ext_tf, ext_start, ext_end],
698
  [extend_out])
699
- extend_btn.click(extension.extend_ui,
700
- [ext_model, ext_asset, ext_tf, ext_start, ext_end],
701
- [extend_out, coverage_table])
702
- add_btn.click(extension.add_model_ui, [add_family, add_model_id],
703
- [add_out, coverage_table])
704
-
705
- # ---- Load ----
706
- def on_load(request: gr.Request):
707
- st = runtime.get_store()
708
- meta = catalog.catalog_meta(st)
709
- meta_html = C.micro(
 
 
 
 
 
 
710
  f"{meta.get('leaderboard_rows', 0)} combinations · "
711
- f"{meta.get('scorecard_rows', 0)} model slices · generated "
712
- f"{str(meta.get('generated_at', ''))[:16].replace('T', ' ')} · "
713
- f"{meta.get('canonical_config', '')}") if meta else C.micro("catalog not built")
714
-
715
- lb_view = CT.build_leaderboard_view(
716
- st, assets=None, timeframes=None, strategies_=None, models=None,
717
- metric_label="OOS Sharpe", min_trades=0, hide_baselines=False,
718
- require_oos=True, top_n=15)
719
- models_view = CT.build_models_view(st, "1d")
720
- sig = CT.build_signals_view(st, "BTC-USD", "1d")
721
- cov_cells = runtime.coverage_map()
722
-
723
- restored = [gr.update()] * 4
724
- try:
725
- token = dict(request.query_params).get("cfg") if request else None
726
- except Exception:
727
- token = None
728
- if token:
729
- try:
730
- req = RunRequest.decode(token)
731
- restored = [gr.update(value=req.strategy), gr.update(value=req.asset),
732
- gr.update(value=req.timeframe),
733
- gr.update(value=req.date_range)]
734
- except Exception as e:
735
- log.warning("share link rejected: %s", e)
736
-
737
- return (meta_html, *lb_view, *models_view, sig,
738
- C.coverage_summary(cov_cells), runtime.coverage_frame(),
739
- extension.status_html(),
740
- CT.runs_table([], catalog.load_saved_runs(st)), *restored)
741
-
742
- demo.load(on_load, None,
743
- [catalog_meta, *lb_out, *models_out, sig_panel,
744
- coverage_kpis, coverage_table, extend_panel, runs_table,
745
- strategy, asset, timeframe, date_range])
746
- demo.load(lambda: on_universe_change("BTC-USD", "1d"), None,
747
- [coverage_note, model_slug])
748
 
749
  return demo
750
 
 
1
  """Bit Trading Company — Backtest Lab.
2
 
3
+ The visible chrome and every control is the design's own markup, rendered by
4
+ `src/ui/shell.py` and wired back to Python through `src/ui/bridge.py`. Gradio
5
+ owns the plots and the transport; it no longer owns the layout.
6
 
7
+ That split exists because the design is built from `<button>` elements with
8
+ exact inline styles — 23 buttons against a single `<input>` in the whole file
9
+ while Gradio renders a different DOM for the same concepts. Restyling Gradio's
10
+ components from outside only ever approximated it. See docs/DESIGN.md.
11
 
12
+ Everything below the chrome is unchanged: the Compare catalog, the engine, the
13
+ store and the charts are the same code they were.
 
 
14
  """
15
 
16
  from __future__ import annotations
17
 
18
+ import copy
19
  import logging
20
  import os
21
+ import uuid
22
 
23
  import gradio as gr
24
  import pandas as pd
 
27
  from src.runtime import RunError, RunRecord, RunRequest
28
  from src.ui import components as C
29
  from src.ui import compare_tab as CT
30
+ from src.ui import shell, theme
31
+ from src.ui.bridge import ACTION_ELEMENT_ID, BRIDGE_JS, parse_action, parse_pair
32
+ from src.ui.format import EM, count, esc, money, num, pct, tone
33
 
34
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
35
  log = logging.getLogger("bit.app")
36
 
 
 
37
  GLOSSARY = [
38
+ ("SHARPE", "Annualized mean excess return over return volatility. "
39
  "Above 1 is good; above 3 usually means a bug."),
40
  ("SORTINO", "Sharpe with only downside deviation in the denominator."),
41
  ("MAX DRAWDOWN", "Worst peak-to-trough decline of the equity curve."),
42
+ ("PROFIT FACTOR", "Gross profit over gross loss."),
43
+ ("R-MULTIPLE", "Trade P&L in units of initial risk."),
44
+ ("MAE / MFE", "Worst and best unrealized excursion while open."),
45
+ ("WALK-FORWARD", "Train on a rolling window, test on the next unseen one."),
46
+ ("OOS", "Out of sample: data the parameters never saw."),
47
+ ("BASELINE", "A naive forecast. If a learned model cannot beat these, it "
48
+ "has not earned its inference cost."),
49
  ]
50
 
51
+ NUMERIC_PARAMS = {
52
+ "commission_bps": float, "slippage_bps": float, "size_pct": float,
53
+ "leverage": float, "sl_pct": float, "tp_pct": float, "trail_pct": float,
54
+ "train_months": int, "test_months": int, "roll_months": int,
55
+ "holdout_months": int,
56
+ }
 
 
 
 
 
 
57
 
58
 
59
  # --------------------------------------------------------------------------
60
+ # State
61
  # --------------------------------------------------------------------------
62
 
63
 
64
+ def default_state() -> dict:
65
+ return {
66
+ "strategy": "SMA Crossover",
67
+ "asset": "BTC-USD",
68
+ "timeframe": "1d",
69
+ "range": "3Y",
70
+ "model": None,
71
+ "params": dict(strategies.defaults_for("SMA Crossover")),
72
+ "costs_on": True,
73
+ "commission_bps": config.DEFAULT_COMMISSION_BPS,
74
+ "slippage_bps": config.DEFAULT_SLIPPAGE_BPS,
75
+ "slippage_model": "fixed",
76
+ "sizing_mode": "fixed_pct",
77
+ "size_pct": 1.0,
78
+ "leverage": 1.0,
79
+ "sl_pct": None, "tp_pct": None, "trail_pct": None,
80
+ "validation_mode": "walk_forward",
81
+ "train_months": 12, "test_months": 3, "roll_months": 3,
82
+ "holdout_months": config.DEFAULT_HOLDOUT_MONTHS,
83
+ "acc": {"strategy": True, "universe": True,
84
+ "costs": False, "sizing": False, "validation": False},
85
+ "cfg_id": uuid.uuid4().hex[:4].upper(),
86
+ "coverage": "",
87
+ "needs_signals": False,
88
+ "log_scale": False, "cvd": False,
89
+ "metric": "OOS Sharpe", "topn": 15,
90
+ "sig_asset": "BTC-USD", "sig_tf": "1d",
91
+ }
92
+
93
+
94
+ def to_request(st: dict) -> RunRequest:
95
+ return RunRequest(
96
+ strategy=st["strategy"], asset=st["asset"], timeframe=st["timeframe"],
97
+ date_range=st["range"], model_slug=st.get("model") or "",
98
+ params=dict(st["params"]),
99
+ costs_on=st["costs_on"], commission_bps=float(st["commission_bps"]),
100
+ slippage_bps=float(st["slippage_bps"]), slippage_model=st["slippage_model"],
101
+ sizing_mode=st["sizing_mode"], size_pct=float(st["size_pct"]),
102
+ leverage=float(st["leverage"]),
103
+ sl_pct=(float(st["sl_pct"]) / 100.0 if st["sl_pct"] else None),
104
+ tp_pct=(float(st["tp_pct"]) / 100.0 if st["tp_pct"] else None),
105
+ trail_pct=(float(st["trail_pct"]) / 100.0 if st["trail_pct"] else None),
106
+ validation_mode=st["validation_mode"],
107
+ train_months=int(st["train_months"]), test_months=int(st["test_months"]),
108
+ roll_months=int(st["roll_months"]), holdout_months=int(st["holdout_months"]),
109
+ )
110
+
111
+
112
+ def refresh_context(st: dict) -> dict:
113
+ """Recompute what depends on the current asset/timeframe/strategy."""
114
+ preset = strategies.PRESETS.get(st["strategy"])
115
+ st["needs_signals"] = bool(preset and preset.needs_signals)
116
+ models = runtime.available_models(st["asset"], st["timeframe"])
117
+ if st["needs_signals"] and st.get("model") not in models:
118
+ st["model"] = models[0] if models else None
119
+ cov = runtime.price_coverage_for(st["asset"], st["timeframe"])
120
+ st["coverage"] = (f"cached {cov[0]} to {cov[1]} · {len(models)} models"
121
+ if cov else "no cached coverage")
122
+ return st
123
+
124
+
125
+ def apply_action(st: dict, raw: str) -> tuple[dict, bool]:
126
+ """Fold one UI action into state. Returns (state, should_run)."""
127
+ action = parse_action(raw)
128
+ if action is None or action.is_noop:
129
+ return st, False
130
+
131
+ k, v = action.key, action.value
132
+
133
+ if k == "acc":
134
+ st["acc"][v] = not st["acc"].get(v, False)
135
+ elif k == "strategy" and v in strategies.PRESETS:
136
+ st["strategy"] = v
137
+ st["params"] = dict(strategies.defaults_for(v))
138
+ elif k == "asset" and v in config.ASSETS:
139
+ st["asset"] = v
140
+ elif k == "tf" and v in config.TIMEFRAMES:
141
+ st["timeframe"] = v
142
+ elif k == "range" and v in runtime.RANGE_YEARS:
143
+ st["range"] = v
144
+ elif k == "model":
145
+ st["model"] = v or None
146
+ elif k == "slippage" and v in ("fixed", "volume_scaled"):
147
+ st["slippage_model"] = v
148
+ elif k == "sizing" and v in ("fixed_pct", "vol_target"):
149
+ st["sizing_mode"] = v
150
+ elif k == "validation" and v in ("walk_forward", "split", "holdout", "none"):
151
+ st["validation_mode"] = v
152
+ elif k == "costs":
153
+ _, val = parse_pair(v) if "=" in v else ("", v)
154
+ st["costs_on"] = str(val) == "1"
155
+ elif k == "param":
156
+ name, val = parse_pair(v)
157
+ st = _set_param(st, name, val)
158
+ elif k == "logscale":
159
+ st["log_scale"] = not st["log_scale"]
160
+ elif k == "cvd":
161
+ st["cvd"] = not st["cvd"]
162
+ elif k == "metric" and v in CT.RANK_METRICS:
163
+ st["metric"] = v
164
+ elif k == "topn":
165
+ try:
166
+ st["topn"] = max(5, min(50, int(v)))
167
+ except ValueError:
168
+ pass
169
+ elif k == "sigasset" and v in config.ASSETS:
170
+ st["sig_asset"] = v
171
+ elif k == "sigtf" and v in config.TIMEFRAMES:
172
+ st["sig_tf"] = v
173
+ elif k == "example":
174
+ st.update(strategy="Chronos Forecast Follower", asset="BTC-USD",
175
+ timeframe="1d", range="3Y",
176
+ params=dict(strategies.defaults_for("Chronos Forecast Follower")))
177
+ st = refresh_context(st)
178
+ return st, True
179
+ elif k == "run":
180
+ return refresh_context(st), True
181
+
182
+ return refresh_context(st), False
183
+
184
+
185
+ def _set_param(st: dict, name: str, raw: str) -> dict:
186
+ """Set a numeric field, ignoring anything that is not a number."""
187
+ if name in NUMERIC_PARAMS:
188
+ cast = NUMERIC_PARAMS[name]
189
+ if raw == "":
190
+ st[name] = None if name in ("sl_pct", "tp_pct", "trail_pct") else st[name]
191
+ return st
192
+ try:
193
+ st[name] = cast(float(raw))
194
+ except (TypeError, ValueError):
195
+ pass
196
+ return st
197
+
198
+ preset = strategies.PRESETS.get(st["strategy"])
199
+ valid = {p[0] for p in (preset.params if preset else ())}
200
+ if name in valid:
201
+ try:
202
+ st["params"][name] = float(raw)
203
+ except (TypeError, ValueError):
204
+ pass
205
+ return st
206
+
207
+
208
+ # --------------------------------------------------------------------------
209
+ # Renderers
210
+ # --------------------------------------------------------------------------
211
+
212
+
213
+ def render_left(st: dict) -> str:
214
+ preset = strategies.PRESETS.get(st["strategy"])
215
+ params = [(k, label, default) for k, label, default, _lo, _hi
216
+ in (preset.params if preset else ())]
217
+ return shell.left_panel(
218
+ st,
219
+ presets=[p.name for p in strategies.PRESETS.values() if p.available],
220
+ assets=runtime.available_assets(),
221
+ timeframes=list(config.TIMEFRAMES),
222
+ models=runtime.available_models(st["asset"], st["timeframe"]),
223
+ preset_params=params,
224
+ )
225
+
226
+
227
+ def render_top(st: dict, rec: RunRecord | None) -> str:
228
+ if rec is None:
229
+ return shell.top_bar(status="NO RUN LOADED", tone="idle")
230
  mode = {"walk_forward": "WALK-FORWARD", "holdout": "HOLDOUT",
231
+ "split": "SPLIT", "none": "NO SPLIT"}.get(st["validation_mode"], "")
232
  try:
233
+ s, e = runtime.window_for(st["asset"], st["timeframe"], st["range"])
234
+ span = f"{s.date()} to {e.date()}"
235
  except Exception:
236
+ span = st["range"]
237
+ ctx = f'{st["asset"]} · {st["timeframe"].upper()} · {span} · {mode}'
238
+ return shell.top_bar(context=ctx, status=f"RUN {rec.run_id} COMPLETE",
239
+ tone="ok", elapsed=f"{rec.elapsed_s:.1f}s")
240
+
241
+
242
+ def render_stat_band(rec: RunRecord | None) -> str:
243
+ if rec is None:
244
+ return ""
245
+ r = rec.result
246
+ a, i, o = r.metrics_all, r.metrics_is, r.metrics_oos
247
+
248
+ def seg(m, fmt, key, *args):
249
+ return EM if m.bars == 0 else fmt(getattr(m, key), *args)
250
+
251
+ def isoos(fmt, key, *args):
252
+ return f"IS {seg(i, fmt, key, *args)} · OOS {seg(o, fmt, key, *args)}"
253
+
254
+ bench = (float(r.benchmark_equity.iloc[-1] / r.benchmark_equity.iloc[0] - 1.0)
255
+ if len(r.benchmark_equity) else float("nan"))
256
+ gap = a.total_return - bench if pd.notna(bench) else float("nan")
257
+
258
+ spec = [
259
+ ("Total return", pct(a.total_return), isoos(pct, "total_return"),
260
+ tone(a.total_return)),
261
+ ("CAGR", pct(a.cagr), isoos(pct, "cagr"), tone(a.cagr)),
262
+ ("Sharpe", num(a.sharpe), isoos(num, "sharpe"), tone(a.sharpe)),
263
+ ("Sortino", num(a.sortino), isoos(num, "sortino"), tone(a.sortino)),
264
+ ("Max drawdown", pct(a.max_drawdown), isoos(pct, "max_drawdown"), "down"),
265
+ ("Win rate", pct(a.win_rate, 0, signed=False),
266
+ f"IS {seg(i, pct, 'win_rate', 0, False)} · OOS {seg(o, pct, 'win_rate', 0, False)}", ""),
267
+ ("Profit factor", num(a.profit_factor), isoos(num, "profit_factor"),
268
+ tone(a.profit_factor - 1.0)),
269
+ ("Trades", count(a.trade_count),
270
+ f"IS {seg(i, count, 'trade_count')} · OOS {seg(o, count, 'trade_count')}", ""),
271
+ ("Exposure", pct(a.exposure, 0, signed=False),
272
+ f"IS {seg(i, pct, 'exposure', 0, False)} · OOS {seg(o, pct, 'exposure', 0, False)}", ""),
273
+ ("vs buy & hold", pct(gap), f"costs paid {money(r.costs_paid)}", tone(gap)),
274
+ ]
275
+ cells = [shell.stat_cell(l, v, s, tone_class=("up" if t == "bit-up" else
276
+ "down" if t == "bit-down" else ""))
277
+ for l, v, s, t in spec]
278
+
279
+ notes = [shell.note(esc(n), danger=True) for n in getattr(r.plan, "notes", [])]
280
+ if r.metrics_holdout is not None:
281
+ h = r.metrics_holdout
282
+ ok = h.total_return > 0
283
+ notes.append(shell.note(
284
+ f"<b>LOCKED HOLDOUT</b> · return {pct(h.total_return)} · "
285
+ f"Sharpe {num(h.sharpe)} · {h.bars} bars never used for any "
286
+ f"parameter choice." + ("" if ok else " <b>It loses money here.</b>"),
287
+ danger=not ok))
288
+ return shell.stat_band(cells, notes)
289
+
290
+
291
+ def render_right(hist) -> str:
292
+ runs = [(r.label, r.meta, r.sharpe, r.result.metrics_all.total_return)
293
+ for r in (hist or [])]
294
+ return shell.right_panel(runs, GLOSSARY)
295
+
296
+
297
+ def render_table(df, *, align_right=(), empty="no rows", max_height="430px") -> str:
298
+ headers, rows = shell.frame_to_rows(df)
299
+ return shell.table(headers, rows, align_right=set(align_right),
300
+ empty=empty, max_height=max_height)
301
 
302
 
303
  def trades_frame(rec: RunRecord | None) -> pd.DataFrame:
 
312
  "Exit": t["exit_ts"].dt.strftime("%Y-%m-%d %H:%M"),
313
  "Side": t["side"].str.upper(),
314
  "Entry px": t["entry_px"].round(2), "Exit px": t["exit_px"].round(2),
315
+ "Size": t["size"].round(4), "Gross": t["gross_pnl"].round(2),
316
+ "Costs": t["costs"].round(2), "Net": t["net_pnl"].round(2),
317
+ "R": t["r_multiple"].round(2), "Bars": t["duration_bars"],
318
+ "MAE": (t["mae"] * 100).round(1), "Segment": t["segment"],
319
+ "Trigger": t["trigger"],
320
  })
321
 
322
 
 
 
 
 
 
 
 
323
  def report_markdown(rec: RunRecord | None) -> str:
324
  if rec is None:
325
  return "_Run a backtest to generate the report._"
 
332
  if len(r.benchmark_equity) else float("nan"))
333
  ratio = (o.sharpe / r.metrics_is.sharpe) if r.metrics_is.sharpe else float("nan")
334
  grade, checks = runtime.overfit_verdict(rec)
 
335
  lines = [
336
  f"### {rec.label}",
337
  f"`RUN {rec.run_id} · {rec.created_at} · {req.validation_mode.upper()} · "
 
339
  f"Over {a.bars} bars and {a.trade_count} trades the strategy returns "
340
  f"**{pct(a.total_return)}** (CAGR {pct(a.cagr)}, Sharpe {num(a.sharpe)}) "
341
  f"against **{pct(bench)}** for buy and hold, with a maximum drawdown of "
342
+ f"{pct(a.max_drawdown)}. Modelled costs of {money(r.costs_paid)} are "
343
+ f"already deducted.", "",
344
+ (f"Out-of-sample Sharpe is {num(o.sharpe)}, {num(ratio)} of in-sample."
345
+ if o.bars else
346
+ "**No out-of-sample period was produced**, so every number above is "
347
+ "in-sample."), "",
348
+ (f"On the locked holdout ({h.bars} bars) it returns {pct(h.total_return)} "
349
+ f"at Sharpe {num(h.sharpe)}." if h else ""),
 
 
350
  "", f"**Verdict: {grade}**", "",
351
  ]
352
+ lines += [f"- {m} {t}" for m, t in checks]
353
  lines += ["", "#### Config snapshot", "```json",
354
  json.dumps(asdict(req), indent=2, sort_keys=True), "```"]
355
  return "\n".join(lines)
 
358
  def build_overview(rec: RunRecord, *, log_scale: bool, cvd: bool):
359
  r = rec.result
360
  bpy = config.bars_per_year(rec.request.asset, rec.request.timeframe)
361
+ window = {"1d": 90, "1h": 24 * 30, "15m": 4 * 24 * 14}.get(
362
+ rec.request.timeframe, 90)
363
  costs_html = (
364
+ shell.note(f"<b>COSTS PAID TOTAL: {money(r.costs_paid)}</b>. "
365
+ "The costed number is the real one.")
366
  if rec.request.costs_on else
367
+ shell.note("<b>COSTS ARE OFF.</b> These numbers are not achievable.",
368
+ danger=True))
369
  return (
370
  charts.equity_curve(r.equity, r.benchmark_equity, plan=r.plan,
371
  log_scale=log_scale, cvd=cvd),
 
380
  )
381
 
382
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  # --------------------------------------------------------------------------
384
  # App
385
  # --------------------------------------------------------------------------
 
389
  store = runtime.get_store()
390
  all_assets = runtime.available_assets()
391
  all_tfs = list(config.TIMEFRAMES)
 
 
392
 
393
  with gr.Blocks(theme=theme.bit_theme(), css=theme.full_css(),
394
+ head=BRIDGE_JS, title="Bit · Backtest Lab",
395
+ analytics_enabled=False, fill_height=True) as demo:
396
 
397
+ state = gr.State(default_state())
398
  history = gr.State([])
399
  current = gr.State(None)
400
 
401
+ # The bridge target. Hidden, but must stay in the DOM for the
402
+ # delegated listener to find and write into.
403
+ action_box = gr.Textbox(elem_id=ACTION_ELEMENT_ID, visible=False,
404
+ label="", show_label=False)
405
+
406
+ top_html = gr.HTML(shell.top_bar())
407
+
408
+ with gr.Row(elem_classes="bit-zones", equal_height=False):
409
+ with gr.Column(elem_classes="bit-zone-left", min_width=0):
410
+ left_html = gr.HTML(render_left(default_state()))
411
+
412
+ with gr.Column(elem_classes="bit-zone-center", min_width=0):
413
+ stat_html = gr.HTML("")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
 
415
  with gr.Tabs():
 
416
  with gr.Tab("Compare"):
 
 
 
417
  catalog_meta = gr.HTML("")
 
418
  with gr.Tabs():
419
  with gr.Tab("Leaderboard"):
420
  podium = gr.HTML("")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
  lb_meta = gr.HTML("")
422
+ lb_table = gr.HTML("")
423
+ gr.HTML(shell.micro(
424
+ "returns over time · top ranked · "
425
+ "cumulative, costs included"))
 
 
426
  lb_overlay = gr.Plot()
427
+ gr.HTML(shell.micro(
428
+ "risk vs return · marker area = trade count"))
 
429
  lb_scatter = gr.Plot()
430
 
431
  with gr.Tab("Models"):
 
 
432
  models_note = gr.HTML("")
433
  with gr.Row():
434
  acc_plot = gr.Plot()
435
  cal_plot = gr.Plot()
 
 
 
436
  model_bars = gr.Plot()
437
+ score_table = gr.HTML("")
 
 
 
438
 
439
  with gr.Tab("Signals"):
 
 
 
 
 
440
  sig_panel = gr.HTML("")
 
 
 
441
 
442
  with gr.Tab("Run history"):
443
+ runs_table = gr.HTML("")
444
+
 
 
 
 
 
 
 
 
445
  with gr.Tab("Overview"):
446
  empty_state = gr.HTML(C.empty_state())
447
  equity_plot = gr.Plot()
 
 
 
448
  regime_plot = gr.Plot()
449
  with gr.Row():
450
  underwater_plot = gr.Plot()
 
457
  costs_note = gr.HTML("")
458
 
459
  with gr.Tab("Trades"):
460
+ trades_head = gr.HTML("")
461
+ trades_html = gr.HTML("")
 
 
 
 
 
462
 
463
  with gr.Tab("Robustness"):
464
  verdict_html = gr.HTML("")
465
  with gr.Row():
466
  wf_plot = gr.Plot()
467
  mc_plot = gr.Plot()
 
 
 
 
 
468
 
469
  with gr.Tab("Report"):
470
+ report_md = gr.Markdown("_Run a backtest to generate "
471
+ "the report._")
472
  report_equity = gr.Plot()
 
 
 
 
 
 
473
 
474
  with gr.Tab("Coverage"):
475
  coverage_kpis = gr.HTML("")
476
+ coverage_html = gr.HTML("")
 
 
 
 
 
477
  extend_panel = gr.HTML("")
478
  with gr.Row():
479
+ ext_model = gr.Dropdown(list(config.SEED_MODELS),
480
+ label="Model", scale=2)
481
+ ext_asset = gr.Dropdown(list(config.ASSETS),
482
+ label="Asset", scale=2)
483
  ext_tf = gr.Dropdown(all_tfs, value="1d",
484
  label="Timeframe", scale=1)
485
  with gr.Row():
 
491
  extend_btn = gr.Button("Extend coverage", size="sm",
492
  elem_classes="bit-run-btn")
493
  extend_out = gr.HTML("")
 
494
  with gr.Row():
495
  add_family = gr.Dropdown(
496
+ list(config.ALLOWED_ADAPTER_FAMILIES),
497
+ value="chronos", label="Adapter family", scale=1)
498
+ add_model_id = gr.Textbox(label="HF model id", scale=2)
 
499
  add_btn = gr.Button("Smoke test & add", size="sm",
500
  elem_classes="bit-ghost-btn", scale=1)
501
  add_out = gr.HTML("")
502
 
503
+ with gr.Column(elem_classes="bit-zone-right", min_width=0):
504
+ right_html = gr.HTML(render_right([]))
505
+
506
+ gr.HTML(shell.footer())
507
+
508
+ # ------------------------------------------------------------------
509
+ # Wiring
510
+ # ------------------------------------------------------------------
511
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  overview_out = [equity_plot, regime_plot, underwater_plot, rolling_plot,
513
  price_plot, pnl_plot, hold_plot, mae_plot, costs_note]
514
+ compare_out = [podium, lb_table, lb_overlay, lb_scatter, lb_meta]
515
+
516
+ def compare_views(st):
517
+ """Leaderboard view, with the HTML table rendered by the shell."""
518
+ pod, table_df, overlay, scatter, meta = CT.build_leaderboard_view(
519
+ runtime.get_store(), assets=None, timeframes=None,
520
+ strategies_=None, models=None, metric_label=st["metric"],
521
+ min_trades=0, hide_baselines=False, require_oos=True,
522
+ top_n=st["topn"])
523
+ return (pod, render_table(table_df, align_right=range(4, 15),
524
+ empty="catalog not generated yet"),
525
+ overlay, scatter, meta)
526
+
527
+ def on_action(raw, st, hist, rec, progress=gr.Progress()):
528
+ st = copy.deepcopy(st)
529
+ st, should_run = apply_action(st, raw)
530
+
531
+ if not should_run:
532
+ # A pure UI change: re-render the panel and the compare views
533
+ # that depend on state, and leave the run outputs alone.
534
+ return (st, hist, rec, render_left(st), gr.update(),
535
+ gr.update(), *(gr.update(),) * 9, gr.update(),
536
+ gr.update(), gr.update(), gr.update(),
537
+ *compare_views(st), render_right(hist))
538
+
539
+ progress(0.2, desc="Reading cached slices")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
540
  try:
541
+ progress(0.5, desc="Simulating trades")
542
+ rec = runtime.execute(to_request(st))
543
+ except (RunError, ValueError) as exc:
544
+ return (st, hist, None, render_left(st),
545
+ shell.top_bar(status="RUN FAILED", tone="warn"),
546
+ shell.note(esc(str(exc)), danger=True),
547
+ *(gr.update(),) * 9,
548
+ shell.micro("no trades"), "",
549
+ "_Run failed._", gr.update(),
550
+ *compare_views(st), render_right(hist))
551
+
552
+ progress(0.85, desc="Building charts")
553
  hist = ([rec] + list(hist))[:40]
554
+ return (
555
+ st, hist, rec, render_left(st), render_top(st, rec),
556
+ render_stat_band(rec),
557
+ *build_overview(rec, log_scale=st["log_scale"], cvd=st["cvd"]),
558
+ shell.micro(f"{len(rec.result.trades)} total · costs paid "
559
+ f"{money(rec.result.costs_paid)} · fills at next bar open"),
560
+ render_table(trades_frame(rec), align_right=range(4, 13),
561
+ empty="no trades", max_height="520px"),
562
+ report_markdown(rec),
563
+ charts.equity_curve(rec.result.equity, rec.result.benchmark_equity,
564
+ plan=rec.result.plan),
565
+ *compare_views(st), render_right(hist),
566
+ )
567
+
568
+ action_out = [state, history, current, left_html, top_html, stat_html,
569
+ *overview_out, trades_head, trades_html, report_md,
570
+ report_equity, *compare_out, right_html]
571
+
572
+ action_box.change(on_action, [action_box, state, history, current],
573
+ action_out, show_progress="minimal")
574
+
575
+ current.change(
576
+ lambda rec: (
577
+ ("", charts.empty_figure("run a backtest first"),
578
+ charts.empty_figure("run a backtest first"))
579
+ if rec is None else (
580
+ (lambda g, c: (
581
+ f'<div style="background:var(--bg-panel);border:1px solid '
582
+ f'var(--border-default);padding:12px;margin-bottom:8px">'
583
+ f'<div style="font-family:var(--font-styrene);'
584
+ f'text-transform:uppercase;font-size:var(--text-sm);'
585
+ f'letter-spacing:var(--tracking-wide)">Overfit verdict: '
586
+ f'<span style="color:var(--accent-amber-strong)">{g}</span>'
587
+ f'</div>' + "".join(
588
+ f'<div style="font-size:var(--text-2xs);'
589
+ f'color:var(--text-secondary);line-height:1.7">'
590
+ f'{m} {esc(t)}</div>' for m, t in c) + "</div>"
591
+ ))(*runtime.overfit_verdict(rec)),
592
+ charts.walk_forward_bars(rec.result.windows),
593
+ charts.monte_carlo_cone(
594
+ charts.monte_carlo_paths(rec.result.trades)),
595
+ )
596
+ ),
597
+ [current], [verdict_html, wf_plot, mc_plot])
598
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
  estimate_btn.click(extension.estimate_ui,
600
  [ext_model, ext_asset, ext_tf, ext_start, ext_end],
601
  [extend_out])
602
+ extend_btn.click(
603
+ lambda m, a, t, s, e: (
604
+ (lambda html, _df: (html, render_table(runtime.coverage_frame())))(
605
+ *extension.extend_ui(m, a, t, s, e))),
606
+ [ext_model, ext_asset, ext_tf, ext_start, ext_end],
607
+ [extend_out, coverage_html])
608
+ add_btn.click(
609
+ lambda f, m: (
610
+ (lambda html, _df: (html, render_table(runtime.coverage_frame())))(
611
+ *extension.add_model_ui(f, m))),
612
+ [add_family, add_model_id], [add_out, coverage_html])
613
+
614
+ def on_load(st):
615
+ store = runtime.get_store()
616
+ st = refresh_context(copy.deepcopy(st))
617
+ meta = catalog.catalog_meta(store)
618
+ meta_html = shell.micro(
619
  f"{meta.get('leaderboard_rows', 0)} combinations · "
620
+ f"{meta.get('scorecard_rows', 0)} model slices · "
621
+ f"{meta.get('canonical_config', '')}") if meta else \
622
+ shell.micro("catalog not built")
623
+
624
+ note, acc, cal, bars, score_df = CT.build_models_view(store, "1d")
625
+ sig = CT.build_signals_view(store, st["sig_asset"], st["sig_tf"])
626
+ saved = catalog.load_saved_runs(store)
627
+
628
+ return (st, render_left(st), meta_html, *compare_views(st),
629
+ note, acc, cal, bars,
630
+ render_table(score_df, align_right=range(3, 10),
631
+ empty="no scorecard rows"),
632
+ sig,
633
+ render_table(CT.runs_table([], saved), empty="no saved runs"),
634
+ C.coverage_summary(runtime.coverage_map()),
635
+ render_table(runtime.coverage_frame(), empty="no coverage"),
636
+ extension.status_html())
637
+
638
+ demo.load(on_load, [state],
639
+ [state, left_html, catalog_meta, *compare_out,
640
+ models_note, acc_plot, cal_plot, model_bars, score_table,
641
+ sig_panel, runs_table, coverage_kpis, coverage_html,
642
+ extend_panel])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
643
 
644
  return demo
645
 
src/ui/bridge.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Click bridge: design markup in, Python state out.
2
+
3
+ The design is built almost entirely from `<button>` elements -- 23 of them
4
+ against a single `<input>` in the whole file. Gradio's own controls render a
5
+ completely different DOM (`gr.Radio` becomes a `<fieldset>` of labels wrapping
6
+ hidden radio inputs), which is why restyling them from outside only ever
7
+ approximates the design.
8
+
9
+ So the visible layer is the design's own markup, and this module is how a real
10
+ `<button>` reaches Python:
11
+
12
+ <button data-bit="tf:1h">1h</button>
13
+ |
14
+ | delegated listener (installed once, in <head>)
15
+ v
16
+ hidden Textbox #bit-action <- value set + native `input` event dispatched
17
+ |
18
+ v
19
+ Gradio .change() -> parse_action() -> new state -> re-render
20
+
21
+ A nonce is appended to every action because Gradio's `.change()` only fires when
22
+ the value actually differs; clicking the same button twice must still register.
23
+
24
+ Nothing here evaluates anything. Actions are `key:value` pairs, the key must be
25
+ on `ALLOWED_KEYS`, and the value is returned as an opaque string for the caller
26
+ to validate against its own domain.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass
32
+
33
+ # Every action the UI is allowed to emit. An unknown key is dropped, so a
34
+ # hand-crafted click cannot reach code paths the UI does not offer.
35
+ ALLOWED_KEYS = frozenset({
36
+ "tab", # switch a top-level tab
37
+ "subtab", # switch a Compare sub-tab
38
+ "acc", # toggle a left-panel accordion section
39
+ "strategy", # choose a preset
40
+ "asset", # choose an asset
41
+ "tf", # choose a timeframe
42
+ "range", # choose a date range
43
+ "model", # choose a forecast model
44
+ "param", # set a strategy parameter (param:key=value)
45
+ "costs", # toggle costs on/off
46
+ "slippage", # choose a slippage model
47
+ "sizing", # choose a sizing mode
48
+ "validation", # choose a validation mode
49
+ "metric", # leaderboard rank metric
50
+ "filter", # leaderboard filter toggle (filter:kind=value)
51
+ "topn", # leaderboard row count
52
+ "sigasset", # signal aggregator asset
53
+ "sigtf", # signal aggregator timeframe
54
+ "run", # run the backtest
55
+ "example", # load the worked example
56
+ "reset", # reset leaderboard filters
57
+ "logscale", # toggle log scale
58
+ "cvd", # toggle colorblind-safe prices
59
+ "noop", # explicit no-op, used by disabled controls
60
+ })
61
+
62
+ ACTION_ELEMENT_ID = "bit-action"
63
+
64
+ # Separates the action from its nonce. A pipe is used rather than a space
65
+ # because action values are human-readable names -- "SMA Crossover", "Buy & Hold
66
+ # (benchmark)" -- which legitimately contain spaces. No value contains a pipe.
67
+ NONCE_SEP = "|"
68
+
69
+ # Installed once into <head>. Capture-phase delegation keeps it working after
70
+ # Gradio replaces the innerHTML of a re-rendered zone.
71
+ BRIDGE_JS = """
72
+ <script>
73
+ (function () {
74
+ if (window.__bitBridgeInstalled) return;
75
+ window.__bitBridgeInstalled = true;
76
+
77
+ function holder() {
78
+ var root = document.getElementById('__ELEM_ID__');
79
+ return root ? root.querySelector('textarea, input') : null;
80
+ }
81
+
82
+ document.addEventListener('click', function (e) {
83
+ var el = e.target && e.target.closest ? e.target.closest('[data-bit]') : null;
84
+ if (!el) return;
85
+ var action = el.getAttribute('data-bit');
86
+ if (!action || action.indexOf('noop:') === 0) return;
87
+ e.preventDefault();
88
+ e.stopPropagation();
89
+
90
+ var box = holder();
91
+ if (!box) return;
92
+ // The nonce makes every click a distinct value, so clicking the same
93
+ // button twice still fires Gradio's change event.
94
+ var nonce = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
95
+ box.value = action + '|' + nonce;
96
+ box.dispatchEvent(new Event('input', { bubbles: true }));
97
+ }, true);
98
+
99
+ // Numeric and text fields emit on commit (blur / Enter), not per keystroke,
100
+ // so a backtest is not re-run on every digit typed.
101
+ function commit(el) {
102
+ if (!el || !el.hasAttribute('data-bit-input')) return;
103
+ var box = holder();
104
+ if (!box) return;
105
+ var nonce = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
106
+ box.value = el.getAttribute('data-bit-input') + '=' + el.value + '|' + nonce;
107
+ box.dispatchEvent(new Event('input', { bubbles: true }));
108
+ }
109
+
110
+ document.addEventListener('change', function (e) { commit(e.target); }, true);
111
+ document.addEventListener('keydown', function (e) {
112
+ if (e.key === 'Enter') { commit(e.target); }
113
+ }, true);
114
+ })();
115
+ </script>
116
+ """.replace("__ELEM_ID__", ACTION_ELEMENT_ID)
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class Action:
121
+ key: str
122
+ value: str
123
+
124
+ @property
125
+ def is_noop(self) -> bool:
126
+ return self.key == "noop"
127
+
128
+
129
+ def emit(key: str, value: str = "") -> str:
130
+ """Build the `data-bit` attribute value for a clickable element."""
131
+ if key not in ALLOWED_KEYS:
132
+ raise ValueError(f"{key!r} is not an allowed UI action")
133
+ if NONCE_SEP in str(value):
134
+ raise ValueError(f"action values may not contain {NONCE_SEP!r}")
135
+ return f"{key}:{value}"
136
+
137
+
138
+ def parse_action(raw):
139
+ """Parse what the bridge wrote into the hidden textbox.
140
+
141
+ Returns None for anything unparseable or not on the allow-list, so a
142
+ malformed or hand-crafted payload is ignored rather than raising.
143
+ """
144
+ if not raw or not isinstance(raw, str):
145
+ return None
146
+ payload = raw.split(NONCE_SEP, 1)[0]
147
+ if ":" not in payload:
148
+ return None
149
+ key, _, value = payload.partition(":")
150
+ key = key.strip()
151
+ if key not in ALLOWED_KEYS:
152
+ return None
153
+ return Action(key=key, value=value.strip())
154
+
155
+
156
+ def parse_pair(value: str):
157
+ """Split a compound action value like `fast_ma=20` -> ("fast_ma", "20")."""
158
+ name, _, val = value.partition("=")
159
+ return name.strip(), val.strip()
src/ui/shell.py ADDED
@@ -0,0 +1,485 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The design's own markup, rendered from state.
2
+
3
+ Every visible control here is the element the design uses -- a real `<button>`
4
+ with the design's exact padding and type -- not a Gradio component restyled from
5
+ outside. Gradio still owns the plots and the transport; it no longer owns the
6
+ chrome.
7
+
8
+ Values are lifted verbatim from `Backtest Lab.dc.html`: 286px left aside, 306px
9
+ right tray, `8px 12px` header padding, `3px 7px` chips, `4px 6px` segments,
10
+ `9px 12px` panel headers, `0 12px 12px` section bodies with a 10px gap. The DS
11
+ utility classes (`mono-data`, `pixel-text`, `styrene-text`) come from base.css
12
+ and are used rather than reinvented.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .bridge import emit
18
+ from .format import EM, count, esc, money, num, pct, sharpe_tone
19
+
20
+ # Zone widths, straight from the design.
21
+ LEFT_W = "286px"
22
+ RIGHT_W = "306px"
23
+
24
+ # The mark shipped with the design (uploads/bit-trading-mark.svg).
25
+ MARK = (
26
+ '<svg viewBox="0 0 100 100" width="18" height="18" role="img" '
27
+ 'aria-label="The Bit Trading Company" style="flex:0 0 auto">'
28
+ '<path fill="var(--accent-amber)" fill-rule="evenodd" '
29
+ 'd="M0 0H100V100H0Z M50 10H90V90H50Z M22 42H38V58H22Z M62 42H78V58H62Z">'
30
+ "</path></svg>"
31
+ )
32
+
33
+ DISCLAIMER = (
34
+ "Simulated results with modeled costs. Backtests are hypotheses, not "
35
+ "promises. Past performance does not predict future results. "
36
+ "Not financial advice."
37
+ )
38
+
39
+
40
+ # --------------------------------------------------------------------------
41
+ # Primitives, matching the design's exact treatments
42
+ # --------------------------------------------------------------------------
43
+
44
+
45
+ def chip(label, action=None, *, active=False, title=""):
46
+ """A `3px 7px` mono chip -- timeframe, date range, ticker."""
47
+ bg = "var(--accent-amber)" if active else "transparent"
48
+ fg = "var(--stone-950)" if active else "var(--text-secondary)"
49
+ border = "var(--accent-amber)" if active else "var(--border-default)"
50
+ attr = f' data-bit="{esc(action)}"' if action else ""
51
+ t = f' title="{esc(title)}"' if title else ""
52
+ return (
53
+ f'<button{attr}{t} class="mono-data" style="padding:3px 7px;'
54
+ f"background:{bg};border:1px solid {border};color:{fg};cursor:pointer;"
55
+ f'font-size:var(--text-xs);transition:background 1s ease,color 1s ease">'
56
+ f"{esc(label)}</button>"
57
+ )
58
+
59
+
60
+ def segment(label, action, *, active=False, first=False):
61
+ """A full-width segmented control cell (`4px 6px`, styrene, uppercase)."""
62
+ bg = "var(--accent-amber)" if active else "transparent"
63
+ fg = "var(--stone-950)" if active else "var(--text-secondary)"
64
+ return (
65
+ f'<button data-bit="{esc(action)}" style="flex:1 1 auto;padding:4px 6px;'
66
+ f"background:{bg};border:1px solid var(--border-default);"
67
+ f"border-left-width:{'1px' if first else '0'};color:{fg};cursor:pointer;"
68
+ f"font-family:var(--font-styrene);font-size:var(--text-xs);"
69
+ f"text-transform:uppercase;letter-spacing:var(--tracking-wide);"
70
+ f'cursor:pointer">{esc(label)}</button>'
71
+ )
72
+
73
+
74
+ def field_label(text):
75
+ return (
76
+ f'<div class="pixel-text" style="color:var(--text-secondary);'
77
+ f'margin-bottom:4px">{esc(text)}</div>'
78
+ )
79
+
80
+
81
+ def row(children, gap="4px"):
82
+ return (
83
+ f'<div style="display:flex;flex-wrap:wrap;gap:{gap}">'
84
+ f'{"".join(children)}</div>'
85
+ )
86
+
87
+
88
+ def field(label, children, gap="4px"):
89
+ return f"<div>{field_label(label)}{row(children, gap)}</div>"
90
+
91
+
92
+ def note(text, danger=False):
93
+ color = "var(--fin-down)" if danger else "var(--accent-amber)"
94
+ return (
95
+ f'<div class="mono-data" style="border-left:2px solid {color};'
96
+ f"background:var(--bg-raised);padding:6px 10px;font-size:var(--text-2xs);"
97
+ f'line-height:1.6;color:var(--text-secondary);margin-bottom:6px">{text}</div>'
98
+ )
99
+
100
+
101
+ def micro(text):
102
+ return f'<div class="pixel-text" style="color:var(--text-tertiary)">{esc(text)}</div>'
103
+
104
+
105
+ def panel(title, body, meta=""):
106
+ m = (f'<span class="pixel-text" style="color:var(--text-tertiary);'
107
+ f'margin-left:auto">{esc(meta)}</span>') if meta else ""
108
+ return (
109
+ f'<div style="background:var(--bg-panel);border:1px solid '
110
+ f'var(--border-default);margin-bottom:8px">'
111
+ f'<div style="display:flex;align-items:center;gap:8px;padding:9px 12px;'
112
+ f'border-bottom:1px solid var(--border-default)">'
113
+ f'<span style="font-family:var(--font-styrene);font-size:var(--text-sm);'
114
+ f"text-transform:uppercase;letter-spacing:var(--tracking-wide)\">"
115
+ f"{esc(title)}</span>{m}</div>"
116
+ f'<div style="padding:12px">{body}</div></div>'
117
+ )
118
+
119
+
120
+ def section(key, number, title, open_, body):
121
+ """A numbered, collapsible left-panel section."""
122
+ glyph = "-" if open_ else "+"
123
+ inner = (
124
+ f'<div style="padding:0 12px 12px;display:flex;flex-direction:column;'
125
+ f'gap:10px">{body}</div>'
126
+ ) if open_ else ""
127
+ return (
128
+ f'<div style="border-bottom:1px solid var(--border-default)">'
129
+ f'<button data-bit="{esc(emit("acc", key))}" style="width:100%;'
130
+ f"display:flex;align-items:center;justify-content:space-between;gap:8px;"
131
+ f"padding:8px 12px;background:transparent;border:0;cursor:pointer;"
132
+ f'color:var(--text-primary)">'
133
+ f'<span style="font-family:var(--font-styrene);font-size:var(--text-sm);'
134
+ f"text-transform:uppercase;letter-spacing:var(--tracking-wide)\">"
135
+ f"{number} &middot; {esc(title)}</span>"
136
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
137
+ f'color:var(--text-tertiary)">{glyph}</span></button>{inner}</div>'
138
+ )
139
+
140
+
141
+ # --------------------------------------------------------------------------
142
+ # Top bar
143
+ # --------------------------------------------------------------------------
144
+
145
+
146
+ def top_bar(context="", status="NO RUN LOADED", tone="idle", elapsed=""):
147
+ colors = {
148
+ "ok": ("var(--accent-moss-strong)", "var(--accent-moss-dim)"),
149
+ "run": ("var(--accent-amber-strong)", "var(--accent-amber-dim)"),
150
+ "warn": ("var(--fin-down)", "var(--fin-down)"),
151
+ "idle": ("var(--text-tertiary)", "var(--border-default)"),
152
+ }
153
+ color, border = colors.get(tone, colors["idle"])
154
+
155
+ ctx = (
156
+ f'<span class="mono-data" style="font-size:var(--text-xs);'
157
+ f"color:var(--text-secondary);padding:3px 8px;border:1px solid "
158
+ f'var(--border-default);white-space:nowrap">{esc(context)}</span>'
159
+ ) if context else ""
160
+
161
+ el = (
162
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
163
+ f'color:var(--text-tertiary)">{esc(elapsed)}</span>'
164
+ ) if elapsed else ""
165
+
166
+ pulse = "animation:bitPulse 2s ease-in-out infinite" if tone == "run" else ""
167
+
168
+ return (
169
+ f'<header style="position:sticky;top:0;z-index:30;display:flex;'
170
+ f"align-items:center;gap:12px;flex-wrap:wrap;padding:8px 12px;"
171
+ f'background:var(--bg-panel);border-bottom:1px solid var(--border-default)">'
172
+ f"{MARK}"
173
+ f'<span style="color:var(--text-tertiary)">/</span>'
174
+ f'<span style="font-family:var(--font-styrene);text-transform:uppercase;'
175
+ f"letter-spacing:var(--tracking-wide);font-size:var(--text-md);"
176
+ f'white-space:nowrap;margin-top:3px">Backtest Lab</span>'
177
+ f"{ctx}"
178
+ f'<div style="display:flex;align-items:center;gap:8px;margin-left:auto;'
179
+ f'flex-wrap:wrap">'
180
+ f"{el}"
181
+ f'<span style="display:inline-flex;align-items:center;gap:6px;'
182
+ f'padding:3px 8px;border:1px solid {border}">'
183
+ f'<span style="display:inline-block;width:5px;height:5px;'
184
+ f'background:{color};{pulse}"></span>'
185
+ f'<span class="mono-data" style="font-size:var(--text-2xs);color:{color};'
186
+ f'white-space:nowrap">{esc(status)}</span></span>'
187
+ f'<a href="https://huggingface.co/datasets/The-Bit-Trading-Company/'
188
+ f'bit-signal-store" target="_blank" rel="noopener" class="mono-data" '
189
+ f'style="font-size:var(--text-2xs);color:var(--text-tertiary);'
190
+ f'padding:3px 8px;border:1px solid var(--border-default)">STORE &#8599;</a>'
191
+ f"</div></header>"
192
+ )
193
+
194
+
195
+ def footer():
196
+ return (
197
+ f'<footer style="position:sticky;bottom:0;z-index:30;display:flex;'
198
+ f"justify-content:space-between;gap:12px;padding:6px 12px;"
199
+ f"background:var(--bg-panel);border-top:1px solid var(--border-default)\">"
200
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
201
+ f'color:var(--text-tertiary)">{esc(DISCLAIMER)}</span>'
202
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
203
+ f'color:var(--text-tertiary);white-space:nowrap">BITTRADING SDK 1.2.0</span>'
204
+ f"</footer>"
205
+ )
206
+
207
+
208
+ # --------------------------------------------------------------------------
209
+ # Inputs
210
+ # --------------------------------------------------------------------------
211
+
212
+
213
+ def number_input(param_key, label, value, *, step="any"):
214
+ """Label left, boxed value right -- the design's parameter row."""
215
+ val = "" if value is None else value
216
+ return (
217
+ f'<div style="display:flex;align-items:center;gap:8px">'
218
+ f'<span class="pixel-text" style="color:var(--text-secondary);flex:1 1 auto">'
219
+ f"{esc(label)}</span>"
220
+ f'<input type="number" step="{step}" value="{esc(val)}" '
221
+ f'data-bit-input="param:{esc(param_key)}" class="mono-data" '
222
+ f'style="width:74px;padding:3px 6px;background:var(--bg-sunken);'
223
+ f"border:1px solid var(--border-default);color:var(--text-primary);"
224
+ f'font-size:var(--text-xs);text-align:right"></div>'
225
+ )
226
+
227
+
228
+ def toggle(action, label, on, *, warn_when_off=False):
229
+ """A two-state switch rendered as the design's segmented pair."""
230
+ danger = warn_when_off and not on
231
+ on_bg = "var(--accent-amber)" if on else "transparent"
232
+ off_bg = "var(--fin-down)" if danger else "transparent"
233
+ on_fg = "var(--stone-950)" if on else "var(--text-tertiary)"
234
+ off_fg = "var(--stone-950)" if danger else "var(--text-tertiary)"
235
+ return (
236
+ f'<div style="display:flex;align-items:center;gap:8px">'
237
+ f'<span class="pixel-text" style="color:var(--text-secondary);flex:1 1 auto">'
238
+ f"{esc(label)}</span>"
239
+ f'<div style="display:flex">'
240
+ f'<button data-bit="{esc(action)}=1" style="padding:3px 8px;'
241
+ f"background:{on_bg};border:1px solid var(--border-default);color:{on_fg};"
242
+ f'cursor:pointer;font-family:var(--font-mono);font-size:var(--text-2xs)">ON</button>'
243
+ f'<button data-bit="{esc(action)}=0" style="padding:3px 8px;'
244
+ f"background:{off_bg};border:1px solid var(--border-default);border-left:0;"
245
+ f"color:{off_fg};cursor:pointer;font-family:var(--font-mono);"
246
+ f'font-size:var(--text-2xs)">OFF</button></div></div>'
247
+ )
248
+
249
+
250
+ # --------------------------------------------------------------------------
251
+ # Left panel -- Strategy Builder
252
+ # --------------------------------------------------------------------------
253
+
254
+
255
+ def left_panel(st, *, presets, assets, timeframes, models, preset_params):
256
+ """The design's 286px aside, rendered from state."""
257
+ open_ = st.get("acc", {})
258
+
259
+ preset_chips = row(
260
+ [chip(p, emit("strategy", p), active=(p == st["strategy"])) for p in presets])
261
+ params = "".join(
262
+ number_input(k, label, st["params"].get(k, default))
263
+ for k, label, default in preset_params)
264
+ model_block = ""
265
+ if st.get("needs_signals"):
266
+ model_block = field(
267
+ "Forecast model",
268
+ [chip(m, emit("model", m), active=(m == st.get("model"))) for m in models]
269
+ or [micro("no models cached")])
270
+ strategy_body = (
271
+ field_label("Preset") + preset_chips
272
+ + (f'<div style="display:flex;flex-direction:column;gap:6px">{params}</div>'
273
+ if params else "") + model_block)
274
+
275
+ universe_body = (
276
+ field("Ticker", [chip(a, emit("asset", a), active=(a == st["asset"]))
277
+ for a in assets])
278
+ + field("Timeframe", [chip(t, emit("tf", t), active=(t == st["timeframe"]))
279
+ for t in timeframes])
280
+ + field("Date range", [chip(r, emit("range", r), active=(r == st["range"]))
281
+ for r in ("1Y", "3Y", "5Y", "Max")])
282
+ + (micro(st["coverage"]) if st.get("coverage") else ""))
283
+
284
+ costs_body = (
285
+ toggle("costs", "Costs", st["costs_on"], warn_when_off=True)
286
+ + number_input("commission_bps", "Commission bps / side", st["commission_bps"])
287
+ + number_input("slippage_bps", "Slippage bps", st["slippage_bps"])
288
+ + field("Slippage model",
289
+ [segment("Fixed bps", emit("slippage", "fixed"),
290
+ active=st["slippage_model"] == "fixed", first=True),
291
+ segment("Volume", emit("slippage", "volume_scaled"),
292
+ active=st["slippage_model"] == "volume_scaled")], gap="0")
293
+ + field("Fill", [chip("Next bar open", None, active=True,
294
+ title="Enforced by the engine; not configurable.")])
295
+ + note("Costs on. Turning these off is how strategies lie to you.",
296
+ danger=not st["costs_on"]))
297
+
298
+ sizing_body = (
299
+ field("Sizing",
300
+ [segment("Fixed %", emit("sizing", "fixed_pct"),
301
+ active=st["sizing_mode"] == "fixed_pct", first=True),
302
+ segment("Vol-target", emit("sizing", "vol_target"),
303
+ active=st["sizing_mode"] == "vol_target")], gap="0")
304
+ + number_input("size_pct", "Position size", st["size_pct"], step="0.05")
305
+ + number_input("leverage", "Leverage", st["leverage"], step="0.5")
306
+ + number_input("sl_pct", "Stop loss %", st["sl_pct"])
307
+ + number_input("tp_pct", "Take profit %", st["tp_pct"])
308
+ + number_input("trail_pct", "Trailing stop %", st["trail_pct"]))
309
+
310
+ val_modes = [("Walk-forward", "walk_forward"), ("Split", "split"),
311
+ ("Holdout", "holdout"), ("None", "none")]
312
+ validation_body = (
313
+ field("Mode", [chip(lbl, emit("validation", v),
314
+ active=st["validation_mode"] == v) for lbl, v in val_modes])
315
+ + number_input("train_months", "Train months", st["train_months"])
316
+ + number_input("test_months", "Test months", st["test_months"])
317
+ + number_input("roll_months", "Roll months", st["roll_months"])
318
+ + number_input("holdout_months", "OOS holdout months", st["holdout_months"]))
319
+
320
+ sections = (
321
+ section("strategy", "1", "Strategy", open_.get("strategy", True), strategy_body)
322
+ + section("universe", "2", "Universe & Data", open_.get("universe", True),
323
+ universe_body)
324
+ + section("costs", "3", "Costs & Execution", open_.get("costs", False),
325
+ costs_body)
326
+ + section("sizing", "4", "Sizing & Risk", open_.get("sizing", False),
327
+ sizing_body)
328
+ + section("validation", "5", "Validation", open_.get("validation", False),
329
+ validation_body))
330
+
331
+ run_row = (
332
+ f'<div style="padding:12px;display:flex;flex-direction:column;gap:6px">'
333
+ f'<button data-bit="{esc(emit("run"))}" style="width:100%;padding:8px 12px;'
334
+ f"background:var(--accent-amber);border:1px solid var(--accent-amber);"
335
+ f"color:var(--stone-950);cursor:pointer;font-family:var(--font-styrene);"
336
+ f"text-transform:uppercase;letter-spacing:var(--tracking-wide);"
337
+ f'font-size:var(--text-xs)">&#9654; Run backtest</button>'
338
+ f'<button data-bit="{esc(emit("example"))}" style="width:100%;padding:6px 12px;'
339
+ f"background:transparent;border:1px solid var(--border-default);"
340
+ f"color:var(--text-secondary);cursor:pointer;font-family:var(--font-mono);"
341
+ f'font-size:var(--text-2xs)">Load example</button></div>')
342
+
343
+ return (
344
+ f'<aside style="width:{LEFT_W};flex:0 0 {LEFT_W};'
345
+ f"border-right:1px solid var(--border-default);background:var(--bg-panel);"
346
+ f'display:flex;flex-direction:column">'
347
+ f'<div style="display:flex;align-items:center;justify-content:space-between;'
348
+ f'padding:9px 12px;border-bottom:1px solid var(--border-default)">'
349
+ f'<span style="font-family:var(--font-styrene);font-size:var(--text-md);'
350
+ f"text-transform:uppercase;letter-spacing:var(--tracking-wide)\">"
351
+ f"Strategy Builder</span>"
352
+ f'<span class="pixel-text" style="color:var(--text-tertiary)">'
353
+ f'CFG #{esc(st.get("cfg_id", "0000"))}</span></div>'
354
+ f"{sections}{run_row}</aside>")
355
+
356
+
357
+ # --------------------------------------------------------------------------
358
+ # Stat band
359
+ # --------------------------------------------------------------------------
360
+
361
+
362
+ def stat_cell(label, value, sub, *, tone_class="", title=""):
363
+ color = {"up": "var(--fin-up-strong)",
364
+ "down": "var(--fin-down-strong)"}.get(tone_class, "var(--text-primary)")
365
+ t = f' title="{esc(title)}"' if title else ""
366
+ return (
367
+ f'<div{t} style="flex:1 1 124px;padding:8px 12px;'
368
+ f"border-right:1px solid var(--border-subtle);"
369
+ f'border-bottom:1px solid var(--border-subtle)">'
370
+ f'<div class="pixel-text" style="color:var(--text-tertiary)">{esc(label)}</div>'
371
+ f'<div class="mono-data" style="font-size:var(--text-lg);'
372
+ f"line-height:var(--leading-tight);color:{color};margin:2px 0 1px;"
373
+ f'letter-spacing:var(--tracking-tight)">{value}</div>'
374
+ f'<div class="mono-data" style="font-size:var(--text-2xs);'
375
+ f'color:var(--text-tertiary)">{sub}</div></div>'
376
+ )
377
+
378
+
379
+ def stat_band(cells, notes=()):
380
+ band = (
381
+ f'<div style="display:flex;flex-wrap:wrap;border:1px solid '
382
+ f'var(--border-default);background:var(--bg-panel);margin-bottom:8px">'
383
+ f'{"".join(cells)}</div>'
384
+ )
385
+ return band + "".join(notes)
386
+
387
+
388
+ # --------------------------------------------------------------------------
389
+ # Tables -- rendered as markup, not as a Gradio Dataframe
390
+ # --------------------------------------------------------------------------
391
+
392
+
393
+ def table(headers, rows, *, align_right=(), max_height="430px", empty="no rows"):
394
+ """The design's table treatment: pixel-text header, mono body, 1px rules."""
395
+ if not rows:
396
+ return micro(empty)
397
+
398
+ def cell_style(i, header=False):
399
+ a = "right" if i in align_right else "left"
400
+ if header:
401
+ return (f"padding:5px 8px;text-align:{a};"
402
+ f"background:var(--bg-raised);color:var(--text-tertiary);"
403
+ f"border-bottom:1px solid var(--border-default);"
404
+ f"position:sticky;top:0;white-space:nowrap")
405
+ return (f"padding:4px 8px;text-align:{a};color:var(--text-secondary);"
406
+ f"border-bottom:1px solid var(--border-subtle);white-space:nowrap")
407
+
408
+ head = "".join(
409
+ f'<th class="pixel-text" style="{cell_style(i, True)}">{esc(h)}</th>'
410
+ for i, h in enumerate(headers))
411
+ body = "".join(
412
+ "<tr>" + "".join(
413
+ f'<td style="{cell_style(i)}">{c}</td>' for i, c in enumerate(r)
414
+ ) + "</tr>" for r in rows)
415
+
416
+ return (
417
+ f'<div style="overflow:auto;max-height:{max_height};'
418
+ f'border:1px solid var(--border-default);background:var(--bg-panel)">'
419
+ f'<table class="mono-data bit-selectable" style="width:100%;'
420
+ f"border-collapse:collapse;font-size:var(--text-2xs)\">"
421
+ f"<thead><tr>{head}</tr></thead><tbody>{body}</tbody></table></div>"
422
+ )
423
+
424
+
425
+ def frame_to_rows(df, limit=200):
426
+ """DataFrame -> list of escaped string rows, ready for `table`."""
427
+ if df is None or df.empty:
428
+ return [], []
429
+ sub = df.head(limit)
430
+ headers = [str(c) for c in sub.columns]
431
+ rows = [[esc("" if v is None else v) for v in rec]
432
+ for rec in sub.itertuples(index=False, name=None)]
433
+ return headers, rows
434
+
435
+
436
+ # --------------------------------------------------------------------------
437
+ # Right tray -- Run Manager
438
+ # --------------------------------------------------------------------------
439
+
440
+
441
+ def run_card(label, meta, sharpe, ret):
442
+ color = sharpe_tone(sharpe)
443
+ return (
444
+ f'<div style="border:1px solid var(--border-subtle);padding:6px 8px;'
445
+ f'margin-bottom:4px;background:var(--bg-panel)">'
446
+ f'<div style="font-size:var(--text-xs);color:var(--text-primary);'
447
+ f'overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{esc(label)}</div>'
448
+ f'<div class="pixel-text" style="color:var(--text-tertiary)">{esc(meta)}</div>'
449
+ f'<div class="mono-data" style="font-size:var(--text-xs);display:flex;'
450
+ f'gap:6px;color:{color}">SHARPE {num(sharpe)}'
451
+ f'<span style="margin-left:auto;color:var(--text-tertiary)">{pct(ret)}</span>'
452
+ f"</div></div>"
453
+ )
454
+
455
+
456
+ def right_panel(runs, glossary_items, *, signed_in=False):
457
+ """The design's 306px tray."""
458
+ if runs:
459
+ history = "".join(run_card(*r) for r in runs[:14])
460
+ else:
461
+ history = micro("no runs yet in this session")
462
+
463
+ gloss = "".join(
464
+ f'<div style="margin-bottom:6px">'
465
+ f'<div class="pixel-text" style="color:var(--text-tertiary)">{esc(t)}</div>'
466
+ f'<div style="font-size:var(--text-2xs);color:var(--text-secondary);'
467
+ f'line-height:1.6">{esc(d)}</div></div>'
468
+ for t, d in glossary_items)
469
+
470
+ def block(title, body):
471
+ return (
472
+ f'<div style="border-bottom:1px solid var(--border-default)">'
473
+ f'<div style="padding:9px 12px;font-family:var(--font-styrene);'
474
+ f"font-size:var(--text-sm);text-transform:uppercase;"
475
+ f'letter-spacing:var(--tracking-wide)">{esc(title)}</div>'
476
+ f'<div style="padding:0 12px 12px">{body}</div></div>')
477
+
478
+ return (
479
+ f'<aside style="width:{RIGHT_W};flex:0 0 {RIGHT_W};'
480
+ f"border-left:1px solid var(--border-default);background:var(--bg-panel);"
481
+ f'display:flex;flex-direction:column">'
482
+ + block("Run Manager", history)
483
+ + block("Metrics glossary", gloss)
484
+ + "</aside>"
485
+ )
src/ui/theme.py CHANGED
@@ -446,6 +446,51 @@ input:focus, select:focus, textarea:focus{
446
  }
447
  .bit-footer-right{ white-space:nowrap; }
448
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
449
  @media (max-width: 900px){
450
  .bit-statband{ flex-direction:column; }
451
  .bit-stat{ border-right:none; }
 
446
  }
447
  .bit-footer-right{ white-space:nowrap; }
448
 
449
+
450
+ /* Gradio centres the app in a padded `.contain` wrapper, which was costing
451
+ 208px of a 1680px viewport. The design is a full-bleed dashboard, so the
452
+ wrapper is stretched rather than the zones being shrunk to fit it. */
453
+ .gradio-container .contain,
454
+ .gradio-container > .wrap,
455
+ .gradio-container main,
456
+ .gradio-container .main{
457
+ max-width:100% !important; width:100% !important;
458
+ padding:0 !important; margin:0 !important;
459
+ }
460
+
461
+ /* ================= three zones, exact widths =================
462
+ The design uses <aside style="width:286px"> and 306px, not proportions.
463
+ Gradio's Row/Column only offers relative `scale`, so the widths are pinned
464
+ here and the columns are stripped of their own padding and gap. */
465
+ .bit-zones{
466
+ gap:0 !important; flex-wrap:nowrap !important; align-items:stretch !important;
467
+ margin:0 !important;
468
+ }
469
+ .bit-zone-left{
470
+ flex:0 0 286px !important; width:286px !important; min-width:286px !important;
471
+ max-width:286px !important; padding:0 !important; gap:0 !important;
472
+ }
473
+ .bit-zone-right{
474
+ flex:0 0 306px !important; width:306px !important; min-width:306px !important;
475
+ max-width:306px !important; padding:0 !important; gap:0 !important;
476
+ }
477
+ .bit-zone-center{
478
+ flex:1 1 auto !important; min-width:0 !important; padding:0 12px !important;
479
+ }
480
+ .bit-zone-left > *, .bit-zone-right > *{ height:100%; }
481
+ /* The bridge target must stay in the DOM to receive events, but never show. */
482
+ #bit-action{
483
+ position:absolute !important; width:1px !important; height:1px !important;
484
+ overflow:hidden !important; clip:rect(0 0 0 0) !important; opacity:0 !important;
485
+ pointer-events:none !important;
486
+ }
487
+ @media (max-width: 1100px){
488
+ .bit-zones{ flex-wrap:wrap !important; }
489
+ .bit-zone-left, .bit-zone-right{
490
+ flex:1 1 100% !important; width:100% !important; max-width:100% !important;
491
+ }
492
+ }
493
+
494
  @media (max-width: 900px){
495
  .bit-statband{ flex-direction:column; }
496
  .bit-stat{ border-right:none; }
tests/test_ui.py CHANGED
@@ -218,8 +218,14 @@ def test_comparison_renders_three_runs(three_runs):
218
  assert len(corr.data[0].z) == 3
219
 
220
 
221
- def test_comparison_respects_the_six_run_cap():
222
- assert bitapp.MAX_COMPARE == 6
 
 
 
 
 
 
223
 
224
 
225
  def test_precomputed_heatmap_loads_from_the_store():
@@ -444,3 +450,169 @@ def test_app_constructs_without_any_hugging_face_credentials(monkeypatch):
444
 
445
  importlib.reload(fresh)
446
  assert fresh.demo is not None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  assert len(corr.data[0].z) == 3
219
 
220
 
221
+ def test_return_overlay_caps_the_series_it_will_draw():
222
+ """The old six-run session picker was replaced by the catalog view; the
223
+ readability cap now lives in the chart itself."""
224
+ import numpy as np
225
+
226
+ idx = pd.date_range("2024-01-01", periods=30, freq="D", tz="UTC")
227
+ curves = {f"s{i}": pd.Series(np.linspace(0, 1, 30), index=idx) for i in range(50)}
228
+ assert len(charts.multi_return_overlay(curves, max_series=6).data) == 6
229
 
230
 
231
  def test_precomputed_heatmap_loads_from_the_store():
 
450
 
451
  importlib.reload(fresh)
452
  assert fresh.demo is not None
453
+
454
+
455
+ # --------------------------------------------------------------------------
456
+ # Design-markup rendering (shell) and the click bridge
457
+ # --------------------------------------------------------------------------
458
+
459
+
460
+ def test_left_panel_is_design_markup_not_gradio_components():
461
+ """Every control in the left panel must be a real button or input."""
462
+ from src.ui import shell
463
+
464
+ st = bitapp.default_state()
465
+ html = bitapp.render_left(st)
466
+ assert 'width:286px' in html, "zone width is not the design's 286px"
467
+ assert html.count("<button") >= 12, "controls are not real buttons"
468
+ assert "data-bit=" in html, "buttons are not wired to the bridge"
469
+ # Gradio's own control DOM must not appear here.
470
+ assert "<fieldset" not in html
471
+ assert 'type="radio"' not in html
472
+
473
+
474
+ def test_left_panel_uses_the_designs_exact_spacing():
475
+ html = bitapp.render_left(bitapp.default_state())
476
+ for value in ("padding:9px 12px", # panel header
477
+ "padding:0 12px 12px", # section body
478
+ "gap:10px", # section body gap
479
+ "padding:3px 7px", # chips
480
+ "padding:8px 12px"): # accordion header
481
+ assert value in html, f"design spacing {value!r} missing"
482
+
483
+
484
+ def test_top_bar_uses_the_real_mark_and_design_header_treatment():
485
+ from src.ui import shell
486
+
487
+ html = shell.top_bar(context="BTC-USD", status="RUN COMPLETE", tone="ok")
488
+ assert "M50 10H90V90H50Z" in html, "not the design's mark"
489
+ assert "padding:8px 12px" in html
490
+ assert "margin-top:3px" in html, "optical alignment on the title is missing"
491
+ assert "mono-data" in html, "design-system utility class not used"
492
+
493
+
494
+ def test_selected_chip_is_the_only_active_one():
495
+ st = bitapp.default_state()
496
+ st["timeframe"] = "1h"
497
+ html = bitapp.render_left(st)
498
+ actives = re.findall(
499
+ r'<button data-bit="(tf:[^"]+)"[^>]*background:var\(--accent-amber\)', html)
500
+ assert actives == ["tf:1h"]
501
+
502
+
503
+ def test_html_entities_are_escaped_exactly_once():
504
+ html = bitapp.render_left(bitapp.default_state())
505
+ assert "&amp;amp;" not in html, "double-escaped entity"
506
+ assert "Universe &amp; Data" in html
507
+
508
+
509
+ @pytest.mark.parametrize("action,key,expected", [
510
+ ("tf:15m", "timeframe", "15m"),
511
+ ("asset:ETH-USD", "asset", "ETH-USD"),
512
+ ("range:1Y", "range", "1Y"),
513
+ ("validation:holdout", "validation_mode", "holdout"),
514
+ ("sizing:vol_target", "sizing_mode", "vol_target"),
515
+ ("slippage:volume_scaled", "slippage_model", "volume_scaled"),
516
+ ])
517
+ def test_actions_fold_into_state(action, key, expected):
518
+ st, ran = bitapp.apply_action(bitapp.default_state(), f"{action}|nonce")
519
+ assert st[key] == expected
520
+ assert ran is False
521
+
522
+
523
+ def test_run_and_example_actions_request_a_run():
524
+ _, ran = bitapp.apply_action(bitapp.default_state(), "run:|n")
525
+ assert ran is True
526
+ st, ran = bitapp.apply_action(bitapp.default_state(), "example:|n")
527
+ assert ran is True and st["strategy"] == "Chronos Forecast Follower"
528
+
529
+
530
+ def test_accordion_action_toggles():
531
+ st = bitapp.default_state()
532
+ before = st["acc"]["costs"]
533
+ st, _ = bitapp.apply_action(st, "acc:costs|n")
534
+ assert st["acc"]["costs"] is not before
535
+
536
+
537
+ def test_numeric_param_commits_through_the_bridge():
538
+ st, _ = bitapp.apply_action(bitapp.default_state(), "param:commission_bps=25|n")
539
+ assert st["commission_bps"] == 25.0
540
+
541
+
542
+ def test_strategy_param_commits_and_survives_preset_defaults():
543
+ st = bitapp.default_state()
544
+ st, _ = bitapp.apply_action(st, "param:fast_ma=33|n")
545
+ assert st["params"]["fast_ma"] == 33.0
546
+
547
+
548
+ @pytest.mark.parametrize("bad", [
549
+ "param:commission_bps=nonsense", "param:commission_bps=", "topn:abc",
550
+ "tf:99y", "asset:../etc/passwd", "strategy:__import__",
551
+ ])
552
+ def test_malformed_actions_leave_state_untouched(bad):
553
+ before = bitapp.default_state()
554
+ after, ran = bitapp.apply_action(bitapp.default_state(), f"{bad}|n")
555
+ assert ran is False
556
+ assert after["timeframe"] == before["timeframe"]
557
+ assert after["asset"] == before["asset"]
558
+ assert after["strategy"] == before["strategy"]
559
+ assert after["commission_bps"] == before["commission_bps"]
560
+
561
+
562
+ def test_unknown_action_keys_are_dropped():
563
+ from src.ui.bridge import parse_action
564
+
565
+ assert parse_action("evil:rm -rf|n") is None
566
+ assert parse_action("os.system:x|n") is None
567
+
568
+
569
+ def test_bridge_survives_values_containing_spaces():
570
+ """Preset names contain spaces; the nonce separator must not collide."""
571
+ from src.ui.bridge import emit, parse_action
572
+
573
+ raw = emit("strategy", "Buy & Hold (benchmark)")
574
+ assert parse_action(raw + "|nonce").value == "Buy & Hold (benchmark)"
575
+
576
+
577
+ def test_tables_render_as_markup_not_dataframes():
578
+ df = pd.DataFrame({"A": [1, 2], "B": ["x", "y"]})
579
+ html = bitapp.render_table(df)
580
+ assert "<table" in html and "pixel-text" in html
581
+ assert "bit-selectable" in html, "table text must stay selectable"
582
+
583
+
584
+ def test_empty_table_says_so():
585
+ assert "no rows" in bitapp.render_table(pd.DataFrame(), empty="no rows")
586
+
587
+
588
+ def test_zone_widths_are_pinned_in_css():
589
+ from src.ui import theme
590
+
591
+ css = theme.full_css()
592
+ assert "flex:0 0 286px" in css
593
+ assert "flex:0 0 306px" in css
594
+
595
+
596
+ def test_bridge_target_is_hidden_but_present():
597
+ from src.ui import theme
598
+
599
+ css = theme.full_css()
600
+ assert "#bit-action" in css
601
+ assert "opacity:0" in css.split("#bit-action")[1][:220]
602
+
603
+
604
+ def test_gradio_content_wrapper_is_stretched_full_bleed():
605
+ """Gradio centres the app in a padded `.contain`, which cost 208px of a
606
+ 1680px viewport. The design is a full-bleed dashboard."""
607
+ from src.ui import theme
608
+
609
+ import re
610
+
611
+ css = theme.full_css()
612
+ # Match the rule whose selector list starts with `.contain`, not the
613
+ # `.container` rule -- one is a prefix of the other.
614
+ m = re.search(r"\.gradio-container \.contain,(.*?)\{(.*?)\}", css, re.S)
615
+ assert m, "no full-bleed rule for Gradio's .contain wrapper"
616
+ body = m.group(2)
617
+ assert "max-width:100% !important" in body
618
+ assert "padding:0 !important" in body