bit-backtest-lab / src /ui /bridge.py
Bit-Trading-Company's picture
CI deploy cb6851b9
f0ac64a verified
Raw
History Blame Contribute Delete
8.63 kB
"""Click bridge: design markup in, Python state out.
The design is built almost entirely from `<button>` elements -- 23 of them
against a single `<input>` in the whole file. Gradio's own controls render a
completely different DOM (`gr.Radio` becomes a `<fieldset>` of labels wrapping
hidden radio inputs), which is why restyling them from outside only ever
approximates the design.
So the visible layer is the design's own markup, and this module is how a real
`<button>` reaches Python:
<button data-bit="tf:1h">1h</button>
|
| delegated listener (installed once, in <head>)
v
hidden Textbox #bit-action <- value set + native `input` event dispatched
|
v
Gradio .change() -> parse_action() -> new state -> re-render
A nonce is appended to every action because Gradio's `.change()` only fires when
the value actually differs; clicking the same button twice must still register.
Nothing here evaluates anything. Actions are `key:value` pairs, the key must be
on `ALLOWED_KEYS`, and the value is returned as an opaque string for the caller
to validate against its own domain.
"""
from __future__ import annotations
from dataclasses import dataclass
# Every action the UI is allowed to emit. An unknown key is dropped, so a
# hand-crafted click cannot reach code paths the UI does not offer.
ALLOWED_KEYS = frozenset({
"tab", # switch a top-level tab
"subtab", # switch a Compare sub-tab
"acc", # toggle a left-panel accordion section
"strategy", # choose a preset
"asset", # choose an asset
"tf", # choose a timeframe
"range", # choose a date range
"model", # choose a forecast model
"param", # set a strategy parameter (param:key=value)
"costs", # toggle costs on/off
"slippage", # choose a slippage model
"sizing", # choose a sizing mode
"validation", # choose a validation mode
"metric", # leaderboard rank metric
"filter", # leaderboard filter toggle (filter:kind=value)
"topn", # leaderboard row count
"sigasset", # signal aggregator asset
"sigtf", # signal aggregator timeframe
"run", # run the backtest
"example", # load the worked example
"reset", # reset leaderboard filters
"logscale", # toggle log scale
"cvd", # toggle colorblind-safe prices
"noop", # explicit no-op, used by disabled controls
})
ACTION_ELEMENT_ID = "bit-action"
TRIGGER_ELEMENT_ID = "bit-trigger"
# Separates the action from its nonce. A pipe is used rather than a space
# because action values are human-readable names -- "SMA Crossover", "Buy & Hold
# (benchmark)" -- which legitimately contain spaces. No value contains a pipe.
NONCE_SEP = "|"
# The listener body. Installed two ways, because neither alone is reliable:
# `gr.Blocks(head=...)` works locally but Hugging Face Spaces serves the page
# through its own template and drops it, while `demo.load(js=...)` runs
# client-side wherever the page came from. Both call the same idempotent
# installer, guarded by a window flag, so running twice is harmless.
BRIDGE_JS = """
<script>
(function () {
if (window.__bitBridgeInstalled) return;
window.__bitBridgeInstalled = true;
function holder() {
var root = document.getElementById('__ELEM_ID__');
return root ? root.querySelector('textarea, input') : null;
}
document.addEventListener('click', function (e) {
var el = e.target && e.target.closest ? e.target.closest('[data-bit]') : null;
if (!el) return;
var action = el.getAttribute('data-bit');
if (!action || action.indexOf('noop:') === 0) return;
e.preventDefault();
e.stopPropagation();
var box = holder();
if (!box) return;
// The nonce makes every click a distinct value, so clicking the same
// button twice still fires Gradio's change event.
var nonce = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
box.value = action + '|' + nonce;
box.dispatchEvent(new Event('input', { bubbles: true }));
}, true);
// Numeric and text fields emit on commit (blur / Enter), not per keystroke,
// so a backtest is not re-run on every digit typed.
function commit(el) {
if (!el || !el.hasAttribute('data-bit-input')) return;
var box = holder();
if (!box) return;
var nonce = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
box.value = el.getAttribute('data-bit-input') + '=' + el.value + '|' + nonce;
box.dispatchEvent(new Event('input', { bubbles: true }));
}
document.addEventListener('change', function (e) { commit(e.target); }, true);
document.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { commit(e.target); }
}, true);
})();
</script>
""".replace("__ELEM_ID__", ACTION_ELEMENT_ID).replace(
"__TRIGGER_ID__", TRIGGER_ELEMENT_ID)
@dataclass(frozen=True)
class Action:
key: str
value: str
@property
def is_noop(self) -> bool:
return self.key == "noop"
def emit(key: str, value: str = "") -> str:
"""Build the `data-bit` attribute value for a clickable element."""
if key not in ALLOWED_KEYS:
raise ValueError(f"{key!r} is not an allowed UI action")
if NONCE_SEP in str(value):
raise ValueError(f"action values may not contain {NONCE_SEP!r}")
return f"{key}:{value}"
def parse_action(raw):
"""Parse what the bridge wrote into the hidden textbox.
Returns None for anything unparseable or not on the allow-list, so a
malformed or hand-crafted payload is ignored rather than raising.
"""
if not raw or not isinstance(raw, str):
return None
payload = raw.split(NONCE_SEP, 1)[0]
if ":" not in payload:
return None
key, _, value = payload.partition(":")
key = key.strip()
if key not in ALLOWED_KEYS:
return None
return Action(key=key, value=value.strip())
def parse_pair(value: str):
"""Split a compound action value like `fast_ma=20` -> ("fast_ma", "20")."""
name, _, val = value.partition("=")
return name.strip(), val.strip()
# `demo.load(js=...)` takes a JS *function*. This is the same installer as
# BRIDGE_JS, in the form Gradio's load hook expects, and is the path that
# actually works on Hugging Face Spaces.
BRIDGE_LOAD_JS = """
() => {
if (window.__bitBridgeInstalled) return;
window.__bitBridgeInstalled = true;
function holder() {
var root = document.getElementById('__ELEM_ID__');
return root ? root.querySelector('textarea, input') : null;
}
function send(payload) {
var box = holder();
if (!box) return;
var nonce = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
// Setting .value directly does not reliably wake Svelte's binding, so the
// native setter is used and both events are dispatched...
var proto = box.tagName === 'TEXTAREA'
? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
var setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
setter.call(box, payload + '|' + nonce);
box.dispatchEvent(new Event('input', { bubbles: true }));
box.dispatchEvent(new Event('change', { bubbles: true }));
// ...and a hidden Gradio Button is then clicked. A real click on a real
// Button is the one event path Gradio always honours, so the handler fires
// even if the textbox binding did not register.
var trigger = document.getElementById('__TRIGGER_ID__');
var btn = trigger ? (trigger.tagName === 'BUTTON'
? trigger : trigger.querySelector('button')) : null;
if (btn) { setTimeout(function () { btn.click(); }, 0); }
}
document.addEventListener('click', function (e) {
var el = e.target && e.target.closest ? e.target.closest('[data-bit]') : null;
if (!el) return;
var action = el.getAttribute('data-bit');
if (!action || action.indexOf('noop:') === 0) return;
e.preventDefault();
e.stopPropagation();
send(action);
}, true);
function commit(el) {
if (!el || !el.hasAttribute || !el.hasAttribute('data-bit-input')) return;
send(el.getAttribute('data-bit-input') + '=' + el.value);
}
document.addEventListener('change', function (e) { commit(e.target); }, true);
document.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { commit(e.target); }
}, true);
}
""".replace("__ELEM_ID__", ACTION_ELEMENT_ID).replace(
"__TRIGGER_ID__", TRIGGER_ELEMENT_ID)