<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Strategy Workbench | python.financial</title>
<style>
:root{color-scheme:light;--bg:#fff;--panel:#fff;--line:#dededb;--ink:#252525;--muted:#6b6b67;--green:#39705a;--red:#a44949;--bb-line:#4f7e82;--bb-basis:#92adaf;--bb-fill:rgba(79,126,130,.07);--kc-line:#89718f;--compression-fill:rgba(91,99,105,.09);--compression-key:#bfc3c5}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.45 Arial,Helvetica,sans-serif}
button,input,select{font:inherit}
button,input[type=number],select{color:var(--ink);background:#fff;border:1px solid #bdbdb8;border-radius:2px}
button{cursor:pointer;padding:6px 10px;font-size:12px}
button:hover{background:#f3f3f0;border-color:#8b8b85}
button:disabled{cursor:wait;opacity:.55}
button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid #555;outline-offset:2px}
a{color:inherit;text-underline-offset:2px}
select,input[type=number]{padding:7px 8px;width:100%;min-width:0;height:33px}
input[type=number]{font-variant-numeric:tabular-nums}
h1,h2,h3,p{margin:0}
h1{font-size:22px;font-weight:500;letter-spacing:-.45px}
h2{font-size:13px;font-weight:600}
h3{font-size:12px}
.muted{color:var(--muted)}
.metric strong,td{font-variant-numeric:tabular-nums}
.positive{color:var(--green)}.negative{color:var(--red)}
.topbar{height:45px;padding:0 20px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);gap:16px}
.brand{font-size:14px;font-weight:600;border-left:3px solid #a56a42;padding-left:10px}
.topright{font-size:11px;color:var(--muted)}
.app{min-height:calc(100vh - 45px)}
.field{margin-bottom:17px}.field label{display:block;font-size:12px;margin-bottom:5px}
.field small{font-size:10px;display:block;margin-top:5px;color:var(--muted)}
.main{padding:20px 28px;max-width:1800px;margin:0 auto;min-width:0}
.heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:13px}
.heading p{font-size:11px;margin-top:4px}.heading-meta{display:flex;flex-direction:column;align-items:flex-end;gap:5px}.built-with{font-size:12px;color:var(--muted)}.built-with a{color:var(--ink);text-decoration:underline;text-decoration-color:#b2a18e;text-underline-offset:3px}.built-with a:hover{color:#875330}.data-summary{font-size:11px;color:var(--muted);text-align:right;padding-top:4px}
.metrics{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-bottom:17px}
.metric{padding:11px 14px;border-right:1px solid var(--line)}.metric:first-child{padding-left:0}.metric:last-child{border-right:0}
.metric span{display:block;font-size:11px;color:var(--muted);margin-bottom:4px}
.metric strong{font-size:21px;line-height:1.3;font-weight:400}
.metric small{display:block;font-size:10px;color:var(--muted);margin-top:3px}
.panel{border:1px solid var(--line);background:var(--panel);overflow:hidden;margin-bottom:14px}
.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 12px;border-bottom:1px solid var(--line)}
.legend{display:flex;flex-wrap:wrap;gap:13px;font-size:11px;color:var(--muted)}
.legend i{width:13px;height:2px;display:inline-block;vertical-align:middle;margin-right:5px}
.chart{height:470px;width:100%}.chart.small{height:160px}
.chart-note{padding:6px 12px;border-top:1px solid var(--line);font-size:10px;color:var(--muted);display:flex;align-items:center;justify-content:space-between;gap:14px}
.toolbar{display:flex;gap:6px;align-items:center}.toolbar button{font-size:10px;padding:3px 7px}
.curve-row{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:14px}.curve-row .panel{min-width:0}
.tabs{display:flex;gap:17px;padding:0 12px;border-bottom:1px solid var(--line)}
.tabs button{border:0;border-bottom:2px solid transparent;border-radius:0;background:transparent;padding:10px 0 8px;font-size:12px;color:var(--muted)}
.tabs button:hover{color:var(--ink)}.tabs button[aria-selected=true]{color:var(--ink);border-bottom-color:#a56a42}
.tab-panel{padding:14px 12px}.tab-panel[hidden]{display:none}
.table-wrap{overflow:auto;max-height:300px}
table{border-collapse:collapse;width:100%;font-size:11px;white-space:nowrap}
th{text-align:right;color:var(--muted);font-size:10px;font-weight:400;background:#f8f8f6;position:sticky;top:0}
th,td{padding:7px 10px;border-bottom:1px solid #e9e9e6;text-align:right}
th:nth-child(-n+2),td:nth-child(-n+2){text-align:left}tbody tr:hover{background:#fafaf8}
td button{font-size:10px;padding:2px 7px}
.table-heading{display:flex;justify-content:space-between;align-items:center;gap:14px;margin-bottom:11px}.table-heading p{font-size:11px}
.source{font-size:10px;color:var(--muted);margin:12px 0;line-height:1.55}
.status{font-size:10px;min-height:15px}
.error{padding:9px 12px;border:1px solid #dbbaba;background:#fff8f8;color:#8c3333;margin-bottom:13px;font-size:12px}.error[hidden]{display:none}
.grid-layout{display:grid;grid-template-columns:minmax(0,1fr) 215px;gap:22px}
.heatmap{display:grid;gap:2px;margin-top:12px;overflow:auto}
.heatmap .axis{font-size:10px;color:var(--muted);display:flex;align-items:center;justify-content:center;min-height:24px}
.heatmap button{min-width:38px;padding:8px 4px;border:1px solid #e1e1dc;border-radius:0;font-size:10px;color:#252525}
.heatmap button:hover,.heatmap button.selected{outline:2px solid #383838;outline-offset:-2px}
.grid-help p,.assumptions p{font-size:12px;color:var(--muted);margin:0 0 12px}.grid-help strong,.assumptions strong{color:var(--ink)}
.assumptions{columns:2;column-gap:28px}.assumptions p{break-inside:avoid}
.primary{background:#363636;color:#fff;border-color:#363636;font-weight:400}.primary:hover{background:#222;color:#fff}
.footer{font-size:10px;color:var(--muted);display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px 20px;padding:0 1px 8px}.timing{font-variant-numeric:tabular-nums}

.strategy-toolbar{display:flex;flex-wrap:wrap;align-items:flex-end;gap:14px 24px;margin:19px 0 17px;padding-bottom:16px;border-bottom:1px solid var(--line)}
.control{display:flex;flex-direction:column;gap:5px}.control label{font-size:11px;color:var(--muted)}
.control select,.control input{height:32px;border-color:#c9c9c2;font-size:12px}
.market-control{width:190px}.window-control{width:165px}.parameter-controls{display:flex;align-items:flex-end;gap:9px;border-left:1px solid var(--line);padding-left:24px}.parameter-control{width:150px}.parameter-value{display:flex;align-items:center;justify-content:space-between;gap:8px}.parameter-value label{font-size:11px;color:var(--muted)}.parameter-value input[type=number]{width:53px;height:27px;padding:3px 5px;font-size:12px}.parameter-control input[type=range]{display:block;width:100%;height:15px;margin:6px 0 0;accent-color:#9a7634;cursor:ew-resize}

#lookback-slider{accent-color:#626762}#bb_mult-slider{accent-color:var(--bb-line)}#kc_mult-slider{accent-color:var(--kc-line)}
.properties{position:relative;margin-left:auto}.properties summary{cursor:pointer;display:flex;align-items:center;gap:10px;list-style:none;border:1px solid #c9c9c2;border-radius:2px;padding:7px 12px;font-size:12px;height:32px;white-space:nowrap}.properties summary::-webkit-details-marker{display:none}.properties summary:after{content:" +";padding-left:12px;color:var(--muted)}.properties[open] summary:after{content:" -"}.properties summary span{font-size:10px;color:var(--muted)}.properties summary:hover{border-color:#8c8c80}
.properties-panel{position:absolute;z-index:20;right:0;top:calc(100% + 8px);width:270px;background:white;border:1px solid #b8b8ae;padding:17px;box-shadow:0 5px 15px #0000000d}.properties-panel h2{margin-bottom:17px}.properties-panel p{color:var(--muted);font-size:11px;line-height:1.5}.properties-panel .field{margin-bottom:14px}
.chart-context{font-size:11px;color:var(--muted);margin:0 0 11px}.provenance{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px;border-top:1px solid var(--line);padding-top:10px}.provenance .source{margin:0 0 8px}

.data-loader{border:1px solid var(--line);margin:16px 0 0;padding:10px 13px}.data-loader summary{cursor:pointer;font-size:12px}.data-form{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:14px;margin-top:16px}.data-form label{display:block;font-size:11px;color:var(--muted);margin-bottom:5px}.data-form input{width:100%;height:33px;padding:6px;border:1px solid #bdbdb8;border-radius:2px}.data-form .data-note{grid-column:1/-1;font-size:11px;color:var(--muted)}.data-form [hidden]{display:none}.research-line{display:flex;flex-wrap:wrap;align-items:center;gap:12px;margin:-4px 0 17px;font-size:11px;color:var(--muted)}.research-line input[type=checkbox]{accent-color:#626762}.research-line input[type=number]{width:52px;height:28px;padding:4px}.research-line input[type=date]{font:inherit;border:1px solid #c9c9c2;padding:5px}.research-line label{display:flex;align-items:center;gap:7px}.comparison{margin:15px 0}.comparison .source{font-size:11px}.comparison table{min-width:650px}
@media(max-width:760px){.data-form{grid-template-columns:1fr 1fr}.data-form .data-note{grid-column:1/-1}}
@media(min-width:1700px){.chart{height:510px}.chart.small{height:180px}}
@media(max-width:1100px){.main{padding:18px 20px}.strategy-toolbar{gap:13px 18px}.parameter-controls{padding-left:18px}.metric{padding:10px}.metric strong{font-size:19px}.grid-layout{grid-template-columns:1fr}.curve-row{grid-template-columns:1fr 1fr}.properties{margin-left:0}}
@media(max-width:760px){.topbar{padding:0 13px}.main{padding:16px 12px}.heading{flex-wrap:wrap;gap:6px}.data-summary{text-align:left}.heading-meta{align-items:flex-start}.strategy-toolbar{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px;margin-top:15px}.market-control,.window-control{width:auto}.parameter-controls{padding-left:0;border:0;grid-column:1/-1;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));width:100%}.parameter-control{width:auto}.parameter-value{flex-wrap:wrap;gap:4px}.parameter-value label{font-size:10px}.properties{grid-column:1/-1;justify-self:start;margin-left:0}.properties-panel{left:0;right:auto;width:270px;max-width:calc(100vw - 24px)}.metrics{grid-template-columns:repeat(3,minmax(0,1fr))}.metric{padding:10px 8px}.metric:first-child{padding-left:8px}.metric:nth-child(3){border-right:0}.metric strong{font-size:18px}.chart{height:330px}.chart-note{flex-wrap:wrap}.curve-row{grid-template-columns:1fr}.assumptions{columns:1}.grid-layout{gap:10px}.panel-head{flex-wrap:wrap}.heatmap{max-width:100%}}
</style>
<script src="/charts.js" defer></script>
</head>
<body>
<header class="topbar"><div class="brand">python.financial</div><div class="topright" id="connection">Connecting...</div></header>
<div class="app">
<main class="main">
  <div class="heading"><div><h1>Volatility squeeze</h1><p class="muted" id="run-summary">Loading...</p></div><div class="heading-meta"><span class="built-with">Built with <a href="https://vectorbt.pro/" target="_blank" rel="noopener">VectorBT PRO</a></span><p class="data-summary" id="data-summary"></p></div></div>

  <details class="data-loader" id="data-loader">
    <summary>Historical market data <span class="muted">/ choose a provider and download prices</span></summary>
    <form id="data-form" class="data-form" autocomplete="off">
      <div><label for="provider">Provider</label><select id="provider" required></select></div>
      <div><label for="symbol">Symbol</label><input id="symbol" value="BTC-USD" maxlength="40" required></div>
      <div><label for="timeframe">Timeframe</label><select id="timeframe" required></select></div>
      <div><label for="history-start">Start (UTC, inclusive)</label><input id="history-start" type="date" value="2018-01-01" required></div>
      <div><label for="history-end">End (UTC, exclusive)</label><input id="history-end" type="date" value="2026-01-01" required></div>
      <div id="exchange-field" hidden><label for="exchange">Exchange</label><select id="exchange"></select></div>
      <div id="feed-field" hidden><label for="feed">Stock feed</label><select id="feed"><option value="iex">IEX</option><option value="sip">SIP (entitlement required)</option></select></div>
      <div id="key-field" hidden><label for="api-key">API key</label><input id="api-key" type="password" autocomplete="off" maxlength="512"></div>
      <div id="secret-field" hidden><label for="api-secret">API secret</label><input id="api-secret" type="password" autocomplete="off" maxlength="512"></div>
      <p class="data-note" id="provider-note"></p>
      <div><button type="submit" class="primary" id="load-history">Download prices</button></div>
      <p class="data-note" role="status" id="history-status">Prices are downloaded once. Strategy controls reuse the loaded data. Keys are used for this download and cleared from the form.</p>
    </form>
  </details>

  <div class="strategy-toolbar" role="group" aria-label="Strategy settings">
    <div class="control market-control"><label for="dataset">Dataset</label><select id="dataset"></select></div>
    <div class="control window-control"><label for="period">Test window</label><select id="period"><option value="all">All loaded bars</option><option value="2160">Last 2,160 bars</option><option value="720">Last 720 bars</option><option value="development">Before split date</option><option value="holdout">From split date</option></select></div>
    <div class="parameter-controls" role="group" aria-label="Squeeze parameters">
      <div class="parameter-control"><div class="parameter-value"><label for="lookback">Lookback</label><input id="lookback" aria-label="Shared lookback in bars" type="number" min="5" max="100" step="1" value="20" required></div><input id="lookback-slider" type="range" min="5" max="100" step="1" value="20" aria-label="Adjust shared lookback in bars"></div>
      <div class="parameter-control"><div class="parameter-value"><label for="bb_mult">Bollinger width</label><input id="bb_mult" aria-label="Bollinger standard deviation multiplier" type="number" min="1" max="3.5" step="0.1" value="2" required></div><input id="bb_mult-slider" type="range" min="1" max="3.5" step="0.1" value="2" aria-label="Adjust Bollinger width"></div>
      <div class="parameter-control"><div class="parameter-value"><label for="kc_mult">Keltner width</label><input id="kc_mult" aria-label="Keltner ATR multiplier" type="number" min="0.5" max="4" step="0.05" value="1.5" required></div><input id="kc_mult-slider" type="range" min="0.5" max="4" step="0.05" value="1.5" aria-label="Adjust Keltner width"></div>
    </div>
    <details class="properties">
      <summary>Costs and capital <span id="cost-summary">5 bps fee</span></summary>
      <div class="properties-panel">
        <h2>Costs and capital</h2>
        <div class="field"><label for="fees">Fee (bps per side)</label><input id="fees" type="number" min="0" max="50" step="1" value="5" required></div>
        <div class="field"><label for="slippage">Slippage (bps per side)</label><input id="slippage" type="number" min="0" max="30" step="1" value="2" required></div>
        <div class="field"><label for="allocation">Cash allocation (%)</label><input id="allocation" type="number" min="5" max="100" step="5" value="95" required></div>
        <p>1 bp = 0.01%. Allocation is the share of available cash used on entry. Each test starts flat with 10,000 account units.</p>
      </div>
    </details>
  </div>
  <div class="research-line">
    <label><input type="checkbox" id="rsi_filter"> RSI(14) entry filter</label>
    <label for="rsi_threshold">RSI above <input type="number" id="rsi_threshold" min="1" max="99" step="1" value="55" required></label>
    <label for="split">Evaluation split (UTC) <input id="split" type="date" value="2024-07-01" required></label>
    <button id="compare">Compare original vs RSI filter</button><span id="compare-status" role="status"></span>
  </div>
  <div id="error" role="alert" class="error" hidden></div>
  <section class="panel" aria-label="Price chart and trade markers">
    <div class="panel-head"><h2 id="chart-title">Price / squeeze</h2><div class="legend"><span><i style="background:var(--bb-line)"></i>Bollinger Bands</span><span><i style="background:var(--kc-line)"></i>Keltner Channel</span><span><i style="background:var(--compression-key);height:8px"></i>Compression</span><span class="positive">Buy</span><span class="negative">Sell</span><span id="blocked-legend" hidden>RSI blocked</span></div></div>
    <div id="price-chart" class="chart" role="img" aria-label="Interactive candlestick chart with shaded volatility bands, squeeze compression and fill markers, above a synchronized momentum histogram"></div>
    <div class="chart-note"><span id="ohlc-readout">Drag to pan / scroll to zoom / markers show fills.</span><div class="toolbar"><button id="fit-chart">Full history</button><button id="recent-chart">Recent 250 bars</button></div></div>
    <div class="chart-note"><span id="momentum-readout">Momentum / fitted detrended price</span><span>Zero-line dots: dark = compressed / ochre = release / gray = outside squeeze</span></div>
  </section>
  <p class="chart-context">Buy a squeeze release with positive momentum. Exit when momentum crosses zero downward. Decisions at completed closes, fills at the next open.</p>
  <div class="metrics" aria-label="Strategy results" id="metrics">
    <div class="metric"><span>Net return</span><strong id="return">--</strong><small id="profit">After trading costs</small></div>
    <div class="metric"><span>Max drawdown</span><strong id="drawdown">--</strong><small>From starting cash or later peak</small></div>
    <div class="metric"><span>Closed trades</span><strong id="trades">--</strong><small id="position">Long / flat</small></div>
    <div class="metric"><span>Win rate</span><strong id="win-rate">--</strong><small>Closed trades, after fees</small></div>
    <div class="metric"><span>Profit factor</span><strong id="profit-factor">--</strong><small>Closed gross wins / gross losses</small></div>
  </div>
  <div class="curve-row">
    <section class="panel"><div class="panel-head"><h2>Account value</h2><div class="legend"><span><i style="background:var(--green)"></i>Strategy</span><span><i style="background:#92928d"></i>Buy &amp; hold</span></div></div><div id="equity-chart" class="chart small" role="img" aria-label="Strategy equity and cost-adjusted buy-and-hold benchmark"></div></section>
    <section class="panel"><div class="panel-head"><h2>Drawdown</h2><span class="muted" style="font-size:11px">% below prior peak</span></div><div id="drawdown-chart" class="chart small" role="img" aria-label="Account drawdown over the evaluation window"></div></section>
  </div>
  <section class="panel comparison" id="comparison" hidden aria-labelledby="comparison-title">
    <h2 id="comparison-title">Original rule vs RSI entry filter</h2>
    <p class="source" id="comparison-context"></p>
    <div class="table-wrap"><table><thead><tr><th>PERIOD / UTC</th><th>RULE</th><th>NET RETURN</th><th>MAX DRAWDOWN</th><th>CLOSED TRADES</th><th>BLOCKED ENTRY SIGNALS</th></tr></thead><tbody id="comparison-body"></tbody></table></div>
    <p class="source">Both periods start flat with the same cash and costs. Earlier prices warm indicators. No position crosses the split. A date split does not prove robustness after repeated testing.</p>
  </section>
  <section class="panel">
    <div class="tabs" role="tablist" aria-label="Research detail"><button id="tab-trades" role="tab" aria-selected="true" aria-controls="panel-trades" data-tab="trades">List of trades</button><button id="tab-grid" role="tab" aria-selected="false" aria-controls="panel-grid" data-tab="grid" tabindex="-1">Parameter map</button><button id="tab-assumptions" role="tab" aria-selected="false" aria-controls="panel-assumptions" data-tab="assumptions" tabindex="-1">Assumptions</button></div>
    <div id="panel-trades" class="tab-panel" role="tabpanel" aria-labelledby="tab-trades"><div class="table-heading"><p class="muted" id="trade-summary">Loading closed trades...</p><button id="export-trades">Export all closed trades</button></div><div class="table-wrap"><table><thead><tr><th>ENTRY (UTC)</th><th>EXIT (UTC)</th><th>ENTRY PRICE</th><th>EXIT PRICE</th><th>NET P&amp;L</th><th>RETURN</th><th>CHART</th></tr></thead><tbody id="trade-body"></tbody></table></div></div>
    <div id="panel-grid" class="tab-panel" role="tabpanel" aria-labelledby="tab-grid" hidden><div class="grid-layout"><div><h2>Net return by lookback and Keltner width</h2><p class="source" id="map-context">Columns: lookback in bars. Rows: Keltner ATR multiplier. Cell: net return (%).</p><div id="heatmap" class="heatmap"></div></div><div class="grid-help"><p>165 configurations. Bollinger width is held at the current value, with the same data, window, costs, and allocation.</p><button id="run-grid" class="primary">Run parameter map</button><p id="grid-status" class="source" aria-live="polite">The map is calculated on request.</p><p>Click a cell to load its settings. Look for broad regions, then inspect the trades. This is in-sample exploration.</p></div></div></div>
    <div id="panel-assumptions" class="tab-panel assumptions" role="tabpanel" aria-labelledby="tab-assumptions" hidden>
      <p><strong>Execution.</strong> Signals use completed bars. Market orders execute at the following bar's open. The last bar cannot create a fill without another bar. One long position at a time, no leverage, shorting, stops, or partial-fill model.</p>
      <p><strong>Costs.</strong> Fee and slippage inputs are basis points per side. One basis point is 0.01%. Slippage moves buys up and sells down. Cash allocation is the fraction of available cash used on entry, including fees.</p>
      <p><strong>Accounting.</strong> Starting cash is 10,000 account-currency units. Equity includes idle cash and any open position marked at each close. Return and drawdown include open positions. Win rate and profit factor use closed trades only. An undefined profit factor displays n/a.</p>
      <p><strong>Benchmark.</strong> Buy at the first evaluation bar's open, with the same initial cash allocation, fees, and slippage, then hold. The final position is marked at the last close. Neither benchmark nor strategy forces a final liquidation.</p>
      <p><strong>Entry filter.</strong> Optional RSI(14), Wilder smoothing. Entry requires RSI strictly above the displayed threshold at the completed signal bar. Exits stay unchanged. The parameter map holds this filter fixed.</p><p><strong>Split comparison.</strong> Both rules use identical parameters and costs. Each side starts flat, uses earlier data only for indicator warmup, and ends marked to market. At least 100 loaded bars are required on each side. The date split is a comparison tool, not protection against repeated tuning.</p><p><strong>Data and warmup.</strong> Demo series are synthetic. For CSV input, supply completed conventional OHLC bars in account-currency units. Timestamps label bars, not when their close became knowable. Naive CSV timestamps are interpreted as UTC. Gaps are not filled.</p>
      <p><strong>Indicator.</strong> Shared lookback for all components. Bollinger = close SMA +/- width times population standard deviation. Keltner = close EMA +/- width times Wilder ATR, both seeded by VBT with adjust=False. Compression requires both Bollinger boundaries strictly inside Keltner. Momentum fits a line over close minus the average of the rolling high/low midpoint and close SMA, evaluated at the current bar. Momentum needs 2 x lookback - 1 bars. This is our specified variant, not a claim of TradingView script parity.</p>
      <p><strong>Evaluation.</strong> Each selected window starts flat. Indicators use the earlier loaded history. A release requires a valid momentum observation on both bars. The map holds Bollinger width fixed and tests lookback x Keltner width. Keep an untouched test period outside this exploration.</p>
      <p><strong>Chart display.</strong> Price candles, bands and momentum retain all bars. Equity lines use at most 1,400 regularly spaced observations plus the worst drawdown observation. Metrics and exported trades use the complete simulation.</p>
      <p><strong>Timing.</strong> The server warms the engine at startup. Simulation timing includes portfolio construction and account-value calculation. Response timing includes the request and JSON download. Update timing also includes updating the chart data and trade table. It does not measure final canvas paint. Network/data loading and first-run warmup are separate.</p>
    </div>
  </section>
  <div class="provenance"><span class="source" id="source">Loading dataset...</span><span class="source" id="engine-version"></span></div>
  <div class="footer"><span id="status" class="status" role="status" aria-live="polite">Connecting...</span><span class="timing">Simulation <span id="engine-time">--</span> / <span id="response-time">Waiting for results</span></span><span>Charts by <a href="https://www.tradingview.com/" target="_blank" rel="noopener">TradingView Lightweight Charts</a> / calculations by <a href="https://vectorbt.pro/" target="_blank" rel="noopener">VectorBT PRO</a></span></div>
</main>
</div>
<script>
"use strict";
window.addEventListener("DOMContentLoaded", () => {
const $ = id => document.getElementById(id);
const indicatorNames = ["lookback", "bb_mult", "kc_mult"];
const controlNames = [...indicatorNames, "fees", "slippage", "allocation", "rsi_threshold"];
const colors=getComputedStyle(document.documentElement);
const chartColor=name=>colors.getPropertyValue(`--${name}`).trim();
const money = value => value.toLocaleString("en-US", {minimumFractionDigits:2,maximumFractionDigits:2});
const pct = value => value === null ? "n/a" : `${(value*100).toFixed(2)}%`;
const date = timestamp => timestamp.replace("T", " ").slice(0,16);
const datasetName = item => item.id === "trend" ? "Trending sample" : item.id === "range" ? "Ranging sample" : item.name;
let meta, market, latest, gridResult, gridKey, marketKey, generation=0, running=false, pending=false, timer;
let priceChart, equityChart, ddChart, candles, bandFill, momentumBars, squeezeDots, markers, equityLine, holdLine, ddLine;
let dataChanged=false;

function settings(){return {dataset:$("dataset").value,period:$("period").value,rsi_filter:$("rsi_filter").checked,split:$("split").value,...Object.fromEntries(controlNames.map(key=>[key,Number($(key).value)]))};}
function mapKey(s){return JSON.stringify([s.dataset,s.period,s.bb_mult,s.fees,s.slippage,s.allocation,s.rsi_filter,s.rsi_threshold,s.split]);}
function showError(message){$("error").textContent=message;$("error").hidden=!message;}
async function post(route, body){const response=await fetch(route,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});const result=await response.json();if(!response.ok)throw new Error(result.error);return result;}

// A chart primitive fills the envelope and compression intervals behind the candles.
// It draws only visible bars and follows the chart's own time/price coordinates.
class SqueezeFill {
  constructor(){this.data=null;this.views=[{zOrder:()=>"bottom",renderer:()=>({draw:target=>this.draw(target)})}];}
  attached({chart,series,requestUpdate}){this.chart=chart;this.series=series;this.requestUpdate=requestUpdate;}
  detached(){this.data=null;this.chart=null;this.series=null;this.requestUpdate=null;}
  paneViews(){return this.views;}
  setData(data){this.data=data;this.requestUpdate?.();}
  autoscaleInfo(start,end){
    if(!this.data)return null;
    let min=Infinity,max=-Infinity;
    for(let row=Math.max(0,Math.floor(start));row<=Math.min(this.data.bb_upper.length-1,Math.ceil(end));row++){
      for(const key of ["bb_upper","bb_lower","kc_upper","kc_lower"]){const value=this.data[key][row];if(value!==null){min=Math.min(min,value);max=Math.max(max,value);}}
    }
    return Number.isFinite(min)?{priceRange:{minValue:min,maxValue:max}}:null;
  }
  draw(target){
    if(!this.data||!this.chart)return;
    const range=this.chart.timeScale().getVisibleLogicalRange();if(!range)return;
    const first=Math.max(0,Math.floor(range.from)-1),last=Math.min(market.time.length-1,Math.ceil(range.to)+1);
    const points=[];
    for(let row=first;row<=last;row++){
      if(this.data.bb_upper[row]===null||this.data.bb_lower[row]===null)continue;
      const x=this.chart.timeScale().logicalToCoordinate(row),upper=this.series.priceToCoordinate(this.data.bb_upper[row]),lower=this.series.priceToCoordinate(this.data.bb_lower[row]);
      if(x!==null&&upper!==null&&lower!==null)points.push({x,upper,lower,row});
    }
    target.useMediaCoordinateSpace(({context:ctx,mediaSize})=>{
      if(!points.length)return;
      ctx.save();ctx.beginPath();ctx.moveTo(points[0].x,points[0].upper);
      for(const p of points)ctx.lineTo(p.x,p.upper);
      for(let i=points.length-1;i>=0;i--)ctx.lineTo(points[i].x,points[i].lower);
      ctx.closePath();ctx.fillStyle=chartColor("bb-fill");ctx.fill();
      const spacing=Math.max(.5,(this.chart.timeScale().logicalToCoordinate(first+1)??points[0].x+1)-(this.chart.timeScale().logicalToCoordinate(first)??points[0].x));
      ctx.fillStyle=chartColor("compression-fill");
      for(const p of points)if(this.data.compressed[p.row])ctx.fillRect(p.x-spacing/2,0,spacing,mediaSize.height);
      // Render the five overlays together, without rebuilding five full chart series.
      for(const key of ["bb_upper","bb_lower","basis","kc_upper","kc_lower"]){
        const keltner=key.startsWith("kc");ctx.strokeStyle=chartColor(keltner?"kc-line":key==="basis"?"bb-basis":"bb-line");
        ctx.lineWidth=1;ctx.setLineDash(keltner?[3,3]:[]);ctx.beginPath();let started=false;
        for(const p of points){const value=this.data[key][p.row];if(value===null){started=false;continue;}const y=this.series.priceToCoordinate(value);if(y===null)continue;if(!started){ctx.moveTo(p.x,y);started=true;}else ctx.lineTo(p.x,y);}
        ctx.stroke();
      }
      ctx.restore();
    });
  }
}

function charts(){
  const options={autoSize:true,layout:{background:{type:"solid",color:"#ffffff"},textColor:"#777772",fontSize:10,attributionLogo:true},grid:{vertLines:{color:"#f0f0ed"},horzLines:{color:"#f0f0ed"}},rightPriceScale:{borderColor:"#dededb",minimumWidth:70},timeScale:{borderColor:"#dededb",timeVisible:true,secondsVisible:false},crosshair:{mode:LightweightCharts.CrosshairMode.Normal}};
  priceChart=LightweightCharts.createChart($("price-chart"),options);
  candles=priceChart.addSeries(LightweightCharts.CandlestickSeries,{upColor:"#39705a",downColor:"#a44949",wickUpColor:"#39705a",wickDownColor:"#a44949",borderVisible:false});
  bandFill=new SqueezeFill();candles.attachPrimitive(bandFill);
  momentumBars=priceChart.addSeries(LightweightCharts.HistogramSeries,{priceLineVisible:false,lastValueVisible:false,priceFormat:{type:"price",precision:3,minMove:.001}},1);
  squeezeDots=priceChart.addSeries(LightweightCharts.LineSeries,{color:"#b8b8b2",lineVisible:false,pointMarkersVisible:true,pointMarkersRadius:2,priceLineVisible:false,lastValueVisible:false,crosshairMarkerVisible:false},1);
  momentumBars.createPriceLine({price:0,color:"#c9c9c2",lineWidth:1,lineStyle:0,axisLabelVisible:false});
  priceChart.panes()[1].setHeight(125);
  markers=LightweightCharts.createSeriesMarkers(candles,[]);
  priceChart.subscribeCrosshairMove(event=>{
    const bar=event.seriesData.get(candles);
    $("ohlc-readout").textContent=bar&&bar.open!==undefined?`O ${bar.open.toFixed(2)} / H ${bar.high.toFixed(2)} / L ${bar.low.toFixed(2)} / C ${bar.close.toFixed(2)}`:"Drag to pan / scroll to zoom / markers show fills.";
    const momentum=event.seriesData.get(momentumBars);
    $("momentum-readout").textContent=momentum&&momentum.value!==undefined?`Momentum ${momentum.value.toFixed(3)} / ${momentum.value>=0?"positive":"negative"}`:"Momentum / fitted detrended price";
  });
  equityChart=LightweightCharts.createChart($("equity-chart"),options);
  equityLine=equityChart.addSeries(LightweightCharts.LineSeries,{color:"#39705a",lineWidth:2,priceLineVisible:false});
  holdLine=equityChart.addSeries(LightweightCharts.LineSeries,{color:"#92928d",lineWidth:1,lineStyle:2,priceLineVisible:false,lastValueVisible:false});
  ddChart=LightweightCharts.createChart($("drawdown-chart"),options);
  ddLine=ddChart.addSeries(LightweightCharts.LineSeries,{color:"#a44949",lineWidth:1,priceLineVisible:false,priceFormat:{type:"custom",formatter:v=>`${v.toFixed(1)}%`}});
  $("fit-chart").onclick=()=>priceChart.timeScale().fitContent();
  $("recent-chart").onclick=recent;
}
function recent(){if(market)priceChart.timeScale().setVisibleLogicalRange({from:Math.max(0,market.time.length-250),to:market.time.length+4});}
function inspectTrade(trade){const padding=Math.max(20,Math.round((trade.exit-trade.entry)*.2));priceChart.timeScale().setVisibleLogicalRange({from:Math.max(0,trade.entry-padding),to:Math.min(market.time.length,trade.exit+padding)});$("price-chart").scrollIntoView({behavior:"smooth",block:"center"});}
function timeAt(row){return Math.floor(Date.parse(market.time[row])/1000);}
function updateMarket(s){
  const item=meta.datasets.find(item=>item.id===s.dataset);
  $("source").textContent=item.source;
  const dateLabel=stamp=>new Date(stamp).toLocaleDateString("en-GB",{day:"2-digit",month:"short",year:"numeric",timeZone:"UTC"});
  $("data-summary").textContent=`${dateLabel(market.time[0])} - ${dateLabel(market.time.at(-1))} / UTC`;
  $("chart-title").textContent=datasetName(item);
  candles.setData(market.time.map((_,i)=>({time:timeAt(i),open:market.open[i],high:market.high[i],low:market.low[i],close:market.close[i]})));
  recent();
  equityChart.timeScale().fitContent();ddChart.timeScale().fitContent();
}
function render(result){
  latest=result;
  const m=result.metrics;
  $("return").textContent=pct(m.return);$("return").className=m.return>=0?"positive":"negative";
  $("profit").textContent=`${m.profit>=0?"+":""}${money(m.profit)} account units`;
  $("drawdown").textContent=pct(m.drawdown);$("drawdown").className="negative";
  $("trades").textContent=m.trades;$("position").textContent=m.open_position?"One position still open":"No open position";
  $("win-rate").textContent=pct(m.win_rate);$("profit-factor").textContent=m.profit_factor===null?"n/a":m.profit_factor.toFixed(2);
  $("engine-time").textContent=`${result.engine_ms.toFixed(1)} ms`;
  $("run-summary").textContent=`${result.settings.lookback} bars / BB ${result.settings.bb_mult} / KC ${result.settings.kc_mult} / ${result.bars.toLocaleString()} bars / ${result.settings.rsi_filter?`RSI(14) > ${result.settings.rsi_threshold}`:"original rule"} / next-open execution`;
  const indicator=result.indicator;
  bandFill.setData(indicator);
  momentumBars.setData(indicator.momentum.map((value,row)=>{
    if(value===null)return {time:timeAt(row)};
    const previous=indicator.momentum[row-1];const increasing=previous!==null&&previous!==undefined&&value>previous;
    return {time:timeAt(row),value,color:value>=0?(increasing?"#39705a":"#91b09d"):(increasing?"#d0a49c":"#a44949")};
  }));
  squeezeDots.setData(indicator.momentum.map((value,row)=>value===null?{time:timeAt(row)}:{time:timeAt(row),value:0,color:indicator.released[row]?"#a56a42":indicator.compressed[row]?"#343c36":"#c3c3bc"}));
  $("blocked-legend").hidden=!result.settings.rsi_filter;
  const fillMarkers=result.fills.map(fill=>({time:timeAt(fill.row),position:fill.side==="Buy"?"belowBar":"aboveBar",color:fill.side==="Buy"?"#39705a":"#a44949",shape:fill.side==="Buy"?"arrowUp":"arrowDown",text:`${fill.side} ${fill.price.toFixed(2)}`}));
  const blockedMarkers=result.settings.rsi_filter?indicator.blocked.flatMap((blocked,row)=>blocked?[{time:timeAt(row),position:"aboveBar",color:chartColor("kc-line"),shape:"circle",text:`RSI blocked ${indicator.rsi[row].toFixed(1)}`}]:[]):[];
  markers.setMarkers([...fillMarkers,...blockedMarkers].sort((a,b)=>a.time-b.time));
  const curve=result.curve;
  equityLine.setData(curve.row.map((row,i)=>({time:timeAt(row),value:curve.equity[i]})));
  holdLine.setData(curve.row.map((row,i)=>({time:timeAt(row),value:curve.benchmark[i]})));
  ddLine.setData(curve.row.map((row,i)=>({time:timeAt(row),value:curve.drawdown[i]})));
  if(dataChanged){equityChart.timeScale().fitContent();ddChart.timeScale().fitContent();dataChanged=false;}
  $("trade-summary").textContent=`${result.trades.length} closed trades / ${money(m.fees)} total fees / latest 100 shown`;
  const body=$("trade-body");body.replaceChildren();
  result.trades.slice(-100).reverse().forEach(trade=>{
    const row=document.createElement("tr");
    const values=[date(market.time[trade.entry]),date(market.time[trade.exit]),money(trade.entry_price),money(trade.exit_price),money(trade.pnl),pct(trade.return)];
    values.forEach((value,i)=>{const cell=document.createElement("td");cell.textContent=value;if(i>=4)cell.className=trade.pnl>=0?"positive":"negative";row.append(cell);});
    const cell=document.createElement("td");const button=document.createElement("button");button.textContent="Inspect";button.onclick=()=>inspectTrade(trade);cell.append(button);row.append(cell);body.append(row);
  });
  if(!result.trades.length){const row=body.insertRow();const cell=row.insertCell();cell.colSpan=7;cell.textContent="No closed trades in this window. Any open position is included in equity.";}
  if(gridResult&&gridKey===mapKey(result.settings))drawGrid();
}

function schedule(){
  $("comparison").hidden=true;$("compare-status").textContent="";
  for(const name of indicatorNames) $(`${name}-slider`).value=$(name).value;
  $("cost-summary").textContent=`${$("fees").value} bps fee / ${$("slippage").value} bps slip`;
  generation++;pending=true;$("status").textContent="Recalculating...";
  const s=settings();
  if(gridResult&&gridKey!==mapKey(s)){$("heatmap").replaceChildren();gridResult=null;$("grid-status").textContent="Fixed settings changed. Run the map again.";}
  $("map-context").textContent=`Columns: lookback in bars. Rows: Keltner ATR multiplier. Cell: net return (%). Bollinger width fixed at ${s.bb_mult}. ${s.rsi_filter?`RSI(14) > ${s.rsi_threshold}`:"RSI filter off"}.`;
  // Coalesce input without postponing every request until the pointer stops.
  // One request runs at a time. Its completion picks up the newest settings.
  if(!running&&!timer)timer=setTimeout(()=>{timer=null;run();},40);
}
async function run(){
  if(running||!pending)return;
  pending=false;running=true;const ticket=generation;const s=settings();const started=performance.now();
  try{
    showError("");
    if(controlNames.some(name=>!$(name).checkValidity()))throw new Error("Use values within the labeled ranges and steps.");
    const key=`${s.dataset}:${s.period}:${s.split}`;
    let newMarket=market;
    if(key!==marketKey)newMarket=await post("/api/market",s);
    const result=await post("/api/backtest",s);const responseMs=performance.now()-started;
    // Parameter previews may finish during a drag. Keep showing real completed
    // runs, but never apply a response for a different market or cost context.
    const current=settings();
    if(["dataset","period","fees","slippage","allocation","rsi_filter","rsi_threshold","split"].some(name=>s[name]!==current[name]))return;
    if(key!==marketKey){market=newMarket;marketKey=key;dataChanged=true;updateMarket(s);}
    render(result);
    const updateMs=performance.now()-started;
    $("response-time").textContent=`Response ${responseMs.toFixed(0)} ms / update ${updateMs.toFixed(0)} ms`;
    $("status").textContent=ticket!==generation?"Recalculating...":`Updated / ${result.bars.toLocaleString()} bars / ${result.fills.length} fills / ${updateMs.toFixed(0)} ms request + chart data`;
    window.workbenchLastRun={settings:s,engineMs:result.engine_ms,responseMs,updateMs};
  }catch(error){if(ticket===generation){showError(error.message);$("status").textContent="Settings not applied. Previous results remain visible.";}}
  finally{running=false;if(pending)run();}
}

function drawGrid(){
  const target=$("heatmap");target.replaceChildren();target.style.gridTemplateColumns=`36px repeat(${gridResult.lookback.length},minmax(38px,1fr))`;
  const axis=text=>{const item=document.createElement("div");item.className="axis";item.textContent=text;target.append(item);};
  axis("KC / n");gridResult.lookback.forEach(axis);
  const valid=gridResult.returns.flat().filter(v=>v!==null);const scale=Math.max(1,...valid.map(Math.abs));
  gridResult.kc_mult.forEach((kc,row)=>{
    axis(kc);gridResult.lookback.forEach((lookback,col)=>{
      const value=gridResult.returns[row][col];
      const button=document.createElement("button");button.textContent=value.toFixed(1);
      button.style.background=value>=0?`rgba(69,126,92,${.07+.32*Math.abs(value)/scale})`:`rgba(175,83,78,${.07+.32*Math.abs(value)/scale})`;
      button.classList.toggle("selected",latest.settings.lookback===lookback&&latest.settings.kc_mult===kc);
      button.setAttribute("aria-label",`Lookback ${lookback}, Keltner ${kc}, Bollinger ${gridResult.bb_mult}, net return ${value.toFixed(2)} percent`);
      button.onclick=()=>{$("lookback").value=lookback;$("kc_mult").value=kc;schedule();};target.append(button);
    });
  });
}
$("run-grid").onclick=async()=>{
  const button=$("run-grid");const s=settings();const key=mapKey(s);button.disabled=true;$("grid-status").textContent="Running the parameter batch...";
  try{const result=await post("/api/grid",s);if(key!==mapKey(settings())){$("grid-status").textContent="Settings changed during calculation. Run again.";return;}gridResult=result;gridKey=key;drawGrid();$("grid-status").textContent=`${result.count} configurations / ${result.compute_ms.toFixed(0)} ms server calculation / click a cell to inspect`;}
  catch(error){showError(error.message);$("grid-status").textContent="Map failed.";}
  finally{button.disabled=false;}
};

document.addEventListener("click",event=>{const properties=document.querySelector(".properties");if(properties.open&&!properties.contains(event.target))properties.open=false;});
document.addEventListener("keydown",event=>{if(event.key==="Escape"){const properties=document.querySelector(".properties");if(properties.open){properties.open=false;properties.querySelector("summary").focus();}}});

const tabs=[...document.querySelectorAll("[role=tab]")];
function selectTab(button){tabs.forEach(tab=>{const selected=tab===button;tab.setAttribute("aria-selected",selected);tab.tabIndex=selected?0:-1;$(`panel-${tab.dataset.tab}`).hidden=!selected;});}
tabs.forEach((tab,index)=>{tab.onclick=()=>selectTab(tab);tab.onkeydown=event=>{let next=index;if(event.key==="ArrowRight")next=(index+1)%tabs.length;else if(event.key==="ArrowLeft")next=(index+tabs.length-1)%tabs.length;else if(event.key==="Home")next=0;else if(event.key==="End")next=tabs.length-1;else return;event.preventDefault();selectTab(tabs[next]);tabs[next].focus();};});
$("export-trades").onclick=()=>{
  if(!latest)return;
  const rows=[["entry_utc","exit_utc","entry_price","exit_price","net_pnl","net_return"],...latest.trades.map(t=>[market.time[t.entry],market.time[t.exit],t.entry_price,t.exit_price,t.pnl,t.return])];
  const blob=new Blob([rows.map(row=>row.join(",")).join("\n")],{type:"text/csv"});const url=URL.createObjectURL(blob);const link=document.createElement("a");link.href=url;link.download="closed-trades.csv";link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
};
function populateDatasets(){const selected=$("dataset").value;$("dataset").replaceChildren();meta.datasets.forEach(item=>$("dataset").add(new Option(datasetName(item),item.id)));if(meta.datasets.some(item=>item.id===selected))$("dataset").value=selected;}
function providerChanged(){
  const provider=meta.providers.find(item=>item.id===$("provider").value);
  $("symbol").value=provider.symbol;$("timeframe").replaceChildren();provider.timeframes.forEach(t=>$("timeframe").add(new Option(t,t)));
  $("provider-note").textContent=provider.note;
  $("exchange-field").hidden=provider.id!=="ccxt";$("feed-field").hidden=provider.id!=="alpaca";
  for(const id of ["key-field","secret-field"])$(id).hidden=!provider.credentials;
  $("api-key").value="";$("api-secret").value="";
  $("api-key").required=provider.id==="alpaca";$("api-secret").required=provider.id==="alpaca";
}
$("provider").onchange=providerChanged;
$("data-form").onsubmit=async event=>{
  event.preventDefault();const button=$("load-history");button.disabled=true;$("history-status").textContent="Downloading historical prices... Previous charts remain visible.";
  try{
    showError("");const result=await post("/api/history",{provider:$("provider").value,symbol:$("symbol").value,timeframe:$("timeframe").value,start:$("history-start").value,end:$("history-end").value,exchange:$("exchange").value,feed:$("feed").value,api_key:$("api-key").value,api_secret:$("api-secret").value});
    meta=result.metadata;populateDatasets();marketKey=null;$("dataset").value=result.dataset;$("period").value="all";schedule();
    const item=meta.datasets.find(item=>item.id===result.dataset);$("history-status").textContent=`Loaded ${item.bars.toLocaleString()} bars. ${item.source}`;
  }catch(error){$("history-status").textContent=error.message;showError(error.message);}
  finally{button.disabled=false;$("api-key").value="";$("api-secret").value="";}
};
$("compare").onclick=async()=>{
  const s=settings();const key=JSON.stringify(s);$("compare").disabled=true;$("compare-status").textContent="Comparing both rules before and after the split...";
  try{
    showError("");const result=await post("/api/compare",s);if(key!==JSON.stringify(settings())){$("compare-status").textContent="Settings changed. Compare again.";return;}
    $("comparison-body").replaceChildren();
    result.rows.forEach(item=>{const row=document.createElement("tr");const name=item.settings.period==="development"?"Before split":"From split";[`${name}: ${item.first_bar.slice(0,10)} to ${item.last_bar.slice(0,10)}`,item.settings.rsi_filter?`RSI(14) > ${result.threshold}`:"Original",pct(item.metrics.return),pct(item.metrics.drawdown),String(item.metrics.trades),String(item.blocked_entries)].forEach(value=>{const td=document.createElement("td");td.textContent=value;row.append(td);});$("comparison-body").append(row);});
    $("comparison-context").textContent=`${datasetName(meta.datasets.find(item=>item.id===s.dataset))} / lookback ${s.lookback} / BB ${s.bb_mult} / KC ${s.kc_mult} / ${s.fees} bps fee / ${s.slippage} bps slip / ${s.allocation}% allocation. Split: ${result.split}. RSI threshold is fixed across both periods.`;
    $("comparison").hidden=false;$("compare-status").textContent="Comparison ready below the account curves.";$("comparison").scrollIntoView({behavior:"smooth",block:"center"});
  }catch(error){showError(error.message);$("compare-status").textContent=error.message;}
  finally{$("compare").disabled=false;}
};
async function init(){
  try{
    if(!window.LightweightCharts)throw new Error("Chart library missing. Keep the vendor folder beside app.py.");
    const response=await fetch("/api/meta");if(!response.ok)throw new Error("Could not load server metadata.");meta=await response.json();
    populateDatasets();
    meta.providers.forEach(item=>$("provider").add(new Option(item.name,item.id)));
    meta.exchanges.forEach(item=>$("exchange").add(new Option(item,item)));
    providerChanged();
    if(meta.datasets.some(item=>item.id==="csv"))$("dataset").value="csv";
    const remote=meta.datasets.filter(item=>item.id.startsWith("remote-"));if(remote.length)$("dataset").value=remote.at(-1).id;
    $("engine-version").textContent=`VectorBT PRO ${meta.version} / Lightweight Charts ${LightweightCharts.version()}`;
    $("connection").textContent="Ready";charts();
    controlNames.forEach(name=>{ $(name).addEventListener("input",schedule); $(name).addEventListener("change",schedule); });
    for(const name of indicatorNames){
      $(`${name}-slider`).addEventListener("input",event=>{ $(name).value=event.target.value; schedule(); });
    }
    ["dataset","period","split","rsi_filter"].forEach(name=>$(name).addEventListener("change",schedule));
    schedule();
  }catch(error){showError(error.message);$("connection").textContent="Unavailable";$("status").textContent="Startup failed.";}
}
init();
});
</script>
</body>
</html>
