Spaces:
Running on Zero
Running on Zero
CI deploy 9dc88d4e
Browse files- app.py +81 -13
- src/store.py +49 -3
- src/ui/shell.py +62 -13
- src/ui/theme.py +38 -1
- tests/test_store.py +90 -0
- 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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(),
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(),
|
|
|
|
| 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,
|
|
|
|
| 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
|
| 587 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 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
|
| 197 |
-
f'
|
| 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 · 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:
|
| 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
|
| 638 |
from src.ui import shell
|
| 639 |
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 643 |
|
| 644 |
|
| 645 |
def test_off_space_header_explains_sign_in_is_unavailable():
|
| 646 |
from src.ui import shell
|
| 647 |
|
| 648 |
-
|
| 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():
|