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