Bit-Trading-Company commited on
Commit
b656fb9
·
verified ·
1 Parent(s): 3f2deae

CI deploy 9dc88d4e

Browse files
Files changed (6) hide show
  1. app.py +81 -13
  2. src/store.py +49 -3
  3. src/ui/shell.py +62 -13
  4. src/ui/theme.py +38 -1
  5. tests/test_store.py +90 -0
  6. tests/test_ui.py +15 -8
app.py CHANGED
@@ -88,6 +88,9 @@ def default_state() -> dict:
88
  "needs_signals": False,
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,
@@ -181,6 +184,11 @@ def apply_action(st: dict, raw: str) -> tuple[dict, bool]:
181
  st["topn"] = max(5, min(50, int(v)))
182
  except ValueError:
183
  pass
 
 
 
 
 
184
  elif k == "sigasset" and v in config.ASSETS:
185
  st["sig_asset"] = v
186
  elif k == "sigtf" and v in config.TIMEFRAMES:
@@ -197,6 +205,42 @@ def apply_action(st: dict, raw: str) -> tuple[dict, bool]:
197
  return refresh_context(st), False
198
 
199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  def _set_param(st: dict, name: str, raw: str) -> dict:
201
  """Set a numeric field, ignoring anything that is not a number."""
202
  if name in NUMERIC_PARAMS:
@@ -413,7 +457,7 @@ def build_app() -> gr.Blocks:
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([])
@@ -431,7 +475,14 @@ def build_app() -> gr.Blocks:
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.
@@ -446,6 +497,7 @@ def build_app() -> gr.Blocks:
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("")
@@ -509,6 +561,7 @@ def build_app() -> gr.Blocks:
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():
@@ -540,7 +593,8 @@ def build_app() -> gr.Blocks:
540
 
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):
@@ -556,13 +610,23 @@ def build_app() -> gr.Blocks:
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"],
562
- min_trades=0, hide_baselines=False, require_oos=True,
563
- top_n=st["topn"])
564
- return (pod, render_table(table_df, align_right=range(4, 15),
565
- empty="catalog not generated yet"),
 
 
 
 
 
 
 
 
 
566
  overlay, scatter, meta)
567
 
568
  def on_action(raw, st, hist, rec, progress=gr.Progress()):
@@ -571,7 +635,8 @@ def build_app() -> gr.Blocks:
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
 
@@ -587,6 +652,7 @@ def build_app() -> gr.Blocks:
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))
@@ -595,7 +661,7 @@ def build_app() -> gr.Blocks:
595
  hist = ([rec] + list(hist))[:40]
596
  return (
597
  st, hist, rec, render_left(st), render_top(st, rec),
598
- render_stat_band(rec),
599
  *build_overview(rec, log_scale=st["log_scale"], cvd=st["cvd"]),
600
  shell.micro(f"{len(rec.result.trades)} total · costs paid "
601
  f"{money(rec.result.costs_paid)} · fills at next bar open"),
@@ -608,6 +674,7 @@ def build_app() -> gr.Blocks:
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
 
@@ -674,7 +741,7 @@ def build_app() -> gr.Blocks:
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"),
@@ -685,7 +752,8 @@ def build_app() -> gr.Blocks:
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])
 
88
  "needs_signals": False,
89
  "log_scale": False, "cvd": False,
90
  "metric": "OOS Sharpe", "topn": 15,
91
+ "filters": {"assets": [], "timeframes": [], "strategies": [],
92
+ "models": [], "min_trades": 0,
93
+ "require_oos": True, "hide_baselines": False},
94
  "sig_asset": "BTC-USD", "sig_tf": "1d",
95
  "tab": "compare", # "compare" | "backtest"
96
  "user": None,
 
184
  st["topn"] = max(5, min(50, int(v)))
185
  except ValueError:
186
  pass
187
+ elif k == "filter":
188
+ st = _apply_filter(st, v)
189
+ elif k == "reset":
190
+ st["filters"] = default_state()["filters"]
191
+ st["metric"], st["topn"] = "OOS Sharpe", 15
192
  elif k == "sigasset" and v in config.ASSETS:
193
  st["sig_asset"] = v
194
  elif k == "sigtf" and v in config.TIMEFRAMES:
 
