Spaces:
Running on Zero
Running on Zero
CI deploy cb6851b9
Browse files- app.py +195 -134
- src/ui/bridge.py +21 -3
- src/ui/shell.py +71 -1
- src/ui/theme.py +26 -2
- 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 == "
|
|
|
|
|
|
|
| 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 |
-
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
mode = {"walk_forward": "WALK-FORWARD", "holdout": "HOLDOUT",
|
| 232 |
"split": "SPLIT", "none": "NO SPLIT"}.get(st["validation_mode"], "")
|
| 233 |
try:
|
| 234 |
-
|
| 235 |
-
span = f"{
|
| 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(
|
| 399 |
history = gr.State([])
|
| 400 |
current = gr.State(None)
|
| 401 |
|
| 402 |
-
#
|
| 403 |
-
#
|
| 404 |
-
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
|
| 407 |
-
top_html = gr.HTML(
|
| 408 |
|
| 409 |
with gr.Row(elem_classes="bit-zones", equal_height=False):
|
| 410 |
-
|
| 411 |
-
|
|
|
|
|
|
|
| 412 |
|
| 413 |
with gr.Column(elem_classes="bit-zone-center", min_width=0):
|
| 414 |
-
stat_html = gr.HTML("")
|
| 415 |
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
with gr.Tabs():
|
| 420 |
-
with gr.Tab("
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 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 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 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 |
-
|
| 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),
|
| 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 |
-
|
| 550 |
-
|
| 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),
|
| 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,
|
| 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,
|
| 605 |
(lambda html, _df: (html, render_table(runtime.coverage_frame())))(
|
| 606 |
-
*extension.extend_ui(m, a, t,
|
| 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,
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 · 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
|
| 482 |
-
|
|
|
|
| 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
|