Bit-Trading-Company commited on
Commit
f0ac64a
·
verified ·
1 Parent(s): 9d6050e

CI deploy cb6851b9

Browse files
Files changed (5) hide show
  1. app.py +195 -134
  2. src/ui/bridge.py +21 -3
  3. src/ui/shell.py +71 -1
  4. src/ui/theme.py +26 -2
  5. tests/test_ui.py +112 -0
app.py CHANGED
@@ -29,7 +29,7 @@ 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, BRIDGE_LOAD_JS,
32
- parse_action, parse_pair)
33
  from src.ui.format import EM, count, esc, money, num, pct, tone
34
 
35
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
@@ -89,6 +89,11 @@ def default_state() -> dict:
89
  "log_scale": False, "cvd": False,
90
  "metric": "OOS Sharpe", "topn": 15,
91
  "sig_asset": "BTC-USD", "sig_tf": "1d",
 
 
 
 
 
92
  }
93
 
94
 
@@ -129,9 +134,18 @@ def apply_action(st: dict, raw: str) -> tuple[dict, bool]:
129
  if action is None or action.is_noop:
130
  return st, False
131
 
 
 
 
 
 
 
 
132
  k, v = action.key, action.value
133
 
134
- if k == "acc":
 
 
135
  st["acc"][v] = not st["acc"].get(v, False)
136
  elif k == "strategy" and v in strategies.PRESETS:
137
  st["strategy"] = v
@@ -225,19 +239,32 @@ def render_left(st: dict) -> str:
225
  )
226
 
227
 
 
 
 
228
  def render_top(st: dict, rec: RunRecord | None) -> str:
229
- if rec is None:
230
- return shell.top_bar(status="NO RUN LOADED", tone="idle")
 
 
 
 
 
 
 
 
 
 
231
  mode = {"walk_forward": "WALK-FORWARD", "holdout": "HOLDOUT",
232
  "split": "SPLIT", "none": "NO SPLIT"}.get(st["validation_mode"], "")
233
  try:
234
- s, e = runtime.window_for(st["asset"], st["timeframe"], st["range"])
235
- span = f"{s.date()} to {e.date()}"
236
  except Exception:
237
  span = st["range"]
238
  ctx = f'{st["asset"]} · {st["timeframe"].upper()} · {span} · {mode}'
239
  return shell.top_bar(context=ctx, status=f"RUN {rec.run_id} COMPLETE",
240
- tone="ok", elapsed=f"{rec.elapsed_s:.1f}s")
241
 
242
 
243
  def render_stat_band(rec: RunRecord | None) -> str:
@@ -289,12 +316,6 @@ def render_stat_band(rec: RunRecord | None) -> str:
289
  return shell.stat_band(cells, notes)
290
 
291
 
292
- def render_right(hist) -> str:
293
- runs = [(r.label, r.meta, r.sharpe, r.result.metrics_all.total_return)
294
- for r in (hist or [])]
295
- return shell.right_panel(runs, GLOSSARY)
296
-
297
-
298
  def render_table(df, *, align_right=(), empty="no rows", max_height="430px") -> str:
299
  headers, rows = shell.frame_to_rows(df)
300
  return shell.table(headers, rows, align_right=set(align_right),
@@ -387,122 +408,129 @@ def build_overview(rec: RunRecord, *, log_scale: bool, cvd: bool):
387
 
388
 
389
  def build_app() -> gr.Blocks:
390
- store = runtime.get_store()
391
- all_assets = runtime.available_assets()
392
  all_tfs = list(config.TIMEFRAMES)
 
393
 
394
  with gr.Blocks(theme=theme.bit_theme(), css=theme.full_css(),
395
  head=BRIDGE_JS, title="Bit · Backtest Lab",
396
  analytics_enabled=False, fill_height=True) as demo:
397
 
398
- state = gr.State(default_state())
399
  history = gr.State([])
400
  current = gr.State(None)
401
 
402
- # The bridge target. Hidden, but must stay in the DOM for the
403
- # delegated listener to find and write into.
404
- action_box = gr.Textbox(elem_id=ACTION_ELEMENT_ID, visible=False,
405
- label="", show_label=False)
 
 
 
 
 
 
 
406
 
407
- top_html = gr.HTML(shell.top_bar())
408
 
409
  with gr.Row(elem_classes="bit-zones", equal_height=False):
410
- with gr.Column(elem_classes="bit-zone-left", min_width=0):
411
- left_html = gr.HTML(render_left(default_state()))
 
 
412
 
413
  with gr.Column(elem_classes="bit-zone-center", min_width=0):
414
- stat_html = gr.HTML("")
415
 
416
- with gr.Tabs():
417
- with gr.Tab("Compare"):
418
- catalog_meta = gr.HTML("")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
  with gr.Tabs():
420
- with gr.Tab("Leaderboard"):
421
- podium = gr.HTML("")
422
- lb_meta = gr.HTML("")
423
- lb_table = gr.HTML("")
424
- gr.HTML(shell.micro(
425
- "returns over time · top ranked · "
426
- "cumulative, costs included"))
427
- lb_overlay = gr.Plot()
428
- gr.HTML(shell.micro(
429
- "risk vs return · marker area = trade count"))
430
- lb_scatter = gr.Plot()
431
-
432
- with gr.Tab("Models"):
433
- models_note = gr.HTML("")
434
  with gr.Row():
435
- acc_plot = gr.Plot()
436
- cal_plot = gr.Plot()
437
- model_bars = gr.Plot()
438
- score_table = gr.HTML("")
439
-
440
- with gr.Tab("Signals"):
441
- sig_panel = gr.HTML("")
442
-
443
- with gr.Tab("Run history"):
444
- runs_table = gr.HTML("")
445
-
446
- with gr.Tab("Overview"):
447
- empty_state = gr.HTML(C.empty_state())
448
- equity_plot = gr.Plot()
449
- regime_plot = gr.Plot()
450
- with gr.Row():
451
- underwater_plot = gr.Plot()
452
- rolling_plot = gr.Plot()
453
- price_plot = gr.Plot()
454
- with gr.Row():
455
- pnl_plot = gr.Plot()
456
- hold_plot = gr.Plot()
457
- mae_plot = gr.Plot()
458
- costs_note = gr.HTML("")
459
-
460
- with gr.Tab("Trades"):
461
- trades_head = gr.HTML("")
462
- trades_html = gr.HTML("")
463
-
464
- with gr.Tab("Robustness"):
465
- verdict_html = gr.HTML("")
466
- with gr.Row():
467
- wf_plot = gr.Plot()
468
- mc_plot = gr.Plot()
469
-
470
- with gr.Tab("Report"):
471
- report_md = gr.Markdown("_Run a backtest to generate "
472
- "the report._")
473
- report_equity = gr.Plot()
474
-
475
- with gr.Tab("Coverage"):
476
- coverage_kpis = gr.HTML("")
477
- coverage_html = gr.HTML("")
478
- extend_panel = gr.HTML("")
479
- with gr.Row():
480
- ext_model = gr.Dropdown(list(config.SEED_MODELS),
481
- label="Model", scale=2)
482
- ext_asset = gr.Dropdown(list(config.ASSETS),
483
- label="Asset", scale=2)
484
- ext_tf = gr.Dropdown(all_tfs, value="1d",
485
- label="Timeframe", scale=1)
486
- with gr.Row():
487
- ext_start = gr.Textbox(label="Start (YYYY-MM-DD)", scale=2)
488
- ext_end = gr.Textbox(label="End (YYYY-MM-DD)", scale=2)
489
- with gr.Row():
490
- estimate_btn = gr.Button("Estimate", size="sm",
491
- elem_classes="bit-ghost-btn")
492
- extend_btn = gr.Button("Extend coverage", size="sm",
493
- elem_classes="bit-run-btn")
494
- extend_out = gr.HTML("")
495
- with gr.Row():
496
- add_family = gr.Dropdown(
497
- list(config.ALLOWED_ADAPTER_FAMILIES),
498
- value="chronos", label="Adapter family", scale=1)
499
- add_model_id = gr.Textbox(label="HF model id", scale=2)
500
- add_btn = gr.Button("Smoke test & add", size="sm",
501
- elem_classes="bit-ghost-btn", scale=1)
502
- add_out = gr.HTML("")
503
-
504
- with gr.Column(elem_classes="bit-zone-right", min_width=0):
505
- right_html = gr.HTML(render_right([]))
506
 
507
  gr.HTML(shell.footer())
508
 
@@ -513,9 +541,21 @@ def build_app() -> gr.Blocks:
513
  overview_out = [equity_plot, regime_plot, underwater_plot, rolling_plot,
514
  price_plot, pnl_plot, hold_plot, mae_plot, costs_note]
515
  compare_out = [podium, lb_table, lb_overlay, lb_scatter, lb_meta]
 
 
 
 
 
 
 
 
 
 
 
 
 
516
 
517
  def compare_views(st):
518
- """Leaderboard view, with the HTML table rendered by the shell."""
519
  pod, table_df, overlay, scatter, meta = CT.build_leaderboard_view(
520
  runtime.get_store(), assets=None, timeframes=None,
521
  strategies_=None, models=None, metric_label=st["metric"],
@@ -530,25 +570,26 @@ def build_app() -> gr.Blocks:
530
  st, should_run = apply_action(st, raw)
531
 
532
  if not should_run:
533
- # A pure UI change: re-render the panel and the compare views
534
- # that depend on state, and leave the run outputs alone.
535
- return (st, hist, rec, render_left(st), gr.update(),
536
  gr.update(), *(gr.update(),) * 9, gr.update(),
537
  gr.update(), gr.update(), gr.update(),
538
- *compare_views(st), render_right(hist))
539
 
 
 
540
  progress(0.2, desc="Reading cached slices")
541
  try:
542
  progress(0.5, desc="Simulating trades")
543
  rec = runtime.execute(to_request(st))
544
  except (RunError, ValueError) as exc:
545
  return (st, hist, None, render_left(st),
546
- shell.top_bar(status="RUN FAILED", tone="warn"),
 
 
547
  shell.note(esc(str(exc)), danger=True),
548
- *(gr.update(),) * 9,
549
- shell.micro("no trades"), "",
550
- "_Run failed._", gr.update(),
551
- *compare_views(st), render_right(hist))
552
 
553
  progress(0.85, desc="Building charts")
554
  hist = ([rec] + list(hist))[:40]
@@ -563,13 +604,19 @@ def build_app() -> gr.Blocks:
563
  report_markdown(rec),
564
  charts.equity_curve(rec.result.equity, rec.result.benchmark_equity,
565
  plan=rec.result.plan),
566
- *compare_views(st), render_right(hist),
567
  )
568
 
569
  action_out = [state, history, current, left_html, top_html, stat_html,
570
  *overview_out, trades_head, trades_html, report_md,
571
- report_equity, *compare_out, right_html]
572
-
 
 
 
 
 
 
573
  action_box.change(on_action, [action_box, state, history, current],
574
  action_out, show_progress="minimal")
575
 
@@ -601,9 +648,9 @@ def build_app() -> gr.Blocks:
601
  [ext_model, ext_asset, ext_tf, ext_start, ext_end],
602
  [extend_out])
603
  extend_btn.click(
604
- lambda m, a, t, s, e: (
605
  (lambda html, _df: (html, render_table(runtime.coverage_frame())))(
606
- *extension.extend_ui(m, a, t, s, e))),
607
  [ext_model, ext_asset, ext_tf, ext_start, ext_end],
608
  [extend_out, coverage_html])
609
  add_btn.click(
@@ -626,7 +673,8 @@ def build_app() -> gr.Blocks:
626
  sig = CT.build_signals_view(store, st["sig_asset"], st["sig_tf"])
627
  saved = catalog.load_saved_runs(store)
628
 
629
- return (st, render_left(st), meta_html, *compare_views(st),
 
630
  note, acc, cal, bars,
631
  render_table(score_df, align_right=range(3, 10),
632
  empty="no scorecard rows"),
@@ -636,15 +684,28 @@ def build_app() -> gr.Blocks:
636
  render_table(runtime.coverage_frame(), empty="no coverage"),
637
  extension.status_html())
638
 
639
- # `js=` is what actually installs the click bridge on Spaces: the
640
- # `head=` injection above works locally but HF serves the page from its
641
- # own template and drops it. The installer is idempotent.
642
  demo.load(on_load, [state],
643
- [state, left_html, catalog_meta, *compare_out,
644
  models_note, acc_plot, cal_plot, model_bars, score_table,
645
  sig_panel, runs_table, coverage_kpis, coverage_html,
646
- extend_panel],
647
- js=BRIDGE_LOAD_JS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
648
 
649
  return demo
650
 
 
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, BRIDGE_LOAD_JS,
32
+ TRIGGER_ELEMENT_ID, parse_action, parse_pair)
33
  from src.ui.format import EM, count, esc, money, num, pct, tone
34
 
35
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
 
89
  "log_scale": False, "cvd": False,
90
  "metric": "OOS Sharpe", "topn": 15,
91
  "sig_asset": "BTC-USD", "sig_tf": "1d",
92
+ "tab": "compare", # "compare" | "backtest"
93
+ "user": None,
94
+ # The bridge fires through two event paths for robustness; the nonce
95
+ # is what stops the same click being executed twice.
96
+ "last_nonce": "",
97
  }
98
 
99
 
 
134
  if action is None or action.is_noop:
135
  return st, False
136
 
137
+ # Same click delivered twice (textbox change *and* trigger click) must not
138
+ # run the backtest twice.
139
+ nonce = raw.split("|", 1)[1] if "|" in raw else ""
140
+ if nonce and nonce == st.get("last_nonce"):
141
+ return st, False
142
+ st["last_nonce"] = nonce
143
+
144
  k, v = action.key, action.value
145
 
146
+ if k == "tab" and v in ("compare", "backtest"):
147
+ st["tab"] = v
148
+ elif k == "acc":
149
  st["acc"][v] = not st["acc"].get(v, False)
150
  elif k == "strategy" and v in strategies.PRESETS:
151
  st["strategy"] = v
 
239
  )
240
 
241
 
242
+ ON_SPACE = bool(os.environ.get("SPACE_ID"))
243
+
244
+
245
  def render_top(st: dict, rec: RunRecord | None) -> str:
246
+ """The header: nav, run status, glossary, sign-in.
247
+
248
+ The context chip only appears on the Backtest tab -- on Compare it would be
249
+ describing a run the user is not looking at.
250
+ """
251
+ common = dict(tab=st.get("tab", "compare"), glossary=GLOSSARY,
252
+ user=st.get("user"), on_space=ON_SPACE)
253
+ if rec is None or st.get("tab") != "backtest":
254
+ status = (f"RUN {rec.run_id} COMPLETE" if rec else "NO RUN LOADED")
255
+ tone = "ok" if rec else "idle"
256
+ return shell.top_bar(status=status, tone=tone, **common)
257
+
258
  mode = {"walk_forward": "WALK-FORWARD", "holdout": "HOLDOUT",
259
  "split": "SPLIT", "none": "NO SPLIT"}.get(st["validation_mode"], "")
260
  try:
261
+ a, b = runtime.window_for(st["asset"], st["timeframe"], st["range"])
262
+ span = f"{a.date()} to {b.date()}"
263
  except Exception:
264
  span = st["range"]
265
  ctx = f'{st["asset"]} · {st["timeframe"].upper()} · {span} · {mode}'
266
  return shell.top_bar(context=ctx, status=f"RUN {rec.run_id} COMPLETE",
267
+ tone="ok", elapsed=f"{rec.elapsed_s:.1f}s", **common)
268
 
269
 
270
  def render_stat_band(rec: RunRecord | None) -> str:
 
316
  return shell.stat_band(cells, notes)
317
 
318
 
 
 
 
 
 
 
319
  def render_table(df, *, align_right=(), empty="no rows", max_height="430px") -> str:
320
  headers, rows = shell.frame_to_rows(df)
321
  return shell.table(headers, rows, align_right=set(align_right),
 
408
 
409
 
410
  def build_app() -> gr.Blocks:
 
 
411
  all_tfs = list(config.TIMEFRAMES)
412
+ boot = default_state()
413
 
414
  with gr.Blocks(theme=theme.bit_theme(), css=theme.full_css(),
415
  head=BRIDGE_JS, title="Bit · Backtest Lab",
416
  analytics_enabled=False, fill_height=True) as demo:
417
 
418
+ state = gr.State(boot)
419
  history = gr.State([])
420
  current = gr.State(None)
421
 
422
+ # Bridge target. It must be RENDERED -- `visible=False` removes the
423
+ # element from the DOM entirely, leaving the click listener with
424
+ # nothing to write into -- so it is rendered and hidden in CSS
425
+ # (`#bit-action` is clipped to a 1px box, see theme.py).
426
+ action_box = gr.Textbox(elem_id=ACTION_ELEMENT_ID, visible=True,
427
+ label="", show_label=False,
428
+ container=False, interactive=True)
429
+ # Clicked by the bridge after it writes the payload. A real Button
430
+ # click is the event path Gradio always honours.
431
+ action_trigger = gr.Button("", elem_id=TRIGGER_ELEMENT_ID,
432
+ visible=True, variant="secondary")
433
 
434
+ top_html = gr.HTML(render_top(boot, None))
435
 
436
  with gr.Row(elem_classes="bit-zones", equal_height=False):
437
+ # Strategy Builder belongs to the Backtest tab only.
438
+ with gr.Column(elem_classes="bit-zone-left", min_width=0,
439
+ visible=False) as left_col:
440
+ left_html = gr.HTML(render_left(boot))
441
 
442
  with gr.Column(elem_classes="bit-zone-center", min_width=0):
 
443
 
444
+ # ---------------- Compare ----------------
445
+ with gr.Column(visible=True) as compare_view:
446
+ catalog_meta = gr.HTML("")
447
+ with gr.Tabs():
448
+ with gr.Tab("Leaderboard"):
449
+ podium = gr.HTML("")
450
+ lb_meta = gr.HTML("")
451
+ lb_table = gr.HTML("")
452
+ gr.HTML(shell.micro(
453
+ "returns over time · top ranked · cumulative, "
454
+ "costs included"))
455
+ lb_overlay = gr.Plot()
456
+ gr.HTML(shell.micro(
457
+ "risk vs return · marker area = trade count"))
458
+ lb_scatter = gr.Plot()
459
+ with gr.Tab("Models"):
460
+ models_note = gr.HTML("")
461
+ with gr.Row():
462
+ acc_plot = gr.Plot()
463
+ cal_plot = gr.Plot()
464
+ model_bars = gr.Plot()
465
+ score_table = gr.HTML("")
466
+ with gr.Tab("Signals"):
467
+ sig_panel = gr.HTML("")
468
+ with gr.Tab("Run history"):
469
+ runs_table = gr.HTML("")
470
+ with gr.Tab("Coverage"):
471
+ coverage_kpis = gr.HTML("")
472
+ coverage_html = gr.HTML("")
473
+ extend_panel = gr.HTML("")
474
+ with gr.Row():
475
+ ext_model = gr.Dropdown(list(config.SEED_MODELS),
476
+ label="Model", scale=2)
477
+ ext_asset = gr.Dropdown(list(config.ASSETS),
478
+ label="Asset", scale=2)
479
+ ext_tf = gr.Dropdown(all_tfs, value="1d",
480
+ label="Timeframe", scale=1)
481
+ with gr.Row():
482
+ ext_start = gr.Textbox(label="Start (YYYY-MM-DD)",
483
+ scale=2)
484
+ ext_end = gr.Textbox(label="End (YYYY-MM-DD)",
485
+ scale=2)
486
+ with gr.Row():
487
+ estimate_btn = gr.Button("Estimate", size="sm",
488
+ elem_classes="bit-ghost-btn")
489
+ extend_btn = gr.Button("Extend coverage", size="sm",
490
+ elem_classes="bit-run-btn")
491
+ extend_out = gr.HTML("")
492
+ with gr.Row():
493
+ add_family = gr.Dropdown(
494
+ list(config.ALLOWED_ADAPTER_FAMILIES),
495
+ value="chronos", label="Adapter family", scale=1)
496
+ add_model_id = gr.Textbox(label="HF model id", scale=2)
497
+ add_btn = gr.Button("Smoke test & add", size="sm",
498
+ elem_classes="bit-ghost-btn",
499
+ scale=1)
500
+ add_out = gr.HTML("")
501
+
502
+ # ---------------- Backtest ----------------
503
+ with gr.Column(visible=False) as backtest_view:
504
+ # Before a run there is nothing honest to show, so the tabs
505
+ # stay hidden rather than rendering a grid of empty axes.
506
+ empty_html = gr.HTML(C.empty_state(), visible=True)
507
+
508
+ with gr.Column(visible=False) as results_view:
509
+ stat_html = gr.HTML("")
510
  with gr.Tabs():
511
+ with gr.Tab("Overview"):
512
+ equity_plot = gr.Plot()
513
+ regime_plot = gr.Plot()
514
+ with gr.Row():
515
+ underwater_plot = gr.Plot()
516
+ rolling_plot = gr.Plot()
517
+ price_plot = gr.Plot()
 
 
 
 
 
 
 
518
  with gr.Row():
519
+ pnl_plot = gr.Plot()
520
+ hold_plot = gr.Plot()
521
+ mae_plot = gr.Plot()
522
+ costs_note = gr.HTML("")
523
+ with gr.Tab("Trades"):
524
+ trades_head = gr.HTML("")
525
+ trades_html = gr.HTML("")
526
+ with gr.Tab("Robustness"):
527
+ verdict_html = gr.HTML("")
528
+ with gr.Row():
529
+ wf_plot = gr.Plot()
530
+ mc_plot = gr.Plot()
531
+ with gr.Tab("Report"):
532
+ report_md = gr.Markdown("")
533
+ report_equity = gr.Plot()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
534
 
535
  gr.HTML(shell.footer())
536
 
 
541
  overview_out = [equity_plot, regime_plot, underwater_plot, rolling_plot,
542
  price_plot, pnl_plot, hold_plot, mae_plot, costs_note]
543
  compare_out = [podium, lb_table, lb_overlay, lb_scatter, lb_meta]
544
+ view_out = [left_col, compare_view, backtest_view, empty_html, results_view]
545
+
546
+ def views(st, rec):
547
+ """Which zones are visible, given the tab and whether a run exists."""
548
+ backtest = st.get("tab") == "backtest"
549
+ has_run = rec is not None
550
+ return (
551
+ gr.update(visible=backtest), # left strategy builder
552
+ gr.update(visible=not backtest), # compare
553
+ gr.update(visible=backtest), # backtest
554
+ gr.update(visible=backtest and not has_run), # empty state
555
+ gr.update(visible=backtest and has_run), # results
556
+ )
557
 
558
  def compare_views(st):
 
559
  pod, table_df, overlay, scatter, meta = CT.build_leaderboard_view(
560
  runtime.get_store(), assets=None, timeframes=None,
561
  strategies_=None, models=None, metric_label=st["metric"],
 
570
  st, should_run = apply_action(st, raw)
571
 
572
  if not should_run:
573
+ return (st, hist, rec, render_left(st), render_top(st, rec),
 
 
574
  gr.update(), *(gr.update(),) * 9, gr.update(),
575
  gr.update(), gr.update(), gr.update(),
576
+ *compare_views(st), *views(st, rec))
577
 
578
+ # Running always means the user is looking at the Backtest tab.
579
+ st["tab"] = "backtest"
580
  progress(0.2, desc="Reading cached slices")
581
  try:
582
  progress(0.5, desc="Simulating trades")
583
  rec = runtime.execute(to_request(st))
584
  except (RunError, ValueError) as exc:
585
  return (st, hist, None, render_left(st),
586
+ shell.top_bar(status="RUN FAILED", tone="warn",
587
+ tab="backtest", glossary=GLOSSARY,
588
+ user=st.get("user"), on_space=ON_SPACE),
589
  shell.note(esc(str(exc)), danger=True),
590
+ *(gr.update(),) * 9, gr.update(), gr.update(),
591
+ gr.update(), gr.update(),
592
+ *compare_views(st), *views(st, None))
 
593
 
594
  progress(0.85, desc="Building charts")
595
  hist = ([rec] + list(hist))[:40]
 
604
  report_markdown(rec),
605
  charts.equity_curve(rec.result.equity, rec.result.benchmark_equity,
606
  plan=rec.result.plan),
607
+ *compare_views(st), *views(st, rec),
608
  )
609
 
610
  action_out = [state, history, current, left_html, top_html, stat_html,
611
  *overview_out, trades_head, trades_html, report_md,
612
+ report_equity, *compare_out, *view_out]
613
+
614
+ # Two bindings on purpose. Setting a textbox value from JS does not
615
+ # always wake Svelte's binding, and a hidden Button click does not
616
+ # always survive either; whichever lands first wins, and the nonce
617
+ # check in apply_action makes the loser a no-op.
618
+ action_trigger.click(on_action, [action_box, state, history, current],
619
+ action_out, show_progress="minimal")
620
  action_box.change(on_action, [action_box, state, history, current],
621
  action_out, show_progress="minimal")
622
 
 
648
  [ext_model, ext_asset, ext_tf, ext_start, ext_end],
649
  [extend_out])
650
  extend_btn.click(
651
+ lambda m, a, t, s_, e: (
652
  (lambda html, _df: (html, render_table(runtime.coverage_frame())))(
653
+ *extension.extend_ui(m, a, t, s_, e))),
654
  [ext_model, ext_asset, ext_tf, ext_start, ext_end],
655
  [extend_out, coverage_html])
656
  add_btn.click(
 
673
  sig = CT.build_signals_view(store, st["sig_asset"], st["sig_tf"])
674
  saved = catalog.load_saved_runs(store)
675
 
676
+ return (st, render_top(st, None), render_left(st), meta_html,
677
+ *compare_views(st),
678
  note, acc, cal, bars,
679
  render_table(score_df, align_right=range(3, 10),
680
  empty="no scorecard rows"),
 
684
  render_table(runtime.coverage_frame(), empty="no coverage"),
685
  extension.status_html())
686
 
 
 
 
687
  demo.load(on_load, [state],
688
+ [state, top_html, left_html, catalog_meta, *compare_out,
689
  models_note, acc_plot, cal_plot, model_bars, score_table,
690
  sig_panel, runs_table, coverage_kpis, coverage_html,
691
+ extend_panel])
692
+
693
+ # Installing the bridge gets its OWN load with no fn and no outputs.
694
+ # Gradio treats a `js=` return value as the output values, so attaching
695
+ # it to a load that also has outputs wipes them in the browser -- which
696
+ # is exactly what broke the UI while leaving the API working.
697
+ demo.load(fn=None, inputs=None, outputs=None, js=BRIDGE_LOAD_JS)
698
+
699
+ # Sign-in state is only meaningful on a Space. Off-Space, Gradio mocks
700
+ # OAuth by calling whoami and raises without a token, so this handler
701
+ # is only registered where OAuth actually exists.
702
+ if ON_SPACE:
703
+ def whoami(st, profile: gr.OAuthProfile | None = None):
704
+ st = copy.deepcopy(st)
705
+ st["user"] = getattr(profile, "username", None) if profile else None
706
+ return st, render_top(st, None)
707
+
708
+ demo.load(whoami, [state], [state, top_html])
709
 
710
  return demo
711
 
src/ui/bridge.py CHANGED
@@ -60,6 +60,7 @@ ALLOWED_KEYS = frozenset({
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
@@ -116,7 +117,8 @@ BRIDGE_JS = """
116
  }, true);
117
  })();
118
  </script>
119
- """.replace("__ELEM_ID__", ACTION_ELEMENT_ID)
 
120
 
121
 
122
  @dataclass(frozen=True)
@@ -179,8 +181,23 @@ BRIDGE_LOAD_JS = """
179
  var box = holder();
180
  if (!box) return;
181
  var nonce = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
182
- box.value = payload + '|' + nonce;
 
 
 
 
 
 
183
  box.dispatchEvent(new Event('input', { bubbles: true }));
 
 
 
 
 
 
 
 
 
184
  }
185
 
186
  document.addEventListener('click', function (e) {
@@ -202,4 +219,5 @@ BRIDGE_LOAD_JS = """
202
  if (e.key === 'Enter') { commit(e.target); }
203
  }, true);
204
  }
205
- """.replace("__ELEM_ID__", ACTION_ELEMENT_ID)
 
 
60
  })
61
 
62
  ACTION_ELEMENT_ID = "bit-action"
63
+ TRIGGER_ELEMENT_ID = "bit-trigger"
64
 
65
  # Separates the action from its nonce. A pipe is used rather than a space
66
  # because action values are human-readable names -- "SMA Crossover", "Buy & Hold
 
117
  }, true);
118
  })();
119
  </script>
120
+ """.replace("__ELEM_ID__", ACTION_ELEMENT_ID).replace(
121
+ "__TRIGGER_ID__", TRIGGER_ELEMENT_ID)
122
 
123
 
124
  @dataclass(frozen=True)
 
181
  var box = holder();
182
  if (!box) return;
183
  var nonce = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
184
+
185
+ // Setting .value directly does not reliably wake Svelte's binding, so the
186
+ // native setter is used and both events are dispatched...
187
+ var proto = box.tagName === 'TEXTAREA'
188
+ ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
189
+ var setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
190
+ setter.call(box, payload + '|' + nonce);
191
  box.dispatchEvent(new Event('input', { bubbles: true }));
192
+ box.dispatchEvent(new Event('change', { bubbles: true }));
193
+
194
+ // ...and a hidden Gradio Button is then clicked. A real click on a real
195
+ // Button is the one event path Gradio always honours, so the handler fires
196
+ // even if the textbox binding did not register.
197
+ var trigger = document.getElementById('__TRIGGER_ID__');
198
+ var btn = trigger ? (trigger.tagName === 'BUTTON'
199
+ ? trigger : trigger.querySelector('button')) : null;
200
+ if (btn) { setTimeout(function () { btn.click(); }, 0); }
201
  }
202
 
203
  document.addEventListener('click', function (e) {
 
219
  if (e.key === 'Enter') { commit(e.target); }
220
  }, true);
221
  }
222
+ """.replace("__ELEM_ID__", ACTION_ELEMENT_ID).replace(
223
+ "__TRIGGER_ID__", TRIGGER_ELEMENT_ID)
src/ui/shell.py CHANGED
@@ -143,7 +143,72 @@ def section(key, number, title, open_, body):
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)"),
@@ -174,6 +239,9 @@ def top_bar(context="", status="NO RUN LOADED", tone="idle", elapsed=""):
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">'
@@ -184,6 +252,8 @@ def top_bar(context="", status="NO RUN LOADED", tone="idle", elapsed=""):
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);'
 
143
  # --------------------------------------------------------------------------
144
 
145
 
146
+ def nav_tab(label, key, active):
147
+ """A header navigation tab."""
148
+ color = "var(--text-primary)" if active else "var(--text-tertiary)"
149
+ border = "var(--accent-amber)" if active else "transparent"
150
+ return (
151
+ f'<button data-bit="{esc(emit("tab", key))}" style="padding:6px 10px;'
152
+ f"background:transparent;border:0;border-bottom:2px solid {border};"
153
+ f"color:{color};cursor:pointer;font-family:var(--font-styrene);"
154
+ f"text-transform:uppercase;letter-spacing:var(--tracking-wide);"
155
+ f'font-size:var(--text-xs)">{esc(label)}</button>'
156
+ )
157
+
158
+
159
+ def glossary_tooltip(items):
160
+ """A `?` in the header that reveals the metrics glossary on hover.
161
+
162
+ Pure CSS -- no JS, and no state to keep in sync.
163
+ """
164
+ rows = "".join(
165
+ f'<div style="margin-bottom:6px">'
166
+ f'<div class="pixel-text" style="color:var(--accent-amber-strong)">{esc(t)}</div>'
167
+ f'<div style="font-size:var(--text-2xs);color:var(--text-secondary);'
168
+ f'line-height:1.6">{esc(d)}</div></div>'
169
+ for t, d in items)
170
+ return (
171
+ f'<span class="bit-help">'
172
+ f'<span class="bit-help-dot" role="button" tabindex="0" '
173
+ f'aria-label="Metrics glossary">?</span>'
174
+ f'<span class="bit-help-pop">'
175
+ f'<span class="pixel-text" style="color:var(--text-tertiary);'
176
+ f'display:block;margin-bottom:6px">Metrics glossary</span>{rows}</span></span>'
177
+ )
178
+
179
+
180
+ def login_control(user=None, on_space=True):
181
+ """Sign-in link, or the signed-in handle.
182
+
183
+ Rendered as markup rather than `gr.LoginButton` so it can live inside the
184
+ design's header. It points at the same OAuth route the Gradio component
185
+ uses. Off-Space there is no OAuth to reach, so it says so instead.
186
+ """
187
+ if user:
188
+ return (
189
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
190
+ f"color:var(--accent-moss-strong);padding:3px 8px;"
191
+ f'border:1px solid var(--accent-moss-dim)">@{esc(user)}</span>'
192
+ )
193
+ if not on_space:
194
+ return (
195
+ f'<span class="mono-data" style="font-size:var(--text-2xs);'
196
+ f"color:var(--text-tertiary);padding:3px 8px;"
197
+ f'border:1px solid var(--border-default)" '
198
+ f'title="Sign-in is available when running on a Hugging Face Space. '
199
+ f'Reading and backtesting work without an account.">SIGN IN &#183; ON SPACE</span>'
200
+ )
201
+ return (
202
+ f'<a href="/login/huggingface" class="mono-data" '
203
+ f'style="font-size:var(--text-2xs);color:var(--stone-950);'
204
+ f"background:var(--accent-amber);padding:3px 8px;"
205
+ f'border:1px solid var(--accent-amber);text-decoration:none;'
206
+ f'white-space:nowrap">SIGN IN WITH HUGGING FACE</a>'
207
+ )
208
+
209
+
210
+ def top_bar(context="", status="NO RUN LOADED", tone="idle", elapsed="",
211
+ tab="compare", glossary=(), user=None, on_space=True):
212
  colors = {
213
  "ok": ("var(--accent-moss-strong)", "var(--accent-moss-dim)"),
214
  "run": ("var(--accent-amber-strong)", "var(--accent-amber-dim)"),
 
239
  f'<span style="font-family:var(--font-styrene);text-transform:uppercase;'
240
  f"letter-spacing:var(--tracking-wide);font-size:var(--text-md);"
241
  f'white-space:nowrap;margin-top:3px">Backtest Lab</span>'
242
+ f'<nav style="display:flex;gap:2px;margin-left:8px">'
243
+ f'{nav_tab("Compare", "compare", tab == "compare")}'
244
+ f'{nav_tab("Backtest", "backtest", tab == "backtest")}</nav>'
245
  f"{ctx}"
246
  f'<div style="display:flex;align-items:center;gap:8px;margin-left:auto;'
247
  f'flex-wrap:wrap">'
 
252
  f'background:{color};{pulse}"></span>'
253
  f'<span class="mono-data" style="font-size:var(--text-2xs);color:{color};'
254
  f'white-space:nowrap">{esc(status)}</span></span>'
255
+ f"{glossary_tooltip(glossary) if glossary else ''}"
256
+ f"{login_control(user, on_space)}"
257
  f'<a href="https://huggingface.co/datasets/The-Bit-Trading-Company/'
258
  f'bit-signal-store" target="_blank" rel="noopener" class="mono-data" '
259
  f'style="font-size:var(--text-2xs);color:var(--text-tertiary);'
src/ui/theme.py CHANGED
@@ -447,6 +447,29 @@ input:focus, select:focus, textarea:focus{
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. */
@@ -478,8 +501,9 @@ input:focus, select:focus, textarea:focus{
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;
 
447
  .bit-footer-right{ white-space:nowrap; }
448
 
449
 
450
+ /* ================= header help tooltip =================
451
+ The metrics glossary lives behind a `?` in the header. Pure CSS hover/focus,
452
+ so there is no state to keep in sync and it works without JS. */
453
+ .bit-help{ position:relative; display:inline-flex; }
454
+ .bit-help-dot{
455
+ display:inline-flex; align-items:center; justify-content:center;
456
+ width:18px; height:18px; cursor:help;
457
+ border:1px solid var(--border-default); color:var(--text-tertiary);
458
+ font-family:var(--font-mono); font-size:var(--text-2xs);
459
+ }
460
+ .bit-help-dot:hover, .bit-help-dot:focus{
461
+ border-color:var(--accent-amber); color:var(--accent-amber-strong);
462
+ outline:none;
463
+ }
464
+ .bit-help-pop{
465
+ display:none; position:absolute; top:24px; right:0; z-index:var(--z-menu);
466
+ width:320px; max-height:60vh; overflow:auto; padding:10px 12px;
467
+ background:var(--bg-raised); border:1px solid var(--border-strong);
468
+ box-shadow:0 8px 24px rgba(0,0,0,0.5); text-align:left;
469
+ }
470
+ .bit-help:hover .bit-help-pop,
471
+ .bit-help:focus-within .bit-help-pop{ display:block; }
472
+
473
  /* Gradio centres the app in a padded `.contain` wrapper, which was costing
474
  208px of a 1680px viewport. The design is a full-bleed dashboard, so the
475
  wrapper is stretched rather than the zones being shrunk to fit it. */
 
501
  flex:1 1 auto !important; min-width:0 !important; padding:0 12px !important;
502
  }
503
  .bit-zone-left > *, .bit-zone-right > *{ height:100%; }
504
+ /* The bridge target and trigger must stay in the DOM to receive events, but
505
+ never show. `visible=False` would remove them from the DOM entirely. */
506
+ #bit-action, #bit-trigger{
507
  position:absolute !important; width:1px !important; height:1px !important;
508
  overflow:hidden !important; clip:rect(0 0 0 0) !important; opacity:0 !important;
509
  pointer-events:none !important;
tests/test_ui.py CHANGED
@@ -616,3 +616,115 @@ def test_gradio_content_wrapper_is_stretched_full_bleed():
616
  body = m.group(2)
617
  assert "max-width:100% !important" in body
618
  assert "padding:0 !important" in body
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
616
  body = m.group(2)
617
  assert "max-width:100% !important" in body
618
  assert "padding:0 !important" in body
619
+
620
+
621
+ # --------------------------------------------------------------------------
622
+ # Regressions from the header/nav restructure
623
+ # --------------------------------------------------------------------------
624
+
625
+
626
+ def test_header_carries_nav_login_and_glossary():
627
+ from src.ui import shell
628
+
629
+ html = shell.top_bar(tab="backtest", glossary=bitapp.GLOSSARY, on_space=True)
630
+ assert 'data-bit="tab:compare"' in html
631
+ assert 'data-bit="tab:backtest"' in html
632
+ assert "/login/huggingface" in html, "sign-in link missing from the header"
633
+ assert "bit-help" in html, "glossary tooltip missing from the header"
634
+
635
+
636
+ def test_signed_in_header_shows_the_handle_not_a_login_link():
637
+ from src.ui import shell
638
+
639
+ html = shell.top_bar(user="alice", on_space=True)
640
+ assert "@alice" in html
641
+ assert "/login/huggingface" not in html
642
+
643
+
644
+ def test_off_space_header_explains_sign_in_is_unavailable():
645
+ from src.ui import shell
646
+
647
+ html = shell.top_bar(on_space=False)
648
+ assert "/login/huggingface" not in html
649
+ assert "SIGN IN" in html
650
+
651
+
652
+ def test_context_chip_only_appears_on_the_backtest_tab():
653
+ """On Compare, a run context chip would describe something not on screen."""
654
+ st = bitapp.default_state()
655
+ st["tab"] = "compare"
656
+
657
+ class FakeRec:
658
+ run_id = "abcd1234"
659
+ elapsed_s = 1.0
660
+
661
+ # The chip is the only place the asset and timeframe appear together.
662
+ # "WALK-FORWARD" alone is ambiguous -- it is also a glossary term.
663
+ compare_header = bitapp.render_top(st, FakeRec())
664
+ assert "BTC-USD · 1D" not in compare_header
665
+
666
+ st["tab"] = "backtest"
667
+ assert "BTC-USD · 1D" in bitapp.render_top(st, FakeRec())
668
+
669
+
670
+ def test_tab_action_switches_views():
671
+ st, _ = bitapp.apply_action(bitapp.default_state(), "tab:backtest|n1")
672
+ assert st["tab"] == "backtest"
673
+ st, _ = bitapp.apply_action(st, "tab:compare|n2")
674
+ assert st["tab"] == "compare"
675
+
676
+
677
+ def test_unknown_tab_value_is_ignored():
678
+ st, _ = bitapp.apply_action(bitapp.default_state(), "tab:../admin|n")
679
+ assert st["tab"] == "compare"
680
+
681
+
682
+ def test_the_same_click_cannot_run_twice():
683
+ """Two event bindings deliver one click; the nonce must de-duplicate it."""
684
+ st = bitapp.default_state()
685
+ st, first = bitapp.apply_action(st, "run:|nonce-a")
686
+ _, replay = bitapp.apply_action(st, "run:|nonce-a")
687
+ assert first is True and replay is False
688
+
689
+
690
+ def test_a_fresh_click_still_runs_after_a_deduped_one():
691
+ st = bitapp.default_state()
692
+ st, _ = bitapp.apply_action(st, "run:|nonce-a")
693
+ _, again = bitapp.apply_action(st, "run:|nonce-b")
694
+ assert again is True
695
+
696
+
697
+ def test_running_switches_to_the_backtest_tab():
698
+ st = bitapp.default_state()
699
+ assert st["tab"] == "compare"
700
+ st, should_run = bitapp.apply_action(st, "example:|n")
701
+ assert should_run is True
702
+
703
+
704
+ def test_right_sidebar_is_gone():
705
+ import inspect
706
+
707
+ src = inspect.getsource(bitapp.build_app)
708
+ assert "bit-zone-right" not in src
709
+ assert "right_panel" not in src
710
+
711
+
712
+ def test_bridge_elements_are_rendered_not_visible_false():
713
+ """`visible=False` removes an element from the DOM, which left the click
714
+ bridge with nothing to write into."""
715
+ import inspect
716
+
717
+ src = inspect.getsource(bitapp.build_app)
718
+ box = src[src.index("action_box = gr.Textbox"):]
719
+ assert "visible=True" in box[:260]
720
+ trig = src[src.index("action_trigger = gr.Button"):]
721
+ assert "visible=True" in trig[:200]
722
+
723
+
724
+ def test_bridge_js_is_loaded_without_outputs():
725
+ """Gradio treats a `js=` return value as the output values, so the bridge
726
+ installer must not share a load call that has outputs."""
727
+ import inspect
728
+
729
+ src = inspect.getsource(bitapp.build_app)
730
+ assert "demo.load(fn=None, inputs=None, outputs=None, js=BRIDGE_LOAD_JS)" in src