205
  return refresh_context(st), False
206
 
207
 
208
+ # Which filter kinds are multi-select lists, and what each is validated against.
209
+ _FILTER_LISTS = {
210
+ "asset": ("assets", lambda: set(config.ASSETS)),
211
+ "tf": ("timeframes", lambda: set(config.TIMEFRAMES)),
212
+ "strategy": ("strategies", lambda: set(catalog.CATALOG_STRATEGIES)),
213
+ "model": ("models", lambda: set(config.SEED_MODELS)),
214
+ }
215
+
216
+
217
+ def _apply_filter(st: dict, value: str) -> dict:
218
+ """Fold `kind=value` into the filter set. Unknown values are ignored."""
219
+ kind, val = parse_pair(value)
220
+ filters = dict(st["filters"])
221
+
222
+ if kind in _FILTER_LISTS:
223
+ key, allowed = _FILTER_LISTS[kind]
224
+ if val not in allowed():
225
+ return st
226
+ chosen = list(filters.get(key, []))
227
+ # Clicking a selected chip clears it, so "none selected" means all.
228
+ filters[key] = [x for x in chosen if x != val] if val in chosen \
229
+ else chosen + [val]
230
+ elif kind == "min_trades":
231
+ try:
232
+ filters["min_trades"] = max(0, min(500, int(val)))
233
+ except ValueError:
234
+ return st
235
+ elif kind in ("require_oos", "hide_baselines"):
236
+ filters[kind] = not filters.get(kind, False)
237
+ else:
238
+ return st
239
+
240
+ st["filters"] = filters
241
+ return st
242
+
243
+
244
  def _set_param(st: dict, name: str, raw: str) -> dict:
245
  """Set a numeric field, ignoring anything that is not a number."""
246
  if name in NUMERIC_PARAMS:
 
457
 
458
  with gr.Blocks(theme=theme.bit_theme(), css=theme.full_css(),
459
  head=BRIDGE_JS, title="Bit · Backtest Lab",
460
+ analytics_enabled=False) as demo:
461
 
462
  state = gr.State(boot)
463
  history = gr.State([])
 
475
  action_trigger = gr.Button("", elem_id=TRIGGER_ELEMENT_ID,
476
  visible=True, variant="secondary")
477
 
478
+ # The header is a Row so a real `gr.LoginButton` can sit inside the
479
+ # bar. That component is also what makes Gradio mount the OAuth routes
480
+ # at all -- without it, /login/huggingface is a 404.
481
+ with gr.Row(elem_classes="bit-headerbar"):
482
+ top_html = gr.HTML(render_top(boot, None))
483
+ if ON_SPACE:
484
+ gr.LoginButton(value="Sign in with Hugging Face", size="sm",
485
+ elem_classes="bit-login-btn", scale=0)
486
 
487
  with gr.Row(elem_classes="bit-zones", equal_height=False):
488
  # Strategy Builder belongs to the Backtest tab only.
 
497
  catalog_meta = gr.HTML("")
498
  with gr.Tabs():
499
  with gr.Tab("Leaderboard"):
500
+ lb_controls = gr.HTML("")
501
  podium = gr.HTML("")
502
  lb_meta = gr.HTML("")
503
  lb_table = gr.HTML("")
 
561
  stat_html = gr.HTML("")
562
  with gr.Tabs():
563
  with gr.Tab("Overview"):
564
+ chart_opts = gr.HTML("")
565
  equity_plot = gr.Plot()
566
  regime_plot = gr.Plot()
567
  with gr.Row():
 
593
 
594
  overview_out = [equity_plot, regime_plot, underwater_plot, rolling_plot,
595
  price_plot, pnl_plot, hold_plot, mae_plot, costs_note]
596
+ compare_out = [lb_controls, podium, lb_table, lb_overlay,
597
+ lb_scatter, lb_meta]
598
  view_out = [left_col, compare_view, backtest_view, empty_html, results_view]
599
 
600
  def views(st, rec):
 
610
  )
611
 
612
  def compare_views(st):
613
+ f = st["filters"]
614
  pod, table_df, overlay, scatter, meta = CT.build_leaderboard_view(
615
+ runtime.get_store(),
616
+ assets=f["assets"] or None, timeframes=f["timeframes"] or None,
617
+ strategies_=f["strategies"] or None, models=f["models"] or None,
618
+ metric_label=st["metric"], min_trades=f["min_trades"],
619
+ hide_baselines=f["hide_baselines"],
620
+ require_oos=f["require_oos"], top_n=st["topn"])
621
+ controls = shell.compare_controls(
622
+ st, metrics=list(CT.RANK_METRICS),
623
+ assets=runtime.available_assets(),
624
+ timeframes=list(config.TIMEFRAMES),
625
+ strategies_=list(catalog.CATALOG_STRATEGIES),
626
+ models=sorted(config.SEED_MODELS))
627
+ return (controls, pod,
628
+ render_table(table_df, align_right=range(4, 15),
629
+ empty="catalog not generated yet"),
630
  overlay, scatter, meta)
631
 
632
  def on_action(raw, st, hist, rec, progress=gr.Progress()):
 
635
 
636
  if not should_run:
637
  return (st, hist, rec, render_left(st), render_top(st, rec),
638
+ gr.update(), shell.chart_controls(st),
639
+ *(gr.update(),) * 9, gr.update(),
640
  gr.update(), gr.update(), gr.update(),
641
  *compare_views(st), *views(st, rec))
642
 
 
652
  tab="backtest", glossary=GLOSSARY,
653
  user=st.get("user"), on_space=ON_SPACE),
654
  shell.note(esc(str(exc)), danger=True),
655
+ shell.chart_controls(st),
656
  *(gr.update(),) * 9, gr.update(), gr.update(),
657
  gr.update(), gr.update(),
658
  *compare_views(st), *views(st, None))
 
661
  hist = ([rec] + list(hist))[:40]
662
  return (
663
  st, hist, rec, render_left(st), render_top(st, rec),
664
+ render_stat_band(rec), shell.chart_controls(st),
665
  *build_overview(rec, log_scale=st["log_scale"], cvd=st["cvd"]),
666
  shell.micro(f"{len(rec.result.trades)} total · costs paid "
667
  f"{money(rec.result.costs_paid)} · fills at next bar open"),
 
674
  )
675
 
676
  action_out = [state, history, current, left_html, top_html, stat_html,
677
+ chart_opts,
678
  *overview_out, trades_head, trades_html, report_md,
679
  report_equity, *compare_out, *view_out]
680
 
 
741
  saved = catalog.load_saved_runs(store)
742
 
743
  return (st, render_top(st, None), render_left(st), meta_html,
744
+ shell.chart_controls(st), *compare_views(st),
745
  note, acc, cal, bars,
746
  render_table(score_df, align_right=range(3, 10),
747
  empty="no scorecard rows"),
 
752
  extension.status_html())
753
 
754
  demo.load(on_load, [state],
755
+ [state, top_html, left_html, catalog_meta, chart_opts,
756
+ *compare_out,
757
  models_note, acc_plot, cal_plot, model_bars, score_table,
758
  sig_panel, runs_table, coverage_kpis, coverage_html,
759
  extend_panel])
src/store.py CHANGED
@@ -20,6 +20,7 @@ Space (see `attach_scheduler`).
20
  from __future__ import annotations
21
 
22
  import json
 
23
  import os
24
  import threading
25
  from dataclasses import dataclass, field, asdict
@@ -31,6 +32,8 @@ import pandas as pd
31
 
32
  from . import config
33
 
 
 
34
  # --------------------------------------------------------------------------
35
  # Errors
36
  # --------------------------------------------------------------------------
@@ -583,13 +586,44 @@ class SignalStore:
583
 
584
  # -- writes -----------------------------------------------------------
585
 
586
- def _merge_year(self, repo_path: str, new: pd.DataFrame) -> pd.DataFrame:
587
- """Append-only merge: existing rows win on a ts collision."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
  existing = self.read_parquet(repo_path)
589
  if existing is None or not len(existing):
590
  return new.sort_values("ts").reset_index(drop=True)
 
591
  existing = existing.copy()
592
  existing["ts"] = existing["ts"].map(_utc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
593
  merged = pd.concat([existing, new], ignore_index=True)
594
  merged = merged.drop_duplicates(subset="ts", keep="first")
595
  return merged.sort_values("ts").reset_index(drop=True)
@@ -612,10 +646,22 @@ class SignalStore:
612
  if frame.empty:
613
  raise StoreError("refusing to write an empty signal frame")
614
 
 
 
 
 
 
 
 
 
 
 
 
 
615
  total_rows = 0
616
  for year, part in frame.groupby(frame["ts"].dt.year):
617
  path = self.signal_path(model_slug, asset, timeframe, int(year))
618
- merged = self._merge_year(path, part)
619
  self._stage(path, lambda p, m=merged: m.to_parquet(p, index=False))
620
  total_rows += len(merged)
621
 
 
20
  from __future__ import annotations
21
 
22
  import json
23
+ import logging
24
  import os
25
  import threading
26
  from dataclasses import dataclass, field, asdict
 
32
 
33
  from . import config
34
 
35
+ log = logging.getLogger("bit.store")
36
+
37
  # --------------------------------------------------------------------------
38
  # Errors
39
  # --------------------------------------------------------------------------
 
586
 
587
  # -- writes -----------------------------------------------------------
588
 
589
+ def _merge_year(self, repo_path: str, new: pd.DataFrame,
590
+ supersede_on: str | None = None) -> pd.DataFrame:
591
+ """Merge a slice into a year file.
592
+
593
+ Re-writing the *same* thing is idempotent: existing rows win on a
594
+ timestamp collision, so a repeated seed or extension changes nothing.
595
+
596
+ `supersede_on` names a column that identifies which computation produced
597
+ a row -- `inference_version` for signals. When the incoming rows carry a
598
+ different value there, they are a *different* computation and must
599
+ replace what is stored.
600
+
601
+ This matters because the parquet path is keyed on model slug, not model
602
+ revision, while the manifest is keyed on both. Without superseding, a
603
+ second revision would be recorded in the manifest while the file still
604
+ held the first revision's numbers -- and a PLACEHOLDER slice would
605
+ shadow real output permanently.
606
+ """
607
  existing = self.read_parquet(repo_path)
608
  if existing is None or not len(existing):
609
  return new.sort_values("ts").reset_index(drop=True)
610
+
611
  existing = existing.copy()
612
  existing["ts"] = existing["ts"].map(_utc)
613
+
614
+ if supersede_on and supersede_on in existing.columns \
615
+ and supersede_on in new.columns and len(new):
616
+ incoming_version = new[supersede_on].iloc[0]
617
+ # Drop stored rows that this write supersedes at the same instant.
618
+ superseded = (existing["ts"].isin(set(new["ts"]))
619
+ & (existing[supersede_on] != incoming_version))
620
+ if superseded.any():
621
+ log.info("superseding %d row(s) in %s (%s -> %s)",
622
+ int(superseded.sum()), repo_path,
623
+ existing.loc[superseded, supersede_on].iloc[0],
624
+ incoming_version)
625
+ existing = existing[~superseded]
626
+
627
  merged = pd.concat([existing, new], ignore_index=True)
628
  merged = merged.drop_duplicates(subset="ts", keep="first")
629
  return merged.sort_values("ts").reset_index(drop=True)
 
646
  if frame.empty:
647
  raise StoreError("refusing to write an empty signal frame")
648
 
649
+ # The caller passes `inference_version` *and* the frame carries a column
650
+ # of the same name. If those disagree, the manifest records one version
651
+ # while the rows claim another -- and supersede compares the wrong
652
+ # value, silently keeping stale numbers. One of them has to win, and it
653
+ # is the argument, because that is what the manifest entry records.
654
+ stamped = set(frame["inference_version"].unique())
655
+ if stamped != {inference_version}:
656
+ log.debug("stamping inference_version %s over %s",
657
+ inference_version, sorted(stamped))
658
+ frame = frame.copy()
659
+ frame["inference_version"] = inference_version
660
+
661
  total_rows = 0
662
  for year, part in frame.groupby(frame["ts"].dt.year):
663
  path = self.signal_path(model_slug, asset, timeframe, int(year))
664
+ merged = self._merge_year(path, part, supersede_on="inference_version")
665
  self._stage(path, lambda p, m=merged: m.to_parquet(p, index=False))
666
  total_rows += len(merged)
667
 
src/ui/shell.py CHANGED
@@ -176,11 +176,11 @@ def glossary_tooltip(items):
176
 
177
 
178
  def login_control(user=None, on_space=True):
179
- """Sign-in link, or the signed-in handle.
180
 
181
- Rendered as markup rather than `gr.LoginButton` so it can live inside the
182
- design's header. It points at the same OAuth route the Gradio component
183
- uses. Off-Space there is no OAuth to reach, so it says so instead.
184
  """
185
  if user:
186
  return (
@@ -193,16 +193,10 @@ def login_control(user=None, on_space=True):
193
  f'<span class="mono-data" style="font-size:var(--text-2xs);'
194
  f"color:var(--text-tertiary);padding:3px 8px;"
195
  f'border:1px solid var(--border-default)" '
196
- f'title="Sign-in is available when running on a Hugging Face Space. '
197
- f'Reading and backtesting work without an account.">SIGN IN &#183; ON SPACE</span>'
198
  )
199
- return (
200
- f'<a href="/login/huggingface" class="mono-data" '
201
- f'style="font-size:var(--text-2xs);color:var(--stone-950);'
202
- f"background:var(--accent-amber);padding:3px 8px;"
203
- f'border:1px solid var(--accent-amber);text-decoration:none;'
204
- f'white-space:nowrap">SIGN IN WITH HUGGING FACE</a>'
205
- )
206
 
207
 
208
  def top_bar(context="", status="NO RUN LOADED", tone="idle", elapsed="",
@@ -499,3 +493,58 @@ def frame_to_rows(df, limit=200):
499
  rows = [[esc("" if v is None else v) for v in rec]
500
  for rec in sub.itertuples(index=False, name=None)]
501
  return headers, rows
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
 
178
  def login_control(user=None, on_space=True):
179
+ """Signed-in handle, or a note when there is no OAuth to reach.
180
 
181
+ The sign-in *button* is a real `gr.LoginButton` placed beside this markup:
182
+ Gradio only mounts `/login/huggingface` when it sees that component in the
183
+ app, so hand-rolling an anchor points at a route that returns 404.
184
  """
185
  if user:
186
  return (
 
193
  f'<span class="mono-data" style="font-size:var(--text-2xs);'
194
  f"color:var(--text-tertiary);padding:3px 8px;"
195
  f'border:1px solid var(--border-default)" '
196
+ f'title="Sign-in needs a Hugging Face Space. Reading and '
197
+ f'backtesting work without an account.">SIGN IN &#183; ON SPACE</span>'
198
  )
199
+ return "" # the real LoginButton renders here
 
 
 
 
 
 
200
 
201
 
202
  def top_bar(context="", status="NO RUN LOADED", tone="idle", elapsed="",
 
493
  rows = [[esc("" if v is None else v) for v in rec]
494
  for rec in sub.itertuples(index=False, name=None)]
495
  return headers, rows
496
+
497
+
498
+ # --------------------------------------------------------------------------
499
+ # Compare view controls
500
+ # --------------------------------------------------------------------------
501
+
502
+
503
+ def compare_controls(st, *, metrics, assets, timeframes, strategies_, models):
504
+ """How the leaderboard is ranked, filtered and plotted.
505
+
506
+ Rendered as the design's chips rather than dropdowns so the controls read
507
+ as part of the board rather than a form sitting above it. Filters are
508
+ multi-select: clicking a chip toggles it, and none selected means all.
509
+ """
510
+ sel = st.get("filters", {})
511
+
512
+ def toggle_row(label, kind, options, active):
513
+ chips = [chip(o, emit("filter", f"{kind}={o}"), active=(o in active))
514
+ for o in options]
515
+ return field(label, chips)
516
+
517
+ rank = field("Rank by", [
518
+ chip(m, emit("metric", m), active=(m == st["metric"])) for m in metrics])
519
+ top = field("Show top", [
520
+ chip(str(n), emit("topn", str(n)), active=(int(st["topn"]) == n))
521
+ for n in (10, 15, 25, 50)])
522
+ minimum = field("Min trades", [
523
+ chip(str(n), emit("filter", f"min_trades={n}"),
524
+ active=int(sel.get("min_trades", 0)) == n)
525
+ for n in (0, 20, 50)])
526
+ flags = field("Show", [
527
+ chip("Out-of-sample only", emit("filter", "require_oos=toggle"),
528
+ active=bool(sel.get("require_oos", True))),
529
+ chip("Hide baselines", emit("filter", "hide_baselines=toggle"),
530
+ active=bool(sel.get("hide_baselines", False))),
531
+ chip("Reset", emit("reset"), active=False),
532
+ ])
533
+
534
+ body = (
535
+ f'<div style="display:flex;flex-wrap:wrap;gap:16px">{rank}{top}{minimum}'
536
+ f"{flags}</div>"
537
+ + toggle_row("Assets", "asset", assets, sel.get("assets", []))
538
+ + toggle_row("Timeframes", "tf", timeframes, sel.get("timeframes", []))
539
+ + toggle_row("Strategies", "strategy", strategies_, sel.get("strategies", []))
540
+ + toggle_row("Models", "model", models, sel.get("models", []))
541
+ )
542
+ return panel("View", body, meta="CLICK TO FILTER · NONE SELECTED = ALL")
543
+
544
+
545
+ def chart_controls(st):
546
+ """Plot options for the Backtest overview."""
547
+ return row([
548
+ chip("Log scale", emit("logscale"), active=bool(st.get("log_scale"))),
549
+ chip("Colorblind-safe", emit("cvd"), active=bool(st.get("cvd"))),
550
+ ], gap="6px")
src/ui/theme.py CHANGED
@@ -447,6 +447,43 @@ input:focus, select:focus, textarea:focus{
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. */
@@ -496,7 +533,7 @@ input:focus, select:focus, textarea:focus{
496
  .bit-zone-center{
497
  flex:1 1 auto !important; min-width:0 !important; padding:0 12px !important;
498
  }
499
- .bit-zone-left > *{ height:100%; }
500
  /* The bridge target and trigger must stay in the DOM to receive events, but
501
  never show. `visible=False` would remove them from the DOM entirely. */
502
  #bit-action, #bit-trigger{
 
447
  .bit-footer-right{ white-space:nowrap; }
448
 
449
 
450
+ /* ================= page height =================
451
+ Gradio's fill_height wrapper sets `min-height:100%` on a child of a parent
452
+ whose own height comes from its content. That is circular: the child asks
453
+ for the parent's height, the parent grows to fit the child, repeat. Plotly's
454
+ ResizeObserver then feeds the loop and the page grows as you scroll.
455
+ This is a content-height dashboard, so the chain is cut instead. */
456
+ html, body{ height:auto !important; min-height:0 !important; }
457
+ .gradio-container, gradio-app{ height:auto !important; min-height:0 !important; }
458
+ .gradio-container main, .gradio-container .wrap, .gradio-container .contain{
459
+ height:auto !important; min-height:0 !important;
460
+ }
461
+ .gradio-container main.fillable{ min-height:0 !important; }
462
+ /* A plot must never be able to grow its own container. */
463
+ .gradio-container .js-plotly-plot, .gradio-container .plot-container{
464
+ max-height:560px !important;
465
+ }
466
+
467
+ /* The header is a Gradio Row wrapping the design's <header> plus the real
468
+ LoginButton, so it must not add spacing of its own. */
469
+ .bit-headerbar{
470
+ position:sticky; top:0; z-index:var(--z-header);
471
+ gap:0 !important; align-items:stretch !important; flex-wrap:nowrap !important;
472
+ background:var(--bg-panel); border-bottom:1px solid var(--border-default);
473
+ }
474
+ .bit-headerbar > *:first-child{ flex:1 1 auto; min-width:0; }
475
+ .bit-headerbar header{ position:static !important; border-bottom:0 !important; }
476
+ .bit-login-btn{
477
+ align-self:center; margin-right:12px !important; flex:0 0 auto !important;
478
+ background:var(--accent-amber) !important; color:var(--stone-950) !important;
479
+ border:1px solid var(--accent-amber) !important;
480
+ font-family:var(--font-mono) !important; font-size:var(--text-2xs) !important;
481
+ padding:3px 8px !important; white-space:nowrap;
482
+ }
483
+ .bit-login-btn:hover{
484
+ background:var(--stone-950) !important; color:var(--accent-amber) !important;
485
+ }
486
+
487
  /* ================= header help tooltip =================
488
  The metrics glossary lives behind a `?` in the header. Pure CSS hover/focus,
489
  so there is no state to keep in sync and it works without JS. */
 
533
  .bit-zone-center{
534
  flex:1 1 auto !important; min-width:0 !important; padding:0 12px !important;
535
  }
536
+ .bit-zone-left > *{ height:auto; }
537
  /* The bridge target and trigger must stay in the DOM to receive events, but
538
  never show. `visible=False` would remove them from the DOM entirely. */
539
  #bit-action, #bit-trigger{
tests/test_store.py CHANGED
@@ -359,3 +359,93 @@ def test_price_write_is_idempotent(store):
359
  cov = store.write_prices("BTC-USD", "1d", df)
360
  assert cov.rows == 40
361
  assert len(store.get_prices("BTC-USD", "1d")) == 40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  cov = store.write_prices("BTC-USD", "1d", df)
360
  assert cov.rows == 40
361
  assert len(store.get_prices("BTC-USD", "1d")) == 40
362
+
363
+
364
+ # --------------------------------------------------------------------------
365
+ # Redundant / interfering data
366
+ #
367
+ # The parquet path is keyed on model slug but the manifest is keyed on slug AND
368
+ # revision, so two revisions share one file. Without superseding, the manifest
369
+ # would record the new revision while the file still held the old numbers.
370
+ # --------------------------------------------------------------------------
371
+
372
+
373
+ def test_rewriting_the_same_version_is_idempotent(store):
374
+ """A repeated seed or extension must change nothing at all."""
375
+ store.write_signals("m", "org/m", "revA", "BTC-USD", "1d",
376
+ make_signals(base=100.0), inference_version="1.0.0")
377
+ before = store.get_signals("m", "BTC-USD", "1d")["q50"].tolist()
378
+ store.write_signals("m", "org/m", "revA", "BTC-USD", "1d",
379
+ make_signals(base=100.0), inference_version="1.0.0")
380
+ after = store.get_signals("m", "BTC-USD", "1d")
381
+ assert after["q50"].tolist() == before
382
+ assert len(after) == 30
383
+
384
+
385
+ def test_a_new_revision_supersedes_the_old_numbers(store):
386
+ store.write_signals("m", "org/m", "revA", "BTC-USD", "1d",
387
+ make_signals(base=100.0), inference_version="1.0.0")
388
+ store.write_signals("m", "org/m", "revB", "BTC-USD", "1d",
389
+ make_signals(base=999.0), inference_version="2.0.0")
390
+
391
+ got = store.get_signals("m", "BTC-USD", "1d")
392
+ assert got["q50"].iloc[0] == 999.0, "manifest would claim revB but hold revA"
393
+ assert set(got["inference_version"]) == {"2.0.0"}
394
+ assert not got.index.duplicated().any()
395
+
396
+
397
+ def test_real_output_supersedes_a_placeholder_slice(store):
398
+ """A PLACEHOLDER slice must never shadow real inference forever."""
399
+ store.write_signals("p", "org/p", "PLACEHOLDER", "BTC-USD", "1d",
400
+ make_signals(base=1.0),
401
+ inference_version=config.PLACEHOLDER_VERSION)
402
+ store.write_signals("p", "org/p", "revReal", "BTC-USD", "1d",
403
+ make_signals(base=500.0), inference_version="1.0.0")
404
+
405
+ got = store.get_signals("p", "BTC-USD", "1d")
406
+ assert got["q50"].iloc[0] == 500.0
407
+ assert config.PLACEHOLDER_VERSION not in set(got["inference_version"])
408
+
409
+
410
+ def test_the_written_version_is_authoritative_over_the_frame(store):
411
+ """The kwarg and the frame column can disagree; the kwarg records the
412
+ manifest entry, so it has to win or supersede compares the wrong value."""
413
+ df = make_signals(base=7.0)
414
+ df["inference_version"] = "stale-value-from-the-caller"
415
+ store.write_signals("m", "org/m", "revA", "BTC-USD", "1d", df,
416
+ inference_version="1.0.0")
417
+
418
+ got = store.get_signals("m", "BTC-USD", "1d")
419
+ assert set(got["inference_version"]) == {"1.0.0"}
420
+
421
+
422
+ def test_superseding_leaves_untouched_timestamps_alone(store):
423
+ """Only the overlapping instants are replaced, not the whole file."""
424
+ store.write_signals("m", "org/m", "revA", "BTC-USD", "1d",
425
+ make_signals(n=30, start="2024-01-01", base=100.0),
426
+ inference_version="1.0.0")
427
+ # A newer version covering only the first 10 bars.
428
+ store.write_signals("m", "org/m", "revB", "BTC-USD", "1d",
429
+ make_signals(n=10, start="2024-01-01", base=999.0),
430
+ inference_version="2.0.0")
431
+
432
+ got = store.get_signals("m", "BTC-USD", "1d")
433
+ assert len(got) == 30, "non-overlapping rows must survive"
434
+ assert got["q50"].iloc[0] == 999.0
435
+ assert got["inference_version"].iloc[0] == "2.0.0"
436
+ assert got["inference_version"].iloc[-1] == "1.0.0"
437
+
438
+
439
+ def test_price_rows_keep_first_source_on_collision(store):
440
+ """Prices have no version to compare, so first-writer-wins stands. Two
441
+ providers disagreeing about one bar is ambiguous, not a supersede."""
442
+ a = make_prices(n=10)
443
+ a["source"] = "binance"
444
+ b = make_prices(n=10, base=500.0)
445
+ b["source"] = "coinbase"
446
+ store.write_prices("BTC-USD", "1d", a)
447
+ store.write_prices("BTC-USD", "1d", b)
448
+
449
+ got = store.get_prices("BTC-USD", "1d")
450
+ assert len(got) == 10
451
+ assert set(got["source"]) == {"binance"}
tests/test_ui.py CHANGED
@@ -630,24 +630,31 @@ def test_header_carries_nav_login_and_glossary():
630
  html = shell.top_bar(tab="backtest", glossary=bitapp.GLOSSARY, on_space=True)
631
  assert 'data-bit="tab:compare"' in html
632
  assert 'data-bit="tab:backtest"' in html
633
- assert "/login/huggingface" in html, "sign-in link missing from the header"
634
  assert "bit-help" in html, "glossary tooltip missing from the header"
635
 
636
 
637
- def test_signed_in_header_shows_the_handle_not_a_login_link():
638
  from src.ui import shell
639
 
640
- html = shell.top_bar(user="alice", on_space=True)
641
- assert "@alice" in html
642
- assert "/login/huggingface" not in html
 
 
 
 
 
 
 
 
 
 
643
 
644
 
645
  def test_off_space_header_explains_sign_in_is_unavailable():
646
  from src.ui import shell
647
 
648
- html = shell.top_bar(on_space=False)
649
- assert "/login/huggingface" not in html
650
- assert "SIGN IN" in html
651
 
652
 
653
  def test_context_chip_only_appears_on_the_backtest_tab():
 
630
  html = shell.top_bar(tab="backtest", glossary=bitapp.GLOSSARY, on_space=True)
631
  assert 'data-bit="tab:compare"' in html
632
  assert 'data-bit="tab:backtest"' in html
 
633
  assert "bit-help" in html, "glossary tooltip missing from the header"
634
 
635
 
636
+ def test_signed_in_header_shows_the_handle():
637
  from src.ui import shell
638
 
639
+ assert "@alice" in shell.top_bar(user="alice", on_space=True)
640
+
641
+
642
+ def test_sign_in_uses_a_real_login_button_not_a_handrolled_link():
643
+ """Gradio only mounts /login/huggingface when it sees a LoginButton in the
644
+ app. A hand-rolled anchor points at a route that returns 404."""
645
+ import inspect
646
+ from src.ui import shell
647
+
648
+ src = inspect.getsource(bitapp.build_app)
649
+ assert "gr.LoginButton" in src, "no LoginButton, so OAuth routes never mount"
650
+ # and the markup must not fake one
651
+ assert "/login/huggingface" not in shell.top_bar(on_space=True)
652
 
653
 
654
  def test_off_space_header_explains_sign_in_is_unavailable():
655
  from src.ui import shell
656
 
657
+ assert "SIGN IN" in shell.top_bar(on_space=False)
 
 
658
 
659
 
660
  def test_context_chip_only_appears_on_the_backtest_tab():