/* global React, store, Panel, TickingPrice, Sparkline, severityPill */
// Pages: Scanner (multi-symbol indicators), Symbols, Options, Backtest, Bots, Settings

const { useState, useEffect, useMemo, useRef } = React;

// ================================================================
// SCANNER — compute many indicators across many symbols at once
// ================================================================
function ScannerPage({ app, quotes, onFocusSymbol }) {
  // User's saved watchlist from /api/watchlist (SQLite-persisted).  Replaces
  // the legacy hardcoded store.WATCH array.  Add/remove via the input + × icon.
  const { items: watchlist, add: addToWatch, remove: removeFromWatch } = store.useWatchlist();
  const allSymbols = useMemo(() => watchlist.map(w => w.symbol), [watchlist]);
  const segByName = useMemo(() => {
    const m = {};
    for (const w of watchlist) m[w.symbol] = w.exchange_segment;
    return m;
  }, [watchlist]);

  const [selected, setSelected] = useState(new Set());
  // When the watchlist arrives or grows, default-select everything in it.
  // Removing a symbol from the watchlist also removes it from the selection.
  useEffect(() => {
    setSelected(prev => {
      const next = new Set(prev);
      for (const s of allSymbols) if (!next.has(s)) next.add(s);
      for (const s of Array.from(next)) if (!allSymbols.includes(s)) next.delete(s);
      return next;
    });
  }, [allSymbols.join(",")]);

  const [timeframe, setTimeframe] = useState("daily");
  const [view, setView] = useState("table"); // table | heatmap | cards
  const [sortKey, setSortKey] = useState(null);
  const [sortDir, setSortDir] = useState("desc");
  const [query, setQuery] = useState("");
  const [addText, setAddText] = useState("");
  const [addSeg, setAddSeg] = useState("ANY");
  const [addError, setAddError] = useState(null);
  const [searchResults, setSearchResults] = useState([]);
  const [searchLoading, setSearchLoading] = useState(false);
  const [searchError, setSearchError] = useState(null);
  const searchReqIdRef = useRef(0);

  // Filter the rendered list by the same text the user types into the
  // "add symbol" input — same field doubles as a search.  Empty text → show
  // everything; partial text → substring match (case-insensitive).
  const visibleSymbols = useMemo(() => {
    if (!addText) return allSymbols;
    const q = addText.toLowerCase();
    return allSymbols.filter(s => s.toLowerCase().includes(q));
  }, [allSymbols, addText]);

  // Debounced master search.  Hits /api/symbols/search (substring on the
  // 260k-row scrip master) so suggestions are real, addable instruments —
  // not the typed text.  Stale responses are dropped via a request-id ref.
  useEffect(() => {
    const q = addText.trim();
    if (q.length < 2) {
      setSearchResults([]);
      setSearchLoading(false);
      setSearchError(null);
      return;
    }
    const reqId = ++searchReqIdRef.current;
    setSearchLoading(true);
    setSearchError(null);
    const t = setTimeout(async () => {
      try {
        const params = new URLSearchParams({ q, limit: "30" });
        if (addSeg && addSeg !== "ANY") params.set("exchange_segment", addSeg);
        const res = await fetch(`/api/symbols/search?${params.toString()}`);
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const j = await res.json();
        if (reqId !== searchReqIdRef.current) return;
        setSearchResults(j.results || []);
        setSearchLoading(false);
      } catch (err) {
        if (reqId !== searchReqIdRef.current) return;
        setSearchError(err.message || String(err));
        setSearchResults([]);
        setSearchLoading(false);
      }
    }, 150);
    return () => clearTimeout(t);
  }, [addText, addSeg]);

  const addFromResult = async (r) => {
    try {
      await addToWatch(r.symbol, r.exchange_segment);
      setAddText("");
      setAddError(null);
    } catch (err) {
      setAddError(err.message || String(err));
    }
  };

  // Real indicators from /api/scan?fields=full — same engine as the per-symbol
  // /analyse endpoint.  Cache-backed (sub-ms per symbol when warm), 60s poll.
  const selectedArr = useMemo(() => Array.from(selected), [selected]);
  const { rows: scanRows, loading: scanLoading, meta: scanMeta } = useScanFull(selectedArr, timeframe, segByName);

  // Overlay live LTP from quotes (5s cadence) on top of analyse rows (30s) so
  // the price + 1D% column tick in real time while indicators stay coherent.
  const rows = useMemo(() => {
    return scanRows.map(r => {
      const q = quotes[r.symbol];
      if (!q || q.last == null) return r;
      const prev = r.prev || q.price || q.last;
      const chg_pct = prev ? ((q.last - prev) / prev) * 100 : (r.chg_pct ?? 0);
      return { ...r, last: q.last, chg_pct, sparkline: q.sparkline };
    });
  }, [scanRows, quotes]);

  const sorted = useMemo(() => {
    const filtered = rows.filter(r => !query || r.symbol.toLowerCase().includes(query.toLowerCase()));
    if (!sortKey) return filtered;
    const copy = [...filtered];
    copy.sort((a, b) => {
      const av = a[sortKey];
      const bv = b[sortKey];
      if (av == null) return 1;
      if (bv == null) return -1;
      if (typeof av === "string") {
        return sortDir === "asc" ? av.localeCompare(bv) : bv.localeCompare(av);
      }
      return sortDir === "asc" ? av - bv : bv - av;
    });
    return copy;
  }, [rows, sortKey, sortDir, query]);

  const sumStats = useMemo(() => {
    const n = rows.length;
    if (n === 0) return null;
    return {
      bull: rows.filter(r => r.bias === "BULLISH").length,
      bear: rows.filter(r => r.bias === "BEARISH").length,
      neut: rows.filter(r => r.bias === "NEUTRAL").length,
      overbought: rows.filter(r => r.rsi14 > 70).length,
      oversold: rows.filter(r => r.rsi14 < 30).length,
      above50: rows.filter(r => r.above_ema50).length,
      avg_rsi: rows.reduce((s, r) => s + r.rsi14, 0) / n,
      avg_adx: rows.reduce((s, r) => s + r.adx, 0) / n,
    };
  }, [rows]);

  const toggle = (sym) => {
    setSelected(prev => {
      const s = new Set(prev);
      if (s.has(sym)) s.delete(sym); else s.add(sym);
      return s;
    });
  };
  const setAll = (on) => setSelected(new Set(on ? allSymbols : []));

  const sortBy = (k) => {
    if (sortKey === k) setSortDir(d => d === "asc" ? "desc" : "asc");
    else { setSortKey(k); setSortDir("desc"); }
  };
  const sortIcon = (k) => sortKey === k ? (sortDir === "desc" ? " ▼" : " ▲") : "";

  return (
    <div style={{ display: "grid", gridTemplateColumns: "240px 1fr", gap: 8, height: "100%", padding: 8, overflow: "hidden" }}>
      {/* LEFT: symbol picker + summary.  Grid with auto rows so each panel
          collapses to its own content height — no panel stretches.  Symbols
          panel is naturally bounded by its inner list's maxHeight: 340.
          Any leftover space sits at the bottom of the column (fine). */}
      <div style={{ display: "grid", gridTemplateRows: "auto auto auto", gap: 8, alignContent: "start", minHeight: 0, height: "100%", overflow: "auto" }}>
        <Panel title="Symbols" tag={selected.size} bodyFlush
          actions={<div style={{ display: "flex", gap: 4 }}>
            <button className="btn btn-xs btn-ghost" onClick={() => setAll(true)}>ALL</button>
            <button className="btn btn-xs btn-ghost" onClick={() => setAll(false)}>NONE</button>
          </div>}
        >
          <div style={{ padding: 6, borderBottom: "1px solid var(--grid)" }}>
            <input
              className="input"
              placeholder="search instruments… (click a result to add)"
              style={{ fontSize: 10, marginBottom: 4 }}
              value={addText}
              onChange={e => { setAddText(e.target.value); setAddError(null); }}
            />
            <select
              className="select"
              style={{ fontSize: 10 }}
              value={addSeg}
              onChange={e => setAddSeg(e.target.value)}
              title="filter search results by exchange segment"
            >
              <option value="ANY">any segment</option>
              <option value="NSE_EQ">NSE_EQ</option>
              <option value="NSE_FNO">NSE_FNO</option>
              <option value="BSE_EQ">BSE_EQ</option>
              <option value="MCX_COMM">MCX_COMM</option>
              <option value="IDX_I">IDX_I</option>
            </select>
            {addError ? <div className="down" style={{ fontSize: 9, marginTop: 4 }}>{addError}</div> : null}
          </div>
          {addText.trim().length >= 2 ? (
            <div style={{ borderBottom: "1px solid var(--grid)", maxHeight: 280, overflow: "auto" }}>
              {searchLoading ? (
                <div className="dim" style={{ padding: "6px 10px", fontSize: 10 }}>searching master…</div>
              ) : searchError ? (
                <div className="down" style={{ padding: "6px 10px", fontSize: 10 }}>search failed: {searchError}</div>
              ) : searchResults.length === 0 ? (
                <div className="dim" style={{ padding: "6px 10px", fontSize: 10 }}>
                  no instruments found in master matching “{addText}”
                </div>
              ) : (
                searchResults.map(r => (
                  <div
                    key={`${r.exchange_segment}:${r.security_id}`}
                    onClick={() => addFromResult(r)}
                    style={{ display: "grid", gridTemplateColumns: "1fr auto", alignItems: "center", gap: 6, padding: "4px 8px", borderBottom: "1px solid var(--grid)", cursor: "pointer" }}
                    title={`add ${r.symbol} (${r.exchange_segment})`}
                  >
                    <div style={{ minWidth: 0 }}>
                      <div className="bright" style={{ fontSize: 11, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.symbol}</div>
                      <div className="dim" style={{ fontSize: 9, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.name}</div>
                    </div>
                    <span className="faint" style={{ fontSize: 9 }}>{r.exchange_segment}</span>
                  </div>
                ))
              )}
            </div>
          ) : null}
          <div style={{ overflow: "auto", maxHeight: 340 }}>
            {visibleSymbols.map(sym => {
              const q = quotes[sym];
              const on = selected.has(sym);
              const chg = q ? ((q.last - q.price) / q.price) * 100 : 0;
              return (
                <div key={sym} style={{ display: "grid", gridTemplateColumns: "14px 1fr 48px 16px", alignItems: "center", gap: 6, padding: "4px 8px", borderBottom: "1px solid var(--grid)", background: on ? "rgba(255,176,32,0.05)" : "transparent" }}>
                  <input type="checkbox" className="checkbox" checked={on} onChange={() => toggle(sym)} />
                  <span className={on ? "bright" : "dim"} style={{ fontSize: 11, cursor: "pointer" }} onClick={() => toggle(sym)}>{sym}</span>
                  <span className={`tnum ${chg >= 0 ? "up" : "down"}`} style={{ fontSize: 9, textAlign: "right" }}>
                    {chg >= 0 ? "+" : ""}{chg.toFixed(2)}%
                  </span>
                  <button
                    className="btn btn-xs btn-ghost"
                    style={{ fontSize: 11, padding: 0, lineHeight: 1, color: "var(--dim)" }}
                    title="remove from watchlist"
                    onClick={() => removeFromWatch(sym, segByName[sym] || "NSE_EQ")}
                  >×</button>
                </div>
              );
            })}
          </div>
        </Panel>

        <Panel title="Scan Settings">
          <div className="label">Timeframe</div>
          <select className="select" value={timeframe} onChange={e => setTimeframe(e.target.value)} style={{ width: "auto", maxWidth: "100%" }}>
            <option value="daily">daily (200 bars)</option>
            <option value="intraday_60">intraday 60m</option>
            <option value="intraday_15">intraday 15m</option>
            <option value="intraday_5">intraday 5m</option>
          </select>
          <div className="faint" style={{ fontSize: 10, marginTop: 10, lineHeight: 1.5 }}>
            Computes: SMA20/50, EMA20/50/200, RSI14, MACD(12,26,9), BB(20,2), ATR14, ADX14, Stoch(14,3), OBV, VWAP, bias score.
          </div>
        </Panel>

        {sumStats ? (
          <Panel title="Summary">
            <dl className="kv">
              <dt>Bullish</dt><dd className="up">{sumStats.bull}/{rows.length}</dd>
              <dt>Bearish</dt><dd className="down">{sumStats.bear}/{rows.length}</dd>
              <dt>Neutral</dt><dd>{sumStats.neut}/{rows.length}</dd>
              <dt>Overbought</dt><dd className="down">{sumStats.overbought}/{rows.length}</dd>
              <dt>Oversold</dt><dd className="up">{sumStats.oversold}/{rows.length}</dd>
              <dt>&gt; EMA50</dt><dd>{sumStats.above50}/{rows.length}</dd>
              <dt>Avg RSI</dt><dd>{sumStats.avg_rsi.toFixed(1)}</dd>
              <dt>Avg ADX</dt><dd>{sumStats.avg_adx.toFixed(1)}</dd>
            </dl>
          </Panel>
        ) : null}
      </div>

      {/* RIGHT: main view */}
      <Panel
        title={`Scanner · ${sorted.length}/${rows.length} symbols`}
        bodyFlush
        actions={
          <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
            {scanMeta && scanMeta.n ? (
              <span className="faint" style={{ fontSize: 9 }} title="loaded / selected · cache hits · last batch wall-time">
                {scanMeta.loaded}/{scanMeta.n} loaded
                {scanMeta.cache_hits ? <> · <span className="amber">{scanMeta.cache_hits} cached</span></> : null}
                {scanMeta.total_ms ? ` · ${scanMeta.total_ms}ms` : ""}
              </span>
            ) : null}
            <input className="input" placeholder="filter…" value={query} onChange={e => setQuery(e.target.value)} style={{ width: 120, fontSize: 10 }} />
            <div className="sep"/>
            {[["table", "TABLE"], ["heatmap", "HEATMAP"], ["cards", "CARDS"]].map(([k, l]) => (
              <button key={k} className={`chip ${view === k ? "active" : ""}`} onClick={() => setView(k)} style={{ fontSize: 9 }}>{l}</button>
            ))}
            <button className="btn btn-xs">EXPORT CSV</button>
          </div>
        }
      >
        {view === "table" ? (
          // overflow:auto on the wrapper + minWidth:max-content on the table forces
          // the table to size to its content (≈4000px for 60 columns) and lets the
          // wrapper scroll horizontally.  Without this, .tbl's `width: 100%` plus
          // the global `.tbl td { overflow: hidden; text-overflow: ellipsis }` rule
          // squeezes every cell to ~23px and ellipsis-truncates the content.
          <div style={{ overflow: "auto", height: "100%", maxWidth: "100%" }}>
            <table className="tbl tbl-compact" style={{ fontSize: 10, width: "max-content", minWidth: "100%" }}>
              <thead><tr>
                {SCAN_COLS.map(c => (
                  <th key={c.k}
                      className={c.num ? "num" : ""}
                      title={c.hint || ""}
                      onClick={c.sortable === false ? undefined : () => sortBy(c.k)}
                      style={{ cursor: c.sortable === false ? "default" : "pointer", whiteSpace: "nowrap" }}>
                    {c.label}{c.sortable === false ? "" : sortIcon(c.k)}
                  </th>
                ))}
              </tr></thead>
              <tbody>
                {sorted.map(r => <ScanRow key={r.symbol} r={r} onClick={() => onFocusSymbol(r.symbol)} />)}
              </tbody>
            </table>
          </div>
        ) : view === "heatmap" ? (
          <Heatmap rows={sorted} onClick={s => onFocusSymbol(s)} />
        ) : (
          <CardGrid rows={sorted} onClick={s => onFocusSymbol(s)} />
        )}
      </Panel>
    </div>
  );
}

// ---------------------------------------------------------------------------
// SCAN_COLS — single source of truth for the Scanner table.  Both the header
// (SCAN_COLS.map(c => <th>)) and ScanRow (SCAN_COLS.map(c => <td>{c.render})>)
// derive from this array.  Adding an indicator column = one entry.
//
// Cell-level rules:
//   render(r)  → React content (string or JSX) for the cell body
//   tone(r)    → optional class added to <td>: "up"|"down"|"amber"|"dim"|""
//   num        → right-align (className "num")
//   sortable   → header is clickable + shows sort arrow (default true)
//   hint       → tooltip shown on header
// ---------------------------------------------------------------------------
const _num  = (v, d = 2) => (v == null || Number.isNaN(v)) ? "—" : Number(v).toFixed(d);
const _sgn  = (v, d = 2) => (v == null || Number.isNaN(v)) ? "—" : (v >= 0 ? "+" : "") + Number(v).toFixed(d);
const _zone = (v, hi, lo) => v == null ? "" : v > hi ? "down" : v < lo ? "up" : "";

function AgoCell({ ts }) {
  const [, setTick] = useState(0);
  useEffect(() => {
    const id = setInterval(() => setTick(t => t + 1), 1000);
    return () => clearInterval(id);
  }, []);
  if (!ts) return <span className="dim">—</span>;
  const age = Math.max(0, (Date.now() - new Date(ts).getTime()) / 1000);
  if (age < 60)   return `${age.toFixed(0)}s`;
  if (age < 3600) return `${Math.floor(age / 60)}m${Math.floor(age % 60)}s`;
  return `${Math.floor(age / 3600)}h${Math.floor((age % 3600) / 60)}m`;
}

const SCAN_COLS = [
  // identity
  { k: "symbol", label: "SYM",
    render: r => <span className="bright" style={{ fontWeight: 500 }}>
      {r.symbol}
      {r._loading ? <span className="faint" style={{ marginLeft: 4, fontSize: 9 }}>·</span> : null}
      {r._failed  ? <span className="down"  style={{ marginLeft: 4, fontSize: 9 }}>✕</span> : null}
    </span>
  },
  { k: "last", label: "LAST", num: true,
    render: r => r.last == null ? "—" : <TickingPrice value={r.last}/>
  },
  { k: "chg_pct", label: "1D%", num: true,
    render: r => _sgn(r.chg_pct), tone: r => (r.chg_pct ?? 0) >= 0 ? "up" : "down"
  },

  // bias
  { k: "bias", label: "BIAS",
    render: r => <span className={`pill ${r.bias === "BULLISH" ? "pill-up" : r.bias === "BEARISH" ? "pill-down" : "pill-dim"}`} style={{ fontSize: 9 }}>{(r.bias || "—").slice(0,4)}</span>
  },
  // freshness — wall-clock age of the indicator computation for this row.
  // Sort key is the raw timestamp so asc=oldest-first, desc=newest-first.
  { k: "_computed_at", label: "AGO", sortable: true,
    hint: "How long ago indicators were computed for this row",
    render: r => <AgoCell ts={r._computed_at}/>
  },
  { k: "bias_score", label: "SCORE", num: true,
    render: r => _sgn(r.bias_score),
    tone: r => (r.bias_score ?? 0) > 0 ? "up" : (r.bias_score ?? 0) < 0 ? "down" : "dim"
  },

  // trend (long EMAs, distance, supertrend, ichimoku, heikin)
  { k: "ema9",   label: "EMA9",   num: true, render: r => _num(r.ema9) },
  { k: "ema20",  label: "EMA20",  num: true, render: r => _num(r.ema20) },
  { k: "ema50",  label: "EMA50",  num: true, render: r => _num(r.ema50) },
  { k: "ema100", label: "EMA100", num: true, render: r => _num(r.ema100) },
  { k: "ema200", label: "EMA200", num: true, render: r => _num(r.ema200) },
  { k: "sma20",  label: "SMA20",  num: true, render: r => _num(r.sma20) },
  { k: "ema20_dist", label: "E20Δ%", num: true, hint: "Price vs EMA20 (%)",
    render: r => _sgn(r.ema20_dist), tone: r => (r.ema20_dist ?? 0) >= 0 ? "up" : "down"
  },
  { k: "ema50_dist", label: "E50Δ%", num: true, hint: "Price vs EMA50 (%)",
    render: r => _sgn(r.ema50_dist), tone: r => (r.ema50_dist ?? 0) >= 0 ? "up" : "down"
  },
  { k: "above_50_200", label: "50>200", sortable: false, hint: "EMA50 above EMA200",
    render: r => {
      if (r.ema50 == null || r.ema200 == null) return <span className="dim">—</span>;
      return <span className={r.ema50 > r.ema200 ? "up" : "down"}>{r.ema50 > r.ema200 ? "Y" : "N"}</span>;
    }
  },
  { k: "supertrend_dir", label: "ST", hint: "Supertrend direction (+ATR band trend flip)",
    render: r => r.supertrend_dir == null ? "—" : r.supertrend_dir > 0 ? "UP" : r.supertrend_dir < 0 ? "DN" : "—",
    tone: r => r.supertrend_dir > 0 ? "up" : r.supertrend_dir < 0 ? "down" : "dim"
  },
  { k: "ichi", label: "ICHI", sortable: false, hint: "Ichimoku tenkan vs kijun (9 vs 26)",
    render: r => {
      if (r.ichi_tenkan == null || r.ichi_kijun == null) return <span className="dim">—</span>;
      const bull = r.ichi_tenkan > r.ichi_kijun;
      return <span className={bull ? "up" : "down"}>{bull ? "BULL" : "BEAR"}</span>;
    }
  },
  { k: "ha_dir", label: "HA", hint: "Heikin-Ashi smoothed candle direction",
    render: r => r.ha_dir == null ? "—" : r.ha_dir > 0 ? "BULL" : r.ha_dir < 0 ? "BEAR" : "DOJI",
    tone: r => r.ha_dir > 0 ? "up" : r.ha_dir < 0 ? "down" : "dim"
  },

  // trend strength
  { k: "adx", label: "ADX", num: true, hint: ">25 = trending",
    render: r => _num(r.adx, 1), tone: r => (r.adx ?? 0) > 25 ? "amber" : ""
  },
  { k: "di", label: "+DI/-DI", sortable: false, hint: "Directional Indicator + / -",
    render: r => {
      if (r.plus_di == null || r.minus_di == null) return <span className="dim">—</span>;
      return <span className={r.plus_di > r.minus_di ? "up" : "down"}>{r.plus_di.toFixed(0)}/{r.minus_di.toFixed(0)}</span>;
    }
  },
  { k: "vortex", label: "VI+/VI-", sortable: false, hint: "Vortex +VI / -VI · cross = trend change",
    render: r => {
      if (r.vortex_plus == null || r.vortex_minus == null) return <span className="dim">—</span>;
      return <span className={r.vortex_plus > r.vortex_minus ? "up" : "down"}>{r.vortex_plus.toFixed(2)}/{r.vortex_minus.toFixed(2)}</span>;
    }
  },
  { k: "aroon_ud", label: "AR U/D", sortable: false, hint: "Aroon Up / Down",
    render: r => {
      if (r.aroon_up == null || r.aroon_down == null) return <span className="dim">—</span>;
      return <span className={r.aroon_up > r.aroon_down ? "up" : "down"}>{r.aroon_up.toFixed(0)}/{r.aroon_down.toFixed(0)}</span>;
    }
  },
  { k: "aroon_osc", label: "AROON", num: true, hint: "Aroon Osc · Up − Down · ±100 strong trend",
    render: r => _num(r.aroon_osc, 0),
    tone: r => r.aroon_osc == null ? "" : r.aroon_osc > 50 ? "up" : r.aroon_osc < -50 ? "down" : ""
  },

  // momentum
  { k: "rsi14",        label: "RSI14",   num: true, hint: "<30 oversold · >70 overbought",
    render: r => _num(r.rsi14, 1), tone: r => _zone(r.rsi14, 70, 30)
  },
  { k: "weekly_rsi14", label: "RSIw",    num: true, hint: "Weekly RSI14 (D→W resample)",
    render: r => _num(r.weekly_rsi14, 1), tone: r => _zone(r.weekly_rsi14, 70, 30)
  },
  { k: "macd_line",   label: "MACD",    num: true, render: r => _num(r.macd_line) },
  { k: "macd_signal", label: "MACDsig", num: true, render: r => _num(r.macd_signal) },
  { k: "macd_hist",   label: "MACDh",   num: true,
    render: r => _num(r.macd_hist),
    tone: r => (r.macd_hist ?? 0) > 0 ? "up" : (r.macd_hist ?? 0) < 0 ? "down" : ""
  },
  { k: "stoch_k", label: "STOCH%K", num: true,
    render: r => _num(r.stoch_k, 1), tone: r => _zone(r.stoch_k, 80, 20)
  },
  { k: "stoch_d", label: "STOCH%D", num: true,
    render: r => _num(r.stoch_d, 1), tone: r => _zone(r.stoch_d, 80, 20)
  },
  { k: "cci20", label: "CCI20", num: true, hint: "±100 momentum threshold",
    render: r => _num(r.cci20, 0),
    tone: r => r.cci20 == null ? "" : r.cci20 > 100 ? "up" : r.cci20 < -100 ? "down" : ""
  },
  { k: "williams_r", label: "W%R", num: true, hint: ">-20 overbought · <-80 oversold",
    render: r => _num(r.williams_r, 0),
    tone: r => r.williams_r == null ? "" : r.williams_r > -20 ? "down" : r.williams_r < -80 ? "up" : ""
  },
  { k: "mfi14", label: "MFI14", num: true, hint: "Money Flow Index — vol-weighted RSI",
    render: r => _num(r.mfi14, 0), tone: r => _zone(r.mfi14, 80, 20)
  },
  { k: "roc10", label: "ROC10", num: true,
    render: r => r.roc10 == null ? "—" : _sgn(r.roc10, 1) + "%",
    tone: r => (r.roc10 ?? 0) > 0 ? "up" : (r.roc10 ?? 0) < 0 ? "down" : ""
  },
  { k: "tsi", label: "TSI", num: true, hint: "True Strength Index",
    render: r => _num(r.tsi, 1), tone: r => (r.tsi ?? 0) > 0 ? "up" : (r.tsi ?? 0) < 0 ? "down" : ""
  },
  { k: "dpo20", label: "DPO20", num: true, hint: "Detrended Price Oscillator · cycle/peak signal",
    render: r => _sgn(r.dpo20, 2),
    tone: r => (r.dpo20 ?? 0) > 0 ? "up" : (r.dpo20 ?? 0) < 0 ? "down" : ""
  },

  // volatility
  { k: "atr14",   label: "ATR14", num: true, render: r => _num(r.atr14) },
  { k: "atr_pct", label: "ATR%",  num: true, hint: "ATR as % of price · cross-symbol comparable",
    render: r => _num(r.atr_pct), tone: r => (r.atr_pct ?? 0) > 3 ? "amber" : ""
  },
  { k: "bb_upper", label: "BBup",  num: true, render: r => _num(r.bb_upper) },
  { k: "bb_mid",   label: "BBmid", num: true, render: r => _num(r.bb_mid) },
  { k: "bb_lower", label: "BBlo",  num: true, render: r => _num(r.bb_lower) },
  { k: "bb_width", label: "BBW%",  num: true, render: r => _num(r.bb_width) },
  { k: "bb_pctb",  label: "%B",    num: true, hint: "0=lower band · 100=upper band",
    render: r => r.bb_pctb == null ? "—" : (r.bb_pctb * 100).toFixed(0),
    tone: r => r.bb_pctb == null ? "" : r.bb_pctb > 1 ? "down" : r.bb_pctb < 0 ? "up" : ""
  },
  { k: "kelt_upper", label: "Kup", num: true, hint: "EMA20 + 2·ATR", render: r => _num(r.kelt_upper) },
  { k: "kelt_lower", label: "Klo", num: true, hint: "EMA20 − 2·ATR", render: r => _num(r.kelt_lower) },
  { k: "squeeze", label: "SQZ", hint: "BB inside Keltner = volatility compression before move",
    render: r => r.squeeze == null ? "—" : r.squeeze ? "ON" : "off",
    tone: r => r.squeeze ? "amber" : "dim"
  },
  { k: "chop14", label: "CHOP", num: true, hint: ">61.8 ranging · <38.2 trending",
    render: r => _num(r.chop14, 1),
    tone: r => r.chop14 == null ? "" : r.chop14 > 61.8 ? "amber" : r.chop14 < 38.2 ? "up" : ""
  },

  // volume / flow
  { k: "rvol20", label: "RVOL", num: true, hint: "Volume / 20-bar avg · ≥1.5 unusual",
    render: r => r.rvol20 == null ? "—" : r.rvol20.toFixed(2) + "×",
    tone: r => (r.rvol20 ?? 0) >= 1.5 ? "amber"
              : ((r.rvol20 ?? 0) > 0 && r.rvol20 < 0.7) ? "down" : ""
  },
  { k: "obv_slope", label: "OBVs", num: true, hint: "Linear-reg slope of last 30 OBV bars",
    render: r => r.obv_slope == null ? "—" : (r.obv_slope > 0 ? "↑" : "↓") + Math.abs(r.obv_slope).toFixed(1),
    tone: r => (r.obv_slope ?? 0) > 0 ? "up" : (r.obv_slope ?? 0) < 0 ? "down" : ""
  },
  { k: "cmf20", label: "CMF20", num: true, hint: "Chaikin Money Flow · accumulation vs distribution",
    render: r => _sgn(r.cmf20, 2),
    tone: r => (r.cmf20 ?? 0) > 0 ? "up" : (r.cmf20 ?? 0) < 0 ? "down" : ""
  },
  { k: "vs_vwap_pct", label: "vVWAP%", num: true, hint: "Price vs VWAP (%)",
    render: r => _sgn(r.vs_vwap_pct),
    tone: r => (r.vs_vwap_pct ?? 0) > 0 ? "up" : (r.vs_vwap_pct ?? 0) < 0 ? "down" : ""
  },
  { k: "vwap", label: "VWAP", num: true, render: r => _num(r.vwap) },

  // structure / position
  { k: "pos_52w", label: "52w%", num: true, hint: "Position in 252-bar range",
    render: r => r.pos_52w == null ? "—" : r.pos_52w.toFixed(0) + "%",
    tone: r => r.pos_52w == null ? "" : r.pos_52w > 80 ? "up" : r.pos_52w < 20 ? "down" : ""
  },
  { k: "hi_52w", label: "52wHi", num: true, render: r => _num(r.hi_52w) },
  { k: "lo_52w", label: "52wLo", num: true, render: r => _num(r.lo_52w) },
  { k: "zscore20", label: "Z20", num: true, hint: "Std-deviations from 20-bar mean",
    render: r => r.zscore20 == null ? "—" : _sgn(r.zscore20, 2) + "σ",
    tone: r => Math.abs(r.zscore20 ?? 0) > 2 ? "amber" : ""
  },
  { k: "donch_upper", label: "Dup", num: true, render: r => _num(r.donch_upper) },
  { k: "donch_lower", label: "Dlo", num: true, render: r => _num(r.donch_lower) },
  { k: "donch_pos", label: "D%", num: true, hint: "Position in 20-bar Donchian channel",
    render: r => r.donch_pos == null ? "—" : r.donch_pos.toFixed(0) + "%"
  },

  // pivots
  { k: "piv_pp", label: "PP", num: true, hint: "Pivot Point — daily fair value", render: r => _num(r.piv_pp) },
  { k: "piv_r1", label: "R1", num: true, render: r => _num(r.piv_r1) },
  { k: "piv_r2", label: "R2", num: true, render: r => _num(r.piv_r2) },
  { k: "piv_r3", label: "R3", num: true, render: r => _num(r.piv_r3) },
  { k: "piv_s1", label: "S1", num: true, render: r => _num(r.piv_s1) },
  { k: "piv_s2", label: "S2", num: true, render: r => _num(r.piv_s2) },
  { k: "piv_s3", label: "S3", num: true, render: r => _num(r.piv_s3) },

  // signals (right edge, non-sortable composite cells)
  { k: "cross", label: "X", sortable: false, hint: "Golden / Death cross",
    render: r => r.golden_cross ? "GLDN" : r.death_cross ? "DEATH" : "—",
    tone: r => r.golden_cross ? "up" : r.death_cross ? "down" : "dim"
  },
  { k: "sig", label: "SIG", sortable: false, hint: "EMA50 · MACD+ · RSI>50 · ADX>25 · near upper BB",
    render: r => <SignalDots r={r}/>
  },
];

function ScanRow({ r, onClick }) {
  const dim = r._loading || r._failed;
  return (
    <tr onClick={onClick} style={{ cursor: "pointer", opacity: dim ? 0.55 : 1 }}>
      {SCAN_COLS.map(c => {
        const tone = c.tone ? c.tone(r) : "";
        const cls  = [c.num ? "num" : "", tone].filter(Boolean).join(" ");
        return <td key={c.k} className={cls} style={{ whiteSpace: "nowrap" }}>{c.render(r)}</td>;
      })}
    </tr>
  );
}

function SignalDots({ r }) {
  const dots = [
    { on: r.above_ema50, color: "var(--up)", title: "> EMA50" },
    { on: r.macd_hist > 0, color: "var(--phosphor)", title: "MACD hist +" },
    { on: r.rsi14 > 50, color: "var(--amber)", title: "RSI > 50" },
    { on: r.adx > 25, color: "var(--cyan)", title: "ADX trending" },
    { on: r.bb_pctb > 0.8, color: "var(--down)", title: "near upper BB" },
  ];
  return (
    <div style={{ display: "flex", gap: 2 }}>
      {dots.map((d, i) => (
        <span key={i} title={d.title} style={{
          width: 7, height: 7, borderRadius: 1,
          background: d.on ? d.color : "var(--border)",
          boxShadow: d.on ? `0 0 4px ${d.color}` : "none",
        }}/>
      ))}
    </div>
  );
}

function Heatmap({ rows, onClick }) {
  const metrics = [
    { key: "chg_pct",      label: "1D %",      scale: "signed", range: [-3, 3] },
    { key: "bias_score",   label: "Bias",      scale: "signed", range: [-1, 1] },
    { key: "rsi14",        label: "RSI14",     scale: "zone",   range: [30, 70] },
    { key: "weekly_rsi14", label: "RSIw",      scale: "zone",   range: [30, 70] },
    { key: "macd_hist",    label: "MACD h",    scale: "signed", range: [-5, 5] },
    { key: "adx",          label: "ADX",       scale: "amber",  range: [0, 50] },
    { key: "aroon_osc",    label: "Aroon",     scale: "signed", range: [-100, 100] },
    { key: "stoch_k",      label: "Stoch %K",  scale: "zone",   range: [20, 80] },
    { key: "bb_pctb",      label: "BB %B",     scale: "zone",   range: [0, 1] },
    { key: "atr_pct",      label: "ATR%",      scale: "amber",  range: [0, 5] },
    { key: "rvol20",       label: "RVOL",      scale: "amber",  range: [0, 3] },
    { key: "vs_vwap_pct",  label: "vs VWAP",   scale: "signed", range: [-1.5, 1.5] },
    { key: "ema20_dist",   label: "EMA20 Δ%",  scale: "signed", range: [-3, 3] },
    { key: "ema50_dist",   label: "EMA50 Δ%",  scale: "signed", range: [-5, 5] },
  ];

  return (
    <div style={{ overflow: "auto", height: "100%", padding: 10 }}>
      <div style={{ display: "grid", gridTemplateColumns: `80px repeat(${metrics.length}, 1fr)`, gap: 1, background: "var(--border)" }}>
        {/* header */}
        <div style={{ padding: "6px 8px", background: "var(--bg-2)" }} className="h-xxs">SYMBOL</div>
        {metrics.map(m => (
          <div key={m.key} style={{ padding: "6px 4px", background: "var(--bg-2)", textAlign: "center" }} className="h-xxs">{m.label}</div>
        ))}
        {rows.map(r => (
          <React.Fragment key={r.symbol}>
            <div onClick={() => onClick(r.symbol)} style={{ padding: "6px 8px", background: "var(--bg-1)", cursor: "pointer" }}>
              <div className="bright" style={{ fontSize: 11, fontWeight: 500 }}>{r.symbol}</div>
              <div className={`tnum ${r.chg_pct >= 0 ? "up" : "down"}`} style={{ fontSize: 9 }}>
                {r.last.toFixed(2)} · {r.chg_pct >= 0 ? "+" : ""}{r.chg_pct.toFixed(2)}%
              </div>
            </div>
            {metrics.map(m => {
              const val = r[m.key];
              const { bg, color } = heatColor(val, m);
              return (
                <div key={m.key} style={{
                  padding: "6px 4px", background: bg, color,
                  textAlign: "center", fontSize: 10, fontVariantNumeric: "tabular-nums",
                  fontWeight: 500,
                }}>
                  {val == null ? "—" : (
                    m.key === "bb_pctb" ? (val * 100).toFixed(0)
                    : m.key === "chg_pct" || m.key === "vs_vwap_pct" || m.key.endsWith("_dist") ? (val >= 0 ? "+" : "") + val.toFixed(2)
                    : val.toFixed(m.key === "bias_score" ? 2 : 1)
                  )}
                </div>
              );
            })}
          </React.Fragment>
        ))}
      </div>
      <div style={{ marginTop: 14, display: "flex", gap: 14, alignItems: "center", fontSize: 10, color: "var(--fg-dim)" }}>
        <span className="h-xxs">LEGEND</span>
        <Legend label="bearish" from="rgba(255,77,77,0.5)" to="transparent"/>
        <Legend label="bullish" from="transparent" to="rgba(34,214,111,0.5)"/>
        <span>· zone: <span style={{ color: "var(--up)" }}>■</span> buy zone · <span style={{ color: "var(--down)" }}>■</span> sell zone · <span style={{ color: "var(--amber)" }}>■</span> trending</span>
      </div>
    </div>
  );
}
function Legend({ label, from, to }) {
  return (
    <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
      <span style={{ width: 40, height: 8, background: `linear-gradient(90deg, ${from}, ${to})` }}/>
      <span>{label}</span>
    </span>
  );
}

function heatColor(val, m) {
  if (val == null) return { bg: "var(--bg-0)", color: "var(--fg-faint)" };
  const [lo, hi] = m.range;
  if (m.scale === "signed") {
    const norm = Math.max(-1, Math.min(1, val / Math.max(Math.abs(lo), Math.abs(hi))));
    const alpha = Math.abs(norm) * 0.55;
    const c = norm >= 0 ? `rgba(34,214,111,${alpha})` : `rgba(255,77,77,${alpha})`;
    return { bg: c, color: Math.abs(norm) > 0.45 ? "var(--fg-bright)" : "var(--fg)" };
  }
  if (m.scale === "zone") {
    // below lo = green (oversold, buy), above hi = red (overbought), middle neutral
    if (val < lo) {
      const t = Math.max(0, Math.min(1, (lo - val) / (lo - (m.range[0] - (hi - lo)))));
      return { bg: `rgba(34,214,111,${0.15 + t * 0.5})`, color: "var(--fg-bright)" };
    }
    if (val > hi) {
      const t = Math.max(0, Math.min(1, (val - hi) / ((hi + (hi - lo)) - hi)));
      return { bg: `rgba(255,77,77,${0.15 + t * 0.5})`, color: "var(--fg-bright)" };
    }
    return { bg: "var(--bg-1)", color: "var(--fg)" };
  }
  if (m.scale === "amber") {
    const t = Math.max(0, Math.min(1, (val - lo) / (hi - lo)));
    return { bg: `rgba(255,176,32,${t * 0.55})`, color: t > 0.45 ? "var(--fg-bright)" : "var(--fg)" };
  }
  return { bg: "var(--bg-1)", color: "var(--fg)" };
}

function CardGrid({ rows, onClick }) {
  return (
    <div style={{ padding: 10, display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 8, overflow: "auto", height: "100%" }}>
      {rows.map(r => (
        <div key={r.symbol} onClick={() => onClick(r.symbol)} style={{
          border: "1px solid var(--border)",
          borderLeft: `3px solid ${r.bias === "BULLISH" ? "var(--up)" : r.bias === "BEARISH" ? "var(--down)" : "var(--fg-faint)"}`,
          background: "var(--bg-1)", padding: 10, cursor: "pointer",
        }}>
          <div style={{ display: "flex", alignItems: "baseline", gap: 6, marginBottom: 4 }}>
            <span className="bright" style={{ fontSize: 14, fontWeight: 500 }}>{r.symbol}</span>
            <span className={`tnum ${r.chg_pct >= 0 ? "up" : "down"}`} style={{ fontSize: 11 }}>
              {r.chg_pct >= 0 ? "+" : ""}{r.chg_pct.toFixed(2)}%
            </span>
            <div style={{ flex: 1 }}/>
            <span className={`pill ${r.bias === "BULLISH" ? "pill-up" : r.bias === "BEARISH" ? "pill-down" : "pill-dim"}`} style={{ fontSize: 9 }}>{r.bias}</span>
          </div>
          <div className="tnum bright" style={{ fontSize: 18, marginBottom: 6 }}>{r.last.toFixed(2)}</div>
          <Sparkline data={r.sparkline} width={200} height={30} />
          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: 4, marginTop: 8, fontSize: 10 }}>
            <Mini label="RSI"    value={r.rsi14.toFixed(1)}       tone={r.rsi14 > 70 ? "down" : r.rsi14 < 30 ? "up" : null}/>
            <Mini label="MACDh"  value={r.macd_hist.toFixed(2)}   tone={r.macd_hist > 0 ? "up" : "down"}/>
            <Mini label="ADX"    value={r.adx.toFixed(1)}         tone={r.adx > 25 ? "amber" : null}/>
            <Mini label="Stoch"  value={r.stoch_k.toFixed(0)}     tone={r.stoch_k > 80 ? "down" : r.stoch_k < 20 ? "up" : null}/>
            <Mini label="ATR"    value={r.atr14.toFixed(1)}/>
            <Mini label="BB%B"   value={(r.bb_pctb * 100).toFixed(0)}/>
            <Mini label="vVWAP"  value={(r.vs_vwap_pct >= 0 ? "+" : "") + r.vs_vwap_pct.toFixed(2)} tone={r.vs_vwap_pct >= 0 ? "up" : "down"}/>
            <Mini label="Score"  value={(r.bias_score > 0 ? "+" : "") + r.bias_score.toFixed(2)} tone={r.bias_score > 0 ? "up" : r.bias_score < 0 ? "down" : null}/>
          </div>
          <div style={{ marginTop: 8, paddingTop: 6, borderTop: "1px solid var(--grid)", display: "flex", justifyContent: "space-between", fontSize: 9, color: "var(--fg-dim)" }}>
            <span>{r.above_ema50 ? "↑ EMA50" : "↓ EMA50"} · {r.above_ema200 ? "↑ EMA200" : "↓ EMA200"}</span>
            <span>{r.golden_cross ? "GOLDEN" : r.death_cross ? "DEATH" : ""}</span>
          </div>
        </div>
      ))}
    </div>
  );
}
function Mini({ label, value, tone, hint }) {
  const c = tone === "up" ? "up" : tone === "down" ? "down" : tone === "amber" ? "amber" : "bright";
  return (
    <div title={hint || ""} style={{ padding: "3px 5px", background: "var(--bg-0)", border: "1px solid var(--border)" }}>
      <div style={{ fontSize: 8, color: "var(--fg-dim)", letterSpacing: "0.08em", textTransform: "uppercase" }}>{label}</div>
      <div className={`tnum ${c}`} style={{ fontSize: 11 }}>{value}</div>
    </div>
  );
}

// ---------------------------------------------------------------------
// IndicatorPanel — flat 6-col grid of every tile.  Order is intentional
// (trend → momentum → volatility → volume → structure → pivots) so the
// reader still scans naturally, but no section dividers / headers.
// ---------------------------------------------------------------------
function IndicatorPanel({ ind }) {
  const sgn = (v, d=2) => v == null ? "—" : (v >= 0 ? "+" : "") + v.toFixed(d);
  const fp  = (v, d=2) => v == null ? "—" : v.toFixed(d) + "%";
  const yn  = (b) => b == null ? "—" : (b ? "YES" : "NO");

  // tone helpers — colour cells by zone, not just sign
  const rsiTone   = v => v == null ? null : v > 70 ? "down" : v < 30 ? "up" : null;
  const stochTone = v => v == null ? null : v > 80 ? "down" : v < 20 ? "up" : null;
  const wrTone    = v => v == null ? null : v > -20 ? "down" : v < -80 ? "up" : null;
  const mfiTone   = v => v == null ? null : v > 80 ? "down" : v < 20 ? "up" : null;
  const cciTone   = v => v == null ? null : v > 100 ? "up" : v < -100 ? "down" : null;
  const adxTone   = v => v == null ? null : v > 25 ? "amber" : null;
  const chopTone  = v => v == null ? null : v > 61.8 ? "amber" : v < 38.2 ? "up" : null;

  // Distance % from current price to a level — useful for pivot tile colouring
  const distPct = (lvl) => (ind.last != null && lvl != null && ind.last !== 0)
    ? ((lvl - ind.last) / ind.last) * 100 : null;
  const pivTile = (label, lvl, hint) => {
    const d = distPct(lvl);
    return {
      label,
      v: lvl == null ? "—" : `${lvl.toFixed(2)}${d != null ? ` (${d >= 0 ? "+" : ""}${d.toFixed(1)}%)` : ""}`,
      tone: d == null ? null : Math.abs(d) < 0.5 ? "amber" : (d > 0 ? "down" : "up"),
      hint: hint || `${(d != null && d > 0 ? "resistance" : "support")} · ${d == null ? "" : Math.abs(d).toFixed(2) + "% away"}`,
    };
  };

  // Tiles in deliberate scan order — trend → momentum → volatility → volume
  // → structure → pivots — but rendered as a single flat 6-col grid.
  const tiles = [
    // trend / direction
    { label: "EMA20",     v: fmt(ind.ema20, 2) },
    { label: "EMA50",     v: fmt(ind.ema50, 2) },
    { label: "EMA200",    v: fmt(ind.ema200, 2) },
    { label: "EMA20 Δ%",  v: sgn(ind.ema20_dist), tone: (ind.ema20_dist || 0) >= 0 ? "up" : "down" },
    { label: "EMA50 Δ%",  v: sgn(ind.ema50_dist), tone: (ind.ema50_dist || 0) >= 0 ? "up" : "down" },
    { label: "50>200",    v: yn(ind.ema50 != null && ind.ema200 != null ? ind.ema50 > ind.ema200 : null),
                          tone: (ind.ema50 || 0) > (ind.ema200 || 0) ? "up" : "down" },
    { label: "Cross",     v: ind.golden_cross ? "GOLDEN" : ind.death_cross ? "DEATH" : "—",
                          tone: ind.golden_cross ? "up" : ind.death_cross ? "down" : null },
    { label: "Supertrend",v: ind.supertrend_dir == null ? "—" : ind.supertrend_dir > 0 ? "UP" : ind.supertrend_dir < 0 ? "DOWN" : "FLAT",
                          tone: ind.supertrend_dir > 0 ? "up" : ind.supertrend_dir < 0 ? "down" : null,
                          hint: "+ATR band trend flip" },
    { label: "Tenkan-Kj", v: (ind.ichi_tenkan != null && ind.ichi_kijun != null) ? (ind.ichi_tenkan > ind.ichi_kijun ? "BULL" : "BEAR") : "—",
                          tone: (ind.ichi_tenkan || 0) > (ind.ichi_kijun || 0) ? "up" : "down",
                          hint: "Ichimoku 9 vs 26" },
    { label: "Heikin-A",  v: ind.ha_dir == null ? "—" : ind.ha_dir > 0 ? "BULL" : ind.ha_dir < 0 ? "BEAR" : "DOJI",
                          tone: ind.ha_dir > 0 ? "up" : ind.ha_dir < 0 ? "down" : null,
                          hint: "Heikin-Ashi smoothed candle direction" },
    { label: "ADX14",     v: fmt(ind.adx, 1), tone: adxTone(ind.adx), hint: ">25 = trending" },
    { label: "+DI/-DI",   v: (ind.plus_di != null && ind.minus_di != null) ? `${ind.plus_di.toFixed(0)}/${ind.minus_di.toFixed(0)}` : "—",
                          tone: (ind.plus_di || 0) > (ind.minus_di || 0) ? "up" : "down" },
    { label: "Vortex",    v: (ind.vortex_plus != null && ind.vortex_minus != null) ? `${ind.vortex_plus.toFixed(2)}/${ind.vortex_minus.toFixed(2)}` : "—",
                          tone: (ind.vortex_plus || 0) > (ind.vortex_minus || 0) ? "up" : "down",
                          hint: "+VI / -VI · cross = trend change" },
    { label: "Aroon U/D", v: (ind.aroon_up != null && ind.aroon_down != null) ? `${ind.aroon_up.toFixed(0)}/${ind.aroon_down.toFixed(0)}` : "—",
                          tone: (ind.aroon_up || 0) > (ind.aroon_down || 0) ? "up" : "down" },
    { label: "Aroon Osc", v: ind.aroon_osc != null ? sgn(ind.aroon_osc, 0) : "—",
                          tone: (ind.aroon_osc || 0) > 50 ? "up" : (ind.aroon_osc || 0) < -50 ? "down" : null,
                          hint: "Up − Down · ±100 strong trend" },

    // momentum / oscillators
    { label: "RSI14",     v: fmt(ind.rsi14, 1),         tone: rsiTone(ind.rsi14), hint: "<30 oversold · >70 overbought" },
    { label: "RSI Wkly",  v: fmt(ind.weekly_rsi14, 1),  tone: rsiTone(ind.weekly_rsi14), hint: "RSI on 5-bar resample (D→W)" },
    { label: "MACD h",    v: fmt(ind.macd_hist, 2),     tone: (ind.macd_hist || 0) > 0 ? "up" : "down" },
    { label: "Stoch %K",  v: fmt(ind.stoch_k, 1),       tone: stochTone(ind.stoch_k) },
    { label: "CCI20",     v: fmt(ind.cci20, 0),         tone: cciTone(ind.cci20),  hint: "±100 momentum threshold" },
    { label: "W%R",       v: fmt(ind.williams_r, 0),    tone: wrTone(ind.williams_r) },
    { label: "ROC10",     v: ind.roc10 != null ? sgn(ind.roc10, 1) + "%" : "—",
                          tone: (ind.roc10 || 0) > 0 ? "up" : "down" },
    { label: "TSI",       v: fmt(ind.tsi, 1),           tone: (ind.tsi || 0) > 0 ? "up" : "down" },
    { label: "DPO20",     v: ind.dpo20 != null ? sgn(ind.dpo20, 2) : "—",
                          tone: (ind.dpo20 || 0) > 0 ? "up" : "down",
                          hint: "Detrended Price Oscillator · cycle/peak signal" },

    // volatility / regime
    { label: "ATR14",     v: fmt(ind.atr14, 2) },
    { label: "ATR%",      v: ind.atr_pct != null ? ind.atr_pct.toFixed(2) + "%" : "—",
                          tone: (ind.atr_pct || 0) > 3 ? "amber" : null,
                          hint: "Cross-symbol comparable volatility" },
    { label: "BB width",  v: fp(ind.bb_width) },
    { label: "BB %B",     v: ind.bb_pctb != null ? (ind.bb_pctb * 100).toFixed(0) : "—",
                          tone: (ind.bb_pctb || 0) > 1 ? "down" : (ind.bb_pctb || 0) < 0 ? "up" : null,
                          hint: "0=lower band · 100=upper band" },
    { label: "Chop14",    v: fmt(ind.chop14, 1), tone: chopTone(ind.chop14),
                          hint: ">61.8 ranging · <38.2 trending" },
    { label: "Squeeze",   v: ind.squeeze == null ? "—" : ind.squeeze ? "ON" : "off",
                          tone: ind.squeeze ? "amber" : null,
                          hint: "BB inside Keltner = compression before move" },
    { label: "Kelt up",   v: fmt(ind.kelt_upper, 2), hint: "EMA20 + 2·ATR" },
    { label: "Kelt lo",   v: fmt(ind.kelt_lower, 2), hint: "EMA20 − 2·ATR" },

    // volume / flow
    { label: "RVOL20",    v: ind.rvol20 != null ? ind.rvol20.toFixed(2) + "×" : "—",
                          tone: (ind.rvol20 || 0) >= 1.5 ? "amber" : (ind.rvol20 || 0) < 0.7 ? "down" : null,
                          hint: "vol / 20-bar avg · ≥1.5 unusual" },
    { label: "OBV slope", v: ind.obv_slope != null ? sgn(ind.obv_slope, 1) : "—",
                          tone: (ind.obv_slope || 0) >= 0 ? "up" : "down",
                          hint: "Linear-reg slope of last 30 OBV bars" },
    { label: "MFI14",     v: fmt(ind.mfi14, 0), tone: mfiTone(ind.mfi14), hint: "Money Flow Index — vol-weighted RSI" },
    { label: "CMF20",     v: ind.cmf20 != null ? sgn(ind.cmf20, 2) : "—",
                          tone: (ind.cmf20 || 0) > 0 ? "up" : "down",
                          hint: "Chaikin Money Flow · accumulation vs distribution" },
    { label: "vs VWAP",   v: ind.vs_vwap_pct != null ? sgn(ind.vs_vwap_pct) + "%" : "—",
                          tone: (ind.vs_vwap_pct || 0) >= 0 ? "up" : "down" },
    { label: "VWAP",      v: fmt(ind.vwap, 2) },

    // structure / position
    { label: "52w pos",   v: ind.pos_52w != null ? ind.pos_52w.toFixed(0) + "%" : "—",
                          tone: (ind.pos_52w || 50) > 80 ? "up" : (ind.pos_52w || 50) < 20 ? "down" : null,
                          hint: "Where price sits in 252-bar range" },
    { label: "52w hi",    v: fmt(ind.hi_52w, 2) },
    { label: "52w lo",    v: fmt(ind.lo_52w, 2) },
    { label: "Z20",       v: ind.zscore20 != null ? sgn(ind.zscore20, 2) + "σ" : "—",
                          tone: Math.abs(ind.zscore20 || 0) > 2 ? "amber" : null,
                          hint: "Std-deviations from 20-bar mean" },
    { label: "Donch up",  v: fmt(ind.donch_upper, 2) },
    { label: "Donch lo",  v: fmt(ind.donch_lower, 2) },
    { label: "Donch pos", v: ind.donch_pos != null ? ind.donch_pos.toFixed(0) + "%" : "—",
                          hint: "Position in 20-bar Donchian channel" },
    { label: "Bias score",v: ind.bias_score != null ? sgn(ind.bias_score) : "—",
                          tone: (ind.bias_score || 0) > 0 ? "up" : (ind.bias_score || 0) < 0 ? "down" : null,
                          hint: "Composite −1..+1 across all indicators" },

    // pivots — closest level highlighted amber via pivTile()
    pivTile("R3", ind.piv_r3),
    pivTile("R2", ind.piv_r2),
    pivTile("R1", ind.piv_r1),
    pivTile("PP", ind.piv_pp, "Pivot Point — daily fair value"),
    pivTile("S1", ind.piv_s1),
    pivTile("S2", ind.piv_s2),
    pivTile("S3", ind.piv_s3),
  ];

  return (
    <div style={{ padding: 10, display: "grid", gridTemplateColumns: "repeat(6, 1fr)", gap: 6, overflow: "auto" }}>
      {tiles.map(t => <Mini key={t.label} label={t.label} value={t.v} tone={t.tone} hint={t.hint}/>)}
    </div>
  );
}

// Deterministic per-symbol indicators, seeded by symbol name — stable across renders
function computeIndicators(sym, quote) {
  if (!quote) return null;
  const seed = hash(sym);
  const r = seedRand(seed);
  const last = quote.last;
  const prev = quote.price;
  const chg_pct = ((last - prev) / prev) * 100;

  // generate fake but plausible indicators using seeded randomness
  const ema20 = last * (1 + (r() - 0.5) * 0.03);
  const ema50 = last * (1 + (r() - 0.5) * 0.06);
  const ema200 = last * (1 + (r() - 0.5) * 0.12);
  const rsi14 = 30 + r() * 55; // 30–85
  const macd_hist = (r() - 0.5) * 10;
  const atr14 = last * (0.008 + r() * 0.02);
  const adx = 10 + r() * 40;
  const stoch_k = r() * 100;
  const bb_width = 2 + r() * 6; // %
  const bb_pctb = r() * 1.2 - 0.1;
  const obv_slope = (r() - 0.45) * 20;
  const vs_vwap_pct = (r() - 0.5) * 3;
  const above_ema50 = last > ema50;
  const above_ema200 = last > ema200;
  const golden_cross = ema50 > ema200 && ema50 / ema200 < 1.02 && r() > 0.7;
  const death_cross = ema50 < ema200 && ema200 / ema50 < 1.02 && r() > 0.7;
  const ema20_dist = ((last - ema20) / ema20) * 100;
  const ema50_dist = ((last - ema50) / ema50) * 100;

  // bias score — weighted combo
  let score = 0;
  score += above_ema50 ? 0.2 : -0.2;
  score += above_ema200 ? 0.15 : -0.15;
  score += macd_hist > 0 ? 0.15 : -0.15;
  score += rsi14 > 55 ? 0.1 : rsi14 < 45 ? -0.1 : 0;
  score += chg_pct > 0 ? 0.1 : -0.1;
  score += vs_vwap_pct > 0 ? 0.1 : -0.1;
  score = Math.max(-1, Math.min(1, score + (r() - 0.5) * 0.15));

  const bias = score > 0.2 ? "BULLISH" : score < -0.2 ? "BEARISH" : "NEUTRAL";

  return {
    symbol: sym,
    last, prev, chg_pct,
    ema20, ema50, ema200, ema20_dist, ema50_dist,
    rsi14, macd_hist, atr14, adx, stoch_k,
    bb_width, bb_pctb, obv_slope, vs_vwap_pct,
    above_ema50, above_ema200, golden_cross, death_cross,
    bias, bias_score: score,
    sparkline: quote.sparkline,
  };
}
function hash(s) { let h = 0; for (let i = 0; i < s.length; i++) h = ((h << 5) - h) + s.charCodeAt(i) | 0; return Math.abs(h); }
function seedRand(seed) { let s = seed; return () => { s = (s * 9301 + 49297) % 233280; return s / 233280; }; }
function fmt(v, n) { return (v == null || Number.isNaN(v)) ? "—" : Number(v).toFixed(n); }

// ----------------------------------------------------------------
// useRealAnalyse — fetches /api/symbols/{sym}/analyse and shapes it
// to match the keys the VALUES grid + Focus header expect.  Returns
// null while loading, then a fully-populated object once data lands.
// Re-fetches every 30s so the panel reflects the live close.
// ----------------------------------------------------------------
function useRealAnalyse(symbol) {
  const [data, setData] = useState(null);
  useEffect(() => {
    if (!symbol) return;
    let stopped = false;
    setData(null);
    const seg = (store.WATCH.find(w => w.sym === symbol) || {}).exch || "NSE_EQ";
    const load = async () => {
      try {
        const res = await fetch(`/api/symbols/${encodeURIComponent(symbol)}/analyse?days=240&exchange_segment=${encodeURIComponent(seg)}`);
        if (!res.ok) throw new Error("HTTP " + res.status);
        const j = await res.json();
        if (!j.ok || stopped) return;
        const ind   = j.indicators || {};
        const snap  = j.snapshot   || {};
        const bias  = j.bias       || {};
        const last  = snap.last_close;
        const ema20 = ind.ema20, ema50 = ind.ema50, ema200 = ind.ema200;
        const macdObj = ind.macd  || {};
        const bbObj   = ind.bollinger || {};
        const adxObj  = ind.adx   || {};
        const stoch   = ind.stochastic || {};
        const ichi    = ind.ichimoku  || {};
        const donch   = ind.donchian  || {};
        const kelt    = ind.keltner   || {};
        const aroonObj= ind.aroon     || {};
        const emaMap  = ind.ema       || {};
        const piv     = ind.pivots    || {};
        const vtx     = ind.vortex    || {};
        const ha      = ind.heikin_ashi || {};
        const bb_mid  = bbObj.mid;
        const bb_pctb = (bbObj.upper != null && bbObj.lower != null && last != null && bbObj.upper !== bbObj.lower)
          ? (last - bbObj.lower) / (bbObj.upper - bbObj.lower) : null;
        const bb_width = (bb_mid && bb_mid !== 0 && bbObj.upper != null && bbObj.lower != null)
          ? ((bbObj.upper - bbObj.lower) / bb_mid) * 100 : null;
        const vwap = ind.vwap;
        const vs_vwap_pct = (vwap != null && last != null) ? ((last - vwap) / vwap) * 100 : null;
        const ema20_dist  = (ema20 && last != null) ? ((last - ema20) / ema20) * 100 : null;
        const ema50_dist  = (ema50 && last != null) ? ((last - ema50) / ema50) * 100 : null;
        const golden_cross = !!bias.golden_cross_50_200;
        const death_cross  = !!bias.death_cross_50_200;
        // Prefer the polars-computed obv_slope_30 (linear-regression slope on
        // last 30 OBV bars).  Falls back to bias channel for compatibility.
        const obv_slope = ind.obv_slope_30 != null ? ind.obv_slope_30
                        : (bias.obv_slope_30 != null ? bias.obv_slope_30 : null);
        // Donchian position 0..100 — where price sits inside the 20-bar range
        const donch_pos = (donch.upper != null && donch.lower != null && last != null && donch.upper !== donch.lower)
          ? ((last - donch.lower) / (donch.upper - donch.lower)) * 100 : null;
        // Keltner squeeze — BB inside Keltner = volatility compression
        const squeeze = (bbObj.upper != null && bbObj.lower != null && kelt.upper != null && kelt.lower != null)
          ? (bbObj.upper < kelt.upper && bbObj.lower > kelt.lower) : null;
        const chg_pct = (last != null && bias.prev_close)
          ? ((last - bias.prev_close) / bias.prev_close) * 100
          : (bias.chg_pct != null ? bias.chg_pct : 0);
        const score = bias.score != null ? bias.score : 0;
        const label = bias.label || (score > 0.2 ? "BULLISH" : score < -0.2 ? "BEARISH" : "NEUTRAL");
        if (stopped) return;
        const next = {
          symbol: symbol,
          last, prev: bias.prev_close || last,
          chg_pct,
          sma20: ind.sma20, ema20, ema50, ema200,
          ema9: emaMap["9"], ema100: emaMap["100"],
          ema20_dist, ema50_dist,
          rsi14: ind.rsi14,
          weekly_rsi14: ind.weekly_rsi14,
          macd_hist: macdObj.hist,
          macd_line: macdObj.macd, macd_signal: macdObj.signal,
          atr14: ind.atr14, atr_pct: ind.atr_pct,
          adx: adxObj.adx, plus_di: adxObj.plus_di, minus_di: adxObj.minus_di,
          stoch_k: stoch.k, stoch_d: stoch.d,
          bb_upper: bbObj.upper, bb_lower: bbObj.lower, bb_mid,
          bb_width, bb_pctb,
          vwap, vs_vwap_pct,
          obv: ind.obv, obv_slope,
          cci20: ind.cci20, mfi14: ind.mfi14, williams_r: ind.williams_r,
          roc10: ind.roc10, tsi: ind.tsi, cmf20: ind.cmf20,
          supertrend_dir: ind.supertrend_dir,
          ichi_tenkan: ichi.tenkan, ichi_kijun: ichi.kijun,
          donch_upper: donch.upper, donch_lower: donch.lower, donch_pos,
          rvol20: ind.rvol20,
          zscore20: ind.zscore20,
          pos_52w: ind.pos_52w, hi_52w: ind.hi_52w, lo_52w: ind.lo_52w,
          kelt_upper: kelt.upper, kelt_lower: kelt.lower, squeeze,
          chop14: ind.chop14,
          aroon_up: aroonObj.up, aroon_down: aroonObj.down, aroon_osc: aroonObj.osc,
          dpo20: ind.dpo20,
          vortex_plus: vtx.plus, vortex_minus: vtx.minus,
          ha_dir: ha.dir, ha_close: ha.close, ha_open: ha.open,
          piv_pp: piv.pivot, piv_r1: piv.r1, piv_r2: piv.r2, piv_r3: piv.r3,
          piv_s1: piv.s1, piv_s2: piv.s2, piv_s3: piv.s3,
          above_ema50:  (ema50 != null && last != null) ? last > ema50  : false,
          above_ema200: (ema200 != null && last != null) ? last > ema200 : false,
          golden_cross, death_cross,
          bias: label, bias_score: score,
          _candles: snap.candles,
          _as_of:   snap.as_of,
          _cache:        j.cache,                // "hit" | "miss"
          _computed_at:  j.computed_at,          // ISO timestamp from batch refresh
          _compute_ms:   j.compute_ms,           // total batch wall-time
          _sym_compute_ms: j.symbol_compute_ms,  // this symbol's slice
        };
        // Sticky merge — if the new fetch returns null/undefined for a field
        // the previous fetch had a real value for, keep the prior value.
        // Stops the panel from oscillating between data and "—" while the
        // backend warms up or returns partial responses.  New non-null values
        // always win; symbol changes start fresh (handled above by setData(null)).
        setData(prev => {
          if (!prev || prev.symbol !== next.symbol) return next;
          const merged = { ...next };
          for (const k of Object.keys(next)) {
            if (next[k] == null && prev[k] != null) merged[k] = prev[k];
          }
          return merged;
        });
      } catch (_e) { /* keep last known data */ }
    };
    load();
    const t = setInterval(load, 30000);
    return () => { stopped = true; clearInterval(t); };
  }, [symbol]);
  return data;
}

// ----------------------------------------------------------------
// flattenAnalysed — turn the /api/symbols/{sym}/analyse (or one row of
// /api/scan?fields=full) JSON into the flat shape the Scanner table /
// heatmap / cards consume.  Pure data mapping — same logic as the
// inline block in useRealAnalyse, factored out so both single-symbol
// and batch paths produce identical row shapes.
// ----------------------------------------------------------------
function flattenAnalysed(j, symbol) {
  const ind   = j.indicators || {};
  const snap  = j.snapshot   || {};
  const bias  = j.bias       || {};
  const last  = snap.last_close;
  const ema20 = ind.ema20, ema50 = ind.ema50, ema200 = ind.ema200;
  const macdObj = ind.macd  || {};
  const bbObj   = ind.bollinger || {};
  const adxObj  = ind.adx   || {};
  const stoch   = ind.stochastic || {};
  const ichi    = ind.ichimoku  || {};
  const donch   = ind.donchian  || {};
  const kelt    = ind.keltner   || {};
  const aroonObj= ind.aroon     || {};
  const emaMap  = ind.ema       || {};
  const piv     = ind.pivots    || {};
  const vtx     = ind.vortex    || {};
  const ha      = ind.heikin_ashi || {};
  const bb_mid  = bbObj.mid;
  const bb_pctb = (bbObj.upper != null && bbObj.lower != null && last != null && bbObj.upper !== bbObj.lower)
    ? (last - bbObj.lower) / (bbObj.upper - bbObj.lower) : null;
  const bb_width = (bb_mid && bb_mid !== 0 && bbObj.upper != null && bbObj.lower != null)
    ? ((bbObj.upper - bbObj.lower) / bb_mid) * 100 : null;
  const vwap = ind.vwap;
  const vs_vwap_pct = (vwap != null && last != null) ? ((last - vwap) / vwap) * 100 : null;
  const ema20_dist  = (ema20 && last != null) ? ((last - ema20) / ema20) * 100 : null;
  const ema50_dist  = (ema50 && last != null) ? ((last - ema50) / ema50) * 100 : null;
  const golden_cross = !!bias.golden_cross_50_200;
  const death_cross  = !!bias.death_cross_50_200;
  const obv_slope = ind.obv_slope_30 != null ? ind.obv_slope_30
                  : (bias.obv_slope_30 != null ? bias.obv_slope_30 : null);
  const donch_pos = (donch.upper != null && donch.lower != null && last != null && donch.upper !== donch.lower)
    ? ((last - donch.lower) / (donch.upper - donch.lower)) * 100 : null;
  const squeeze = (bbObj.upper != null && bbObj.lower != null && kelt.upper != null && kelt.lower != null)
    ? (bbObj.upper < kelt.upper && bbObj.lower > kelt.lower) : null;
  const chg_pct = (last != null && bias.prev_close)
    ? ((last - bias.prev_close) / bias.prev_close) * 100
    : (bias.chg_pct != null ? bias.chg_pct : 0);
  const score = bias.score != null ? bias.score : 0;
  const label = bias.label || (score > 0.2 ? "BULLISH" : score < -0.2 ? "BEARISH" : "NEUTRAL");
  return {
    symbol,
    last, prev: bias.prev_close || last,
    chg_pct,
    sma20: ind.sma20, ema20, ema50, ema200,
    ema9: emaMap["9"], ema100: emaMap["100"],
    ema20_dist, ema50_dist,
    rsi14: ind.rsi14,
    weekly_rsi14: ind.weekly_rsi14,
    macd_hist: macdObj.hist, macd_line: macdObj.macd, macd_signal: macdObj.signal,
    atr14: ind.atr14, atr_pct: ind.atr_pct,
    adx: adxObj.adx, plus_di: adxObj.plus_di, minus_di: adxObj.minus_di,
    stoch_k: stoch.k, stoch_d: stoch.d,
    bb_upper: bbObj.upper, bb_lower: bbObj.lower, bb_mid, bb_width, bb_pctb,
    vwap, vs_vwap_pct,
    obv: ind.obv, obv_slope,
    cci20: ind.cci20, mfi14: ind.mfi14, williams_r: ind.williams_r,
    roc10: ind.roc10, tsi: ind.tsi, cmf20: ind.cmf20,
    supertrend_dir: ind.supertrend_dir,
    ichi_tenkan: ichi.tenkan, ichi_kijun: ichi.kijun,
    donch_upper: donch.upper, donch_lower: donch.lower, donch_pos,
    rvol20: ind.rvol20,
    zscore20: ind.zscore20,
    pos_52w: ind.pos_52w, hi_52w: ind.hi_52w, lo_52w: ind.lo_52w,
    kelt_upper: kelt.upper, kelt_lower: kelt.lower, squeeze,
    chop14: ind.chop14,
    aroon_up: aroonObj.up, aroon_down: aroonObj.down, aroon_osc: aroonObj.osc,
    dpo20: ind.dpo20,
    vortex_plus: vtx.plus, vortex_minus: vtx.minus,
    ha_dir: ha.dir, ha_close: ha.close, ha_open: ha.open,
    piv_pp: piv.pivot, piv_r1: piv.r1, piv_r2: piv.r2, piv_r3: piv.r3,
    piv_s1: piv.s1, piv_s2: piv.s2, piv_s3: piv.s3,
    above_ema50:  (ema50 != null && last != null) ? last > ema50  : false,
    above_ema200: (ema200 != null && last != null) ? last > ema200 : false,
    golden_cross, death_cross,
    bias: label, bias_score: score,
    _candles: snap.candles,
    _as_of:   snap.as_of,
    _cache:   j.cache,
    _computed_at: j.computed_at,
  };
}

// ----------------------------------------------------------------
// useScanFull — drives the Scanner table off `POST /api/scanner/stream`
// (SSE).  Backend emits one `symbol_scanned` event per completed symbol,
// so we patch the table row-by-row as results arrive instead of chunking
// and waiting for the slowest member of each chunk.
//
// Re-runs every 30s to match the backend cache cadence (cached symbols
// return sub-ms; only stale ones do work).
// ----------------------------------------------------------------
function useScanFull(symbols, timeframe = "daily", segByName = {}) {
  const [rowsBySym, setRowsBySym] = useState({});
  const [meta, setMeta] = useState({ loaded: 0, n: 0, cache_hits: 0, total_ms: 0 });
  // Stable dep — array identity changes every parent render even if contents match.
  const key = useMemo(() => [...symbols].slice().sort().join(","), [symbols]);
  // Same trick for segments — re-fetch when a symbol's segment changes
  // (e.g. user adds NIFTY as IDX_I after previously having only NSE_EQ rows).
  const segKey = useMemo(
    () => Object.entries(segByName).sort().map(([k, v]) => `${k}=${v}`).join("|"),
    [segByName]
  );

  useEffect(() => {
    if (!symbols.length) {
      setRowsBySym({});
      setMeta({ loaded: 0, n: 0, cache_hits: 0, total_ms: 0 });
      return;
    }
    let stopped = false;
    let abortCtrl = null;
    let intervalTimer = null;

    // Skeleton rows for newly-added symbols; preserve existing data for symbols
    // still in the selection (no flicker on toggle/refresh).  Drop symbols
    // that are no longer selected.
    setRowsBySym(prev => {
      const next = {};
      for (const s of symbols) next[s] = prev[s] || { symbol: s, _loading: true };
      return next;
    });

    const runOnce = async () => {
      if (stopped) return;
      const started = Date.now();
      const segments = {};
      for (const s of symbols) if (segByName[s]) segments[s] = segByName[s];
      abortCtrl = new AbortController();
      try {
        const res = await fetch("/api/scanner/stream", {
          method: "POST",
          headers: { "Content-Type": "application/json", "Accept": "text/event-stream" },
          body: JSON.stringify({ symbols, timeframe, fields: "full", segments }),
          signal: abortCtrl.signal,
        });
        if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
        const reader = res.body.getReader();
        const decoder = new TextDecoder();
        let buffer = "";
        let totalHits = 0, totalLoaded = 0;
        while (!stopped) {
          const { value, done } = await reader.read();
          if (done) break;
          buffer += decoder.decode(value, { stream: true });
          let idx;
          while ((idx = buffer.indexOf("\n\n")) >= 0) {
            const frame = buffer.slice(0, idx);
            buffer = buffer.slice(idx + 2);
            if (!frame) continue;
            let evType = "message", evData = "";
            for (const line of frame.split("\n")) {
              if (line.startsWith("event:"))      evType = line.slice(6).trim();
              else if (line.startsWith("data:"))  evData += line.slice(5).trim();
            }
            if (!evData) continue;
            let payload;
            try { payload = JSON.parse(evData); } catch (_) { continue; }
            if (evType === "symbol_scanned") {
              const r = payload.row || {};
              const sym = r.symbol;
              if (!sym) continue;
              if (r.cache === "hit") totalHits += 1;
              if (r.ok !== false) totalLoaded += 1;
              if (r.ok === false) {
                setRowsBySym(prev => ({ ...prev, [sym]: { symbol: sym, _failed: true, error: r.error } }));
              } else {
                const flat = flattenAnalysed(r, sym);
                setRowsBySym(prev => ({ ...prev, [sym]: flat }));
              }
              setMeta(m => ({
                ...m,
                loaded: totalLoaded, n: payload.total || symbols.length,
                cache_hits: totalHits,
                total_ms: Date.now() - started,
              }));
            } else if (evType === "scan_done") {
              setMeta(m => ({
                ...m, loaded: totalLoaded, n: payload.total || symbols.length,
                cache_hits: payload.cache_hits ?? totalHits,
                total_ms: payload.elapsed_ms ?? (Date.now() - started),
              }));
            } else if (evType === "error") {
              // Mark every still-loading row as failed; keep last-good rows otherwise.
              setRowsBySym(prev => {
                const next = { ...prev };
                for (const s of symbols) {
                  if (next[s]?._loading) next[s] = { symbol: s, _failed: true, error: payload.error };
                }
                return next;
              });
            }
          }
        }
      } catch (err) {
        if (stopped || (err && err.name === "AbortError")) return;
        // Network failure mid-stream — leave last-good rows visible, mark
        // any unresolved skeletons as failed so the user sees something.
        setRowsBySym(prev => {
          const next = { ...prev };
          for (const s of symbols) {
            if (next[s]?._loading) next[s] = { symbol: s, _failed: true, error: String(err) };
          }
          return next;
        });
      }
    };

    runOnce();
    intervalTimer = setInterval(runOnce, 30000);
    return () => {
      stopped = true;
      if (abortCtrl) try { abortCtrl.abort(); } catch (_) {}
      if (intervalTimer) clearInterval(intervalTimer);
    };
  }, [key, timeframe, segKey]);

  // Preserve insertion order from `symbols` for deterministic display.
  const rows = useMemo(() => symbols.map(s => rowsBySym[s]).filter(Boolean), [rowsBySym, key]);
  const loading = useMemo(() => Object.values(rowsBySym).some(r => r._loading), [rowsBySym]);
  return { rows, loading, meta };
}

// One row in the scrip-master sidebar.  Reads authoritative lot_size from
// the backend meta (scrip master) and falls back to the WATCH default while
// the request is in flight.
function ScripRow({ w, active, onClick }) {
  const meta = store.useSymbolMeta(w.sym, w.exch);
  const lot  = meta?.lot_size ?? w.lot;
  const name = meta?.name || w.name;
  const seg  = meta?.exchange_segment || w.exch;
  return (
    <div onClick={onClick}
      style={{ padding: "6px 10px", borderBottom: "1px solid var(--grid)", cursor: "pointer",
               background: active ? "rgba(255,176,32,0.08)" : "transparent" }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
        <span className="bright" style={{ fontSize: 12, fontWeight: 500 }}>{w.sym}</span>
        <span className="faint" style={{ fontSize: 9 }}>{seg}</span>
      </div>
      <div className="dim" style={{ fontSize: 10 }}>{name}</div>
      <div className="faint" style={{ fontSize: 9 }}>lot {lot} · {w.sector}</div>
    </div>
  );
}

// ================================================================
// SYMBOLS — drill into one symbol, analyse + quote
// ================================================================
function SymbolsPage({ app, quotes, focusSymbol, onFocusSymbol }) {
  const [query, setQuery] = useState("");
  const [symbol, setSymbol] = useState(focusSymbol || "RELIANCE");
  const [indTab, setIndTab] = useState("VALUES");  // VALUES | CHART | GRAPH
  useEffect(() => { if (focusSymbol) setSymbol(focusSymbol); }, [focusSymbol]);

  // Shared user watchlist — same source of truth as ScannerPage.
  const { items: watchlist } = store.useWatchlist();

  // Prefetch authoritative meta (lot/tick/sec_id) for every watchlist symbol.
  useEffect(() => { store.prefetchMetas(watchlist.map(w => w.symbol)); }, [watchlist.length]);

  const q = quotes[symbol];
  const realInd = useRealAnalyse(symbol);
  const wlMatch = watchlist.find(w => w.symbol === symbol);
  const fbMatch = store.WATCH.find(w => w.sym === symbol) || {};
  const segForFocus = wlMatch?.exchange_segment || fbMatch.exch || "NSE_EQ";
  const meta    = store.useSymbolMeta(symbol, segForFocus);
  // Prefer real backend analysis; fall back to seeded compute only while loading.
  const ind = realInd || (q ? computeIndicators(symbol, q) : null);
  // Sidebar shows the user's watchlist (mapped to ScripRow's expected shape).
  const results = watchlist
    .filter(w => !query || w.symbol.toLowerCase().includes(query.toLowerCase()) || (w.name || "").toLowerCase().includes(query.toLowerCase()))
    .map(w => ({ sym: w.symbol, name: w.name || w.symbol, exch: w.exchange_segment, sector: w.sector || "-", lot: w.lot || 0 }));

  return (
    <div style={{ display: "grid", gridTemplateColumns: "300px 1fr", gap: 8, height: "100%", padding: 8, overflow: "hidden" }}>
      <Panel title="Scrip master" bodyFlush>
        <div style={{ padding: 6, borderBottom: "1px solid var(--grid)" }}>
          <input className="input" placeholder="search symbol or name…" value={query} onChange={e => setQuery(e.target.value)} autoFocus/>
        </div>
        <div style={{ overflow: "auto" }}>
          {results.map(w => (
            <ScripRow key={w.sym} w={w} active={symbol === w.sym}
              onClick={() => { setSymbol(w.sym); onFocusSymbol(w.sym); }}/>
          ))}
        </div>
      </Panel>

      <div style={{ display: "grid", gridTemplateRows: "auto 1fr", gridTemplateColumns: "1fr 320px", gap: 8, minHeight: 0 }}>
        <Panel title={`Focus · ${symbol}`} actions={<span className="faint" style={{ fontSize: 10 }}>auto-refresh 5s</span>}>
          {q && ind ? (
            <div style={{ display: "grid", gridTemplateColumns: "auto 1fr 1fr 1fr 1fr", gap: 20, alignItems: "center" }}>
              <div>
                <div className="h-xxs">LAST</div>
                <div style={{ fontSize: 34, fontWeight: 500, letterSpacing: "-0.02em" }}><TickingPrice value={q.last}/></div>
                <div className={`tnum ${ind.chg_pct >= 0 ? "up" : "down"}`} style={{ fontSize: 12 }}>
                  {ind.chg_pct >= 0 ? "+" : ""}{(q.last - q.price).toFixed(2)} ({ind.chg_pct >= 0 ? "+" : ""}{ind.chg_pct.toFixed(2)}%)
                </div>
              </div>
              <Sparkline data={q.sparkline} width={200} height={50} />
              <div>
                <div className="h-xxs">BIAS</div>
                <div className={`bright`} style={{ fontSize: 20 }}>
                  <span className={ind.bias === "BULLISH" ? "up" : ind.bias === "BEARISH" ? "down" : "dim"}>{ind.bias}</span>
                </div>
                <div className="faint" style={{ fontSize: 10 }}>score {ind.bias_score.toFixed(2)}</div>
              </div>
              <dl className="kv" style={{ gridTemplateColumns: "auto 1fr", fontSize: 10 }}>
                <dt>Open</dt><dd>{(q.price * 1.001).toFixed(2)}</dd>
                <dt>High</dt><dd>{(Math.max(q.last, q.price) * 1.006).toFixed(2)}</dd>
                <dt>Low</dt><dd>{(Math.min(q.last, q.price) * 0.995).toFixed(2)}</dd>
                <dt>Prev close</dt><dd>{q.price.toFixed(2)}</dd>
              </dl>
              <dl className="kv" style={{ gridTemplateColumns: "auto 1fr", fontSize: 10 }}>
                <dt>Lot size</dt><dd className={meta ? "" : "faint"}>{meta?.lot_size ?? q.lot}{meta ? "" : " (cached)"}</dd>
                <dt>Exchange</dt><dd>{meta?.exchange_segment || q.exch}</dd>
                <dt>Sector</dt><dd>{q.sector}</dd>
                <dt>Sec ID</dt><dd className="faint">{meta?.security_id ?? "—"}</dd>
              </dl>
            </div>
          ) : <div className="dim">Select a symbol</div>}
        </Panel>

        {/* Right column row 1: Market depth */}
        <Panel title="Depth" bodyFlush
          actions={<span className="faint" style={{ fontSize: 9 }}>3s · 5 levels</span>}>
          <MarketDepth symbol={symbol}/>
        </Panel>

        {/* Indicators panel spans both columns at row 2 */}
        <Panel title="Indicators" bodyFlush
          className="span-2"
          actions={
            <div style={{ display: "flex", gap: 4, alignItems: "center" }}>
              {realInd ? (
                <span className="faint" style={{ fontSize: 9, marginRight: 6, display: "flex", gap: 6, alignItems: "center" }}>
                  <span>live · {realInd._candles}c</span>
                  {realInd._computed_at ? (
                    <span title={`Background batch · ${realInd._cache === "hit" ? "cache hit" : "cache miss"} · batch took ${realInd._compute_ms?.toFixed?.(0) ?? "?"}ms${realInd._sym_compute_ms != null ? ` · this symbol ${realInd._sym_compute_ms.toFixed(0)}ms` : ""}`}
                          style={{ color: realInd._cache === "hit" ? "var(--up)" : "var(--amber)" }}>
                      {(() => {
                        const t = new Date(realInd._computed_at);
                        const hh = String(t.getHours()).padStart(2, "0");
                        const mm = String(t.getMinutes()).padStart(2, "0");
                        const ss = String(t.getSeconds()).padStart(2, "0");
                        const ms = realInd._compute_ms != null ? `· ${realInd._compute_ms.toFixed(0)}ms` : "";
                        return `${realInd._cache === "hit" ? "✓" : "·"} ${hh}:${mm}:${ss} ${ms}`;
                      })()}
                    </span>
                  ) : null}
                </span>
              ) : (
                <span className="faint" style={{ fontSize: 9, marginRight: 6 }}>loading…</span>
              )}
              <button className={`chip ${indTab === "VALUES" ? "active" : ""}`} onClick={() => setIndTab("VALUES")}>VALUES</button>
              <button className={`chip ${indTab === "GRAPH"  ? "active" : ""}`} onClick={() => setIndTab("GRAPH")}>GRAPH</button>
              <button className={`chip ${indTab === "CHART"  ? "active" : ""}`} onClick={() => setIndTab("CHART")}>CHART</button>
            </div>
          }>
          {indTab === "VALUES" && ind ? (
            <IndicatorPanel ind={ind}/>
          ) : null}
          {indTab === "CHART" ? <IndicatorChart symbol={symbol}/> : null}
          {indTab === "GRAPH" ? <SymbolGraphAll symbol={symbol}/> : null}
        </Panel>
      </div>
    </div>
  );
}

// ============================================================================
// MarketDepth — 5-level book + price-impact panel.  Pulls /api/symbols/{sym}/depth
// every 3 seconds (matches typical broker polling cadence).  Shows bid/ask
// ladders with cumulative qty, total counts, spread in bps, and the slippage
// you'd take walking the book for the input quantity.
// ============================================================================
function MarketDepth({ symbol }) {
  const [data, setData]   = useState(null);
  const [size, setSize]   = useState(100);
  const segGuess = (store.WATCH.find(w => w.sym === symbol) || {}).exch || "";

  useEffect(() => {
    if (!symbol) return;
    let stopped = false;
    setData(null);
    const load = async () => {
      try {
        const qs = new URLSearchParams({ size: String(size) });
        if (segGuess) qs.set("exchange_segment", segGuess);
        const res = await fetch(`/api/symbols/${encodeURIComponent(symbol)}/depth?${qs.toString()}`, { cache: "no-store" });
        if (!res.ok) throw new Error("HTTP " + res.status);
        const j = await res.json();
        if (stopped) return;
        // Sticky: only replace previous depth when the new response actually
        // has bids/asks.  Empty/no-depth responses keep the last good ladder
        // visible so the panel doesn't oscillate between data and "no depth".
        if (j && j.ok && (j.bids?.length || j.asks?.length)) {
          setData(j);
        } else {
          setData(prev => prev && prev.ok ? prev : j);
        }
      } catch (e) {
        // Network blip — keep last known data, do not blank the panel
        if (!stopped) setData(prev => prev && prev.ok ? prev : { error: String(e) });
      }
    };
    load();
    const t = setInterval(load, 3000);
    return () => { stopped = true; clearInterval(t); };
  }, [symbol, size, segGuess]);

  // Reserve vertical space whether data is present or not — prevents the
  // outer grid row from collapsing/expanding on every refresh tick.
  if (!data)        return <div className="faint" style={{ padding: 12, fontSize: 10, minHeight: 280 }}>loading depth…</div>;
  if (data.error)   return <div className="down"  style={{ padding: 10, fontSize: 10, minHeight: 280 }}>{data.error}</div>;
  if (!data.ok)     return <div className="faint" style={{ padding: 10, fontSize: 10, minHeight: 280 }}>{data.error || "no depth"}</div>;

  const { bids = [], asks = [], spread, spread_bps, ltp, total_bid_qty, total_ask_qty, impact = {} } = data;
  // Cumulative qty for visual fill bars
  let cumB = 0, cumA = 0;
  const maxBid = bids.length ? Math.max(...bids.map(b => b.qty)) : 1;
  const maxAsk = asks.length ? Math.max(...asks.map(a => a.qty)) : 1;
  const maxLevel = Math.max(maxBid, maxAsk, 1);

  return (
    <div style={{ padding: 8, fontSize: 10 }}>
      {/* Spread + LTP header */}
      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 6, marginBottom: 6 }}>
        <div>
          <div className="h-xxs">SPREAD</div>
          <div className="bright tnum">{spread != null ? spread.toFixed(2) : "—"}</div>
          <div className="faint" style={{ fontSize: 9 }}>{spread_bps != null ? spread_bps + " bps" : "—"}</div>
        </div>
        <div>
          <div className="h-xxs">LTP</div>
          <div className="bright tnum">{ltp ? ltp.toFixed(2) : "—"}</div>
        </div>
        <div>
          <div className="h-xxs">QTY @ each side</div>
          <div className="tnum"><span className="up">{total_bid_qty.toLocaleString()}</span> / <span className="down">{total_ask_qty.toLocaleString()}</span></div>
        </div>
      </div>

      {/* Ladder — bids on the left, asks on the right.  We render rows
          from best price outwards (best bid at top of bid column, best ask
          at top of ask column) so they read like a real depth widget. */}
      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 4, marginBottom: 6 }}>
        <div>
          <div className="h-xxs" style={{ marginBottom: 2, color: "var(--up)" }}>BIDS</div>
          {bids.map((b, i) => {
            cumB += b.qty;
            const w = (b.qty / maxLevel) * 100;
            return (
              <div key={i} style={{ position: "relative", padding: "2px 4px", marginBottom: 1 }}>
                <div style={{ position: "absolute", inset: 0, background: "rgba(34, 214, 111, 0.12)", width: `${w}%`, right: "auto" }}/>
                <div style={{ position: "relative", display: "flex", justifyContent: "space-between" }}>
                  <span className="up tnum" style={{ fontWeight: 500 }}>{b.price.toFixed(2)}</span>
                  <span className="tnum">{b.qty.toLocaleString()}</span>
                  <span className="faint tnum" style={{ fontSize: 9 }}>×{b.orders}</span>
                </div>
              </div>
            );
          })}
          {!bids.length ? <div className="faint">no bids</div> : null}
        </div>
        <div>
          <div className="h-xxs" style={{ marginBottom: 2, color: "var(--down)" }}>ASKS</div>
          {asks.map((a, i) => {
            cumA += a.qty;
            const w = (a.qty / maxLevel) * 100;
            return (
              <div key={i} style={{ position: "relative", padding: "2px 4px", marginBottom: 1 }}>
                <div style={{ position: "absolute", inset: 0, background: "rgba(255, 77, 77, 0.12)", width: `${w}%` }}/>
                <div style={{ position: "relative", display: "flex", justifyContent: "space-between" }}>
                  <span className="down tnum" style={{ fontWeight: 500 }}>{a.price.toFixed(2)}</span>
                  <span className="tnum">{a.qty.toLocaleString()}</span>
                  <span className="faint tnum" style={{ fontSize: 9 }}>×{a.orders}</span>
                </div>
              </div>
            );
          })}
          {!asks.length ? <div className="faint">no asks</div> : null}
        </div>
      </div>

      {/* Price-impact estimator */}
      <div style={{ borderTop: "1px solid var(--grid)", paddingTop: 6 }}>
        <div className="h-xxs" style={{ marginBottom: 4, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
          <span>PRICE IMPACT (walk book)</span>
          <span style={{ display: "flex", gap: 3 }}>
            {[100, 500, 1000, 5000].map(n => (
              <button key={n} className={`chip ${size === n ? "active" : ""}`} style={{ fontSize: 8, padding: "1px 5px" }} onClick={() => setSize(n)}>{n}</button>
            ))}
          </span>
        </div>
        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 6 }}>
          <ImpactCell side="BUY"  data={impact.buy}  color="var(--down)"/>
          <ImpactCell side="SELL" data={impact.sell} color="var(--up)"/>
        </div>
      </div>
    </div>
  );
}

function ImpactCell({ side, data, color }) {
  if (!data) return (
    <div style={{ padding: 6, border: "1px solid var(--border)", background: "var(--bg-0)" }}>
      <div className="h-xxs" style={{ color }}>{side} {/* size */}</div>
      <div className="faint">no data</div>
    </div>
  );
  const partial = data.filled_qty != null && data.filled_qty < (data.size || data.filled_qty);
  return (
    <div style={{ padding: 6, border: "1px solid var(--border)", background: "var(--bg-0)" }}>
      <div className="h-xxs" style={{ color }}>{side}</div>
      {data.avg_price != null ? (
        <>
          <div className="bright tnum">avg {data.avg_price.toFixed(2)}</div>
          <div className="tnum" style={{ fontSize: 9 }}>
            slip {data.slippage >= 0 ? "+" : ""}{data.slippage?.toFixed(2)}
            <span className="faint"> ({data.slippage_pct?.toFixed(3)}%)</span>
          </div>
          <div className="faint" style={{ fontSize: 9 }}>{data.levels_used} level{data.levels_used !== 1 ? "s" : ""}</div>
        </>
      ) : (
        <>
          <div className="down tnum" style={{ fontSize: 9 }}>book too thin</div>
          {data.note ? <div className="faint" style={{ fontSize: 9 }}>{data.note}</div> : null}
        </>
      )}
    </div>
  );
}

// ================================================================
// SECTORS — merged page: NIFTY sector rotation (TABLE or RRG view)
// at the top, then expandable per-sector cards showing member
// symbols' real indicators.  All data comes from real backend
// endpoints (no placeholders).
// ================================================================
function SectorsPage() {
  const [data, setData]   = useState(null);
  const [open, setOpen]   = useState(new Set());
  const [lookback, setLb] = useState(30);
  const [topView, setTopView] = useState("RRG");  // RRG | TABLE

  useEffect(() => {
    let stopped = false;
    setData(null);
    const load = async () => {
      try {
        const res = await fetch(`/api/sectors/groups?lookback_days=${lookback}`, { cache: "no-store" });
        if (!res.ok) throw new Error("HTTP " + res.status);
        const j = await res.json();
        if (!stopped) setData(j);
      } catch (e) { if (!stopped) setData({ error: String(e) }); }
    };
    load();
    const t = setInterval(load, 60000);
    return () => { stopped = true; clearInterval(t); };
  }, [lookback]);

  const toggle = (s) => setOpen(p => { const n = new Set(p); n.has(s) ? n.delete(s) : n.add(s); return n; });
  const expandAll = () => data && setOpen(new Set((data.groups || []).map(g => g.sector)));
  const collapseAll = () => setOpen(new Set());

  return (
    <div style={{ padding: 8, height: "100%", overflow: "auto", display: "grid", gap: 8, gridTemplateRows: "auto 1fr" }}>
      {/* Top: NIFTY sector index rotation — toggle between RRG quadrant and TABLE */}
      <Panel title="Sector rotation" bodyFlush
        actions={
          <div style={{ display: "flex", gap: 4 }}>
            <button className={`chip ${topView === "RRG" ? "active" : ""}`} onClick={() => setTopView("RRG")}>RRG</button>
            <button className={`chip ${topView === "TABLE" ? "active" : ""}`} onClick={() => setTopView("TABLE")}>TABLE</button>
          </div>
        }>
        {topView === "RRG" ? <SectorRRG/> : <SectorRotationPanel embedded/>}
      </Panel>

      {/* Below: groups with their member symbols + indicators */}
      <Panel title="Sector groups · constituents"
        tag={data?.groups ? `${data.sector_count} sectors` : ""}
        actions={
          <div style={{ display: "flex", gap: 4, alignItems: "center" }}>
            {[10, 30, 90].map(d => (
              <button key={d} className={`chip ${lookback === d ? "active" : ""}`} onClick={() => setLb(d)}>{d}d</button>
            ))}
            <span style={{ width: 1, background: "var(--border)", height: 14, margin: "0 4px" }}/>
            <button className="chip" onClick={expandAll}>expand all</button>
            <button className="chip" onClick={collapseAll}>collapse</button>
          </div>
        }
        bodyFlush>
        {!data ? (
          <div className="faint" style={{ padding: 20, textAlign: "center", fontSize: 11 }}>Loading sector groups…</div>
        ) : data.error ? (
          <div className="down" style={{ padding: 12, fontSize: 11 }}>{data.error}</div>
        ) : !data.groups || data.groups.length === 0 ? (
          <div className="faint" style={{ padding: 12, fontSize: 11 }}>No sector groups available.</div>
        ) : (
          <div style={{ overflow: "auto" }}>
            {data.groups.map(g => <SectorGroupCard key={g.sector} g={g} expanded={open.has(g.sector)} onToggle={() => toggle(g.sector)}/>)}
          </div>
        )}
      </Panel>
    </div>
  );
}

// One sector card: header strip with aggregate, expandable members table.
function SectorGroupCard({ g, expanded, onToggle }) {
  const a = g.aggregate || {};
  const idx = g.index;
  const tone = (a.avg_bias_score || 0) > 0.1 ? "up" : (a.avg_bias_score || 0) < -0.1 ? "down" : "dim";
  const idxTone = idx && idx.pct_1d != null ? (idx.pct_1d >= 0 ? "up" : "down") : "dim";
  return (
    <div style={{ borderBottom: "1px solid var(--grid)" }}>
      <div onClick={onToggle}
        style={{ display: "grid", gridTemplateColumns: "16px 110px 100px repeat(7, 1fr)", gap: 8,
                 padding: "8px 12px", cursor: "pointer", alignItems: "center", fontSize: 11,
                 background: expanded ? "rgba(255,176,32,0.05)" : "transparent" }}>
        <span className="faint">{expanded ? "▼" : "▶"}</span>
        <span className="bright" style={{ fontWeight: 500, letterSpacing: "0.04em" }}>{g.sector.toUpperCase()}</span>
        <span className={`tnum ${tone}`}>
          bias {a.avg_bias_score != null ? (a.avg_bias_score >= 0 ? "+" : "") + a.avg_bias_score.toFixed(2) : "—"}
        </span>
        <span className="num tnum"><span className="faint">RSI </span>{a.avg_rsi != null ? a.avg_rsi.toFixed(1) : "—"}</span>
        <span className="num tnum"><span className="faint">ADX </span>{a.avg_adx != null ? a.avg_adx.toFixed(1) : "—"}</span>
        <span className="num tnum">
          <span className="up">{a.bullish || 0}</span> / <span className="dim">{a.neutral || 0}</span> / <span className="down">{a.bearish || 0}</span>
        </span>
        <span className="num tnum">
          <span className="up">{a.gainers || 0}↑</span> <span className="down">{a.losers || 0}↓</span>
        </span>
        <span className={`num tnum ${(a.avg_chg_pct || 0) >= 0 ? "up" : "down"}`}>
          {a.avg_chg_pct != null ? (a.avg_chg_pct >= 0 ? "+" : "") + a.avg_chg_pct.toFixed(2) + "%" : "—"}
        </span>
        <span className="num tnum">
          {idx ? <><span className="faint">{idx.label} </span>{idx.last != null ? idx.last.toFixed(0) : "—"}</> : <span className="faint">no idx</span>}
        </span>
        <span className={`num tnum ${idxTone}`}>
          {idx && idx.pct_1d != null ? (idx.pct_1d >= 0 ? "+" : "") + idx.pct_1d.toFixed(2) + "%" : "—"}
        </span>
      </div>
      {expanded ? (
        <div style={{ padding: "0 0 4px", background: "var(--bg-0)" }}>
          <table className="tbl tbl-compact" style={{ fontSize: 10, width: "100%" }}>
            <thead>
              <tr>
                <th style={{ width: 100, textAlign: "left" }}>Sym</th>
                <th className="num">Last</th>
                <th className="num">1D %</th>
                <th className="num">RSI 14</th>
                <th className="num">MACD h</th>
                <th className="num">ADX 14</th>
                <th className="num">EMA20</th>
                <th className="num">EMA50</th>
                <th className="num">EMA200</th>
                <th>vs EMA</th>
                <th>Bias</th>
                <th className="num">Score</th>
              </tr>
            </thead>
            <tbody>
              {g.members.map(m => (
                <tr key={m.sym} style={{ borderBottom: "1px solid var(--grid)" }}>
                  <td className="bright" style={{ fontSize: 10 }}>{m.sym}</td>
                  {m.ok === false ? (
                    <td colSpan="11" className="dim" style={{ fontSize: 9 }}>{m.error || "no data"}</td>
                  ) : (
                    <>
                      <td className="num tnum">{m.last != null ? m.last.toFixed(2) : "—"}</td>
                      <td className={`num tnum ${(m.chg_pct || 0) >= 0 ? "up" : "down"}`}>
                        {m.chg_pct != null ? (m.chg_pct >= 0 ? "+" : "") + m.chg_pct.toFixed(2) : "—"}
                      </td>
                      <td className={`num tnum ${m.rsi14 > 70 ? "down" : m.rsi14 < 30 ? "up" : ""}`}>
                        {m.rsi14 != null ? m.rsi14.toFixed(1) : "—"}
                      </td>
                      <td className={`num tnum ${(m.macd_hist || 0) >= 0 ? "up" : "down"}`}>
                        {m.macd_hist != null ? (m.macd_hist >= 0 ? "+" : "") + m.macd_hist.toFixed(2) : "—"}
                      </td>
                      <td className={`num tnum ${m.adx > 25 ? "amber" : ""}`}>
                        {m.adx != null ? m.adx.toFixed(1) : "—"}
                      </td>
                      <td className="num tnum">{m.ema20 != null ? m.ema20.toFixed(0) : "—"}</td>
                      <td className="num tnum">{m.ema50 != null ? m.ema50.toFixed(0) : "—"}</td>
                      <td className="num tnum">{m.ema200 != null ? m.ema200.toFixed(0) : "—"}</td>
                      <td className="tnum">
                        <span className={m.above_ema50 ? "up" : "down"}>{m.above_ema50 ? "↑50" : "↓50"}</span>
                        <span className="faint"> · </span>
                        <span className={m.above_ema200 ? "up" : "down"}>{m.above_ema200 ? "↑200" : "↓200"}</span>
                      </td>
                      <td>
                        <span className={`pill ${m.bias === "BULLISH" ? "pill-up" : m.bias === "BEARISH" ? "pill-down" : "pill-dim"}`}
                          style={{ fontSize: 9, padding: "0 4px" }}>{m.bias || "—"}</span>
                      </td>
                      <td className={`num tnum ${(m.bias_score || 0) > 0 ? "up" : (m.bias_score || 0) < 0 ? "down" : ""}`}>
                        {m.bias_score != null ? (m.bias_score > 0 ? "+" : "") + m.bias_score.toFixed(2) : "—"}
                      </td>
                    </>
                  )}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      ) : null}
    </div>
  );
}

// ================================================================
// OPTIONS
// ================================================================
// useExpiryList — fetches /api/options/expiry_list for the selected
// underlying.  The backend infers the correct segment from the symbol
// (IDX_I / MCX_COMM / NSE_EQ); we don't forward the underlyings-list
// segment because it's the OPTIONS segment (NSE_FNO) and option_chain
// expects the UNDERLYING's segment (NSE_EQ for stocks).  Decorates
// each ISO date with a display label, DTE, and a WEEKLY|MONTHLY
// classifier (last expiry per calendar month = MONTHLY).
function useExpiryList(underlying) {
  const [state, setState] = useState({ list: [], loading: true, error: null });
  useEffect(() => {
    if (!underlying) return;
    let stopped = false;
    setState({ list: [], loading: true, error: null });
    (async () => {
      try {
        const qs = new URLSearchParams({ underlying });
        const res = await fetch(`/api/options/expiry_list?${qs.toString()}`);
        if (!res.ok) throw new Error("HTTP " + res.status);
        const j = await res.json();
        if (!j.ok) throw new Error("bad response");
        // Walk the Dhan v2 response — expiries live at .data, .data.data,
        // or .data.expiry depending on SDK version.
        let exp = [];
        let node = j.raw;
        for (let i = 0; i < 4 && node && typeof node === "object"; i++) {
          if (Array.isArray(node)) { exp = node; break; }
          if (Array.isArray(node.data))   { exp = node.data;   break; }
          if (Array.isArray(node.expiry)) { exp = node.expiry; break; }
          node = node.data;
        }
        exp = exp.filter(d => typeof d === "string").sort();
        const today = new Date(); today.setHours(0, 0, 0, 0);
        const decorated = exp.map(iso => {
          const d = new Date(iso + "T00:00:00");
          const dte = Math.round((d - today) / 86400000);
          const display = d.toLocaleDateString("en-GB",
            { day: "2-digit", month: "short", year: "numeric" }).toUpperCase();
          return { date: iso, display, dte };
        });
        const lastByMonth = {};
        decorated.forEach(e => {
          const k = e.date.slice(0, 7);
          if (!lastByMonth[k] || lastByMonth[k] < e.date) lastByMonth[k] = e.date;
        });
        const final = decorated.map(e => ({
          ...e,
          kind: lastByMonth[e.date.slice(0, 7)] === e.date ? "MONTHLY" : "WEEKLY",
        }));
        if (!stopped) setState({ list: final, loading: false, error: null });
      } catch (e) {
        if (!stopped) setState({ list: [], loading: false, error: String(e) });
      }
    })();
    return () => { stopped = true; };
  }, [underlying]);
  return state;
}

function ExpiryTabs({ list, expiry, setExpiry, loading, error }) {
  const PAGE = 6;
  const activeIdx = list.findIndex(e => e.date === expiry);
  const [start, setStart] = useState(0);
  useEffect(() => {
    if (!list.length) { setStart(0); return; }
    const idx = activeIdx < 0 ? 0 : activeIdx;
    setStart(Math.max(0, Math.min(idx, Math.max(0, list.length - PAGE))));
  }, [list, activeIdx]);

  if (!list.length) {
    return (
      <span className="faint" style={{ fontSize: 10, padding: "0 8px" }}>
        {loading ? "loading expiries…" : (error ? `expiries: ${error}` : "no expiries")}
      </span>
    );
  }

  const end = Math.min(start + PAGE, list.length);
  const visible = list.slice(start, end);
  const canPrev = start > 0;
  const canNext = end < list.length;

  return (
    <div className="expiry-tabs">
      <button className="expiry-arrow" onClick={() => setStart(s => Math.max(0, s - PAGE))} disabled={!canPrev} title="previous expiries">‹</button>
      <div className="expiry-tabs-row">
        {visible.map(e => {
          const active = e.date === expiry;
          return (
            <button key={e.date}
              className={`expiry-tab ${active ? "active" : ""}`}
              onClick={() => setExpiry(e.date)}
            >
              <span className="expiry-tab-date">{e.display}</span>
              <span className="expiry-tab-meta">
                <span className={`expiry-tab-kind ${e.kind === "MONTHLY" ? "amber" : e.kind === "QUARTERLY" ? "violet" : "cyan"}`}>{e.kind}</span>
                <span className="expiry-tab-dte">{e.dte}d</span>
              </span>
            </button>
          );
        })}
      </div>
      <button className="expiry-arrow" onClick={() => setStart(s => Math.min(list.length - PAGE, s + PAGE))} disabled={!canNext} title="next expiries">›</button>
      <span className="faint" style={{ fontSize: 9, padding: "0 4px" }}>
        {end}/{list.length}
      </span>
    </div>
  );
}

// ----------------------------------------------------------------
// useFnoUnderlyings — pulls /api/options/underlyings (cached 6h on
// the backend) and returns { list, loading, error }.  Empty list
// while loading or on error — page is greyed out until real data
// arrives.  No mock fallbacks.  Cache once on window so all
// OptionsPage mounts share it.
// ----------------------------------------------------------------
function useFnoUnderlyings() {
  const [state, setState] = useState(() => {
    const cached = window.__fnoUnderlyings;
    return cached ? cached : { list: [], loading: true, error: null };
  });
  useEffect(() => {
    if (window.__fnoUnderlyings && !window.__fnoUnderlyings.loading) return;
    let stopped = false;
    (async () => {
      try {
        const res = await fetch("/api/options/underlyings?limit=80");
        if (!res.ok) throw new Error("HTTP " + res.status);
        const j = await res.json();
        if (!j.ok || !Array.isArray(j.underlyings)) throw new Error("bad response");
        const final = { list: j.underlyings, loading: false, error: null };
        window.__fnoUnderlyings = final;
        if (!stopped) setState(final);
      } catch (e) {
        const err = { list: [], loading: false, error: String(e) };
        window.__fnoUnderlyings = err;
        if (!stopped) setState(err);
      }
    })();
    return () => { stopped = true; };
  }, []);
  return state;
}

// ----------------------------------------------------------------
// useOptionChain — fetches /api/options/chain_table.  Auto-refreshes
// every 30s.  Returns null while loading; { error } on failure.
// ----------------------------------------------------------------
function useOptionChain(underlying, expiryISO) {
  const [data, setData] = useState(null);
  useEffect(() => {
    // Wait for BOTH underlying and a concrete expiry before firing.  The
    // no-expiry path on the backend makes two Dhan API calls (expiry_list
    // + option_chain) which doubles rate-limit pressure and intermittently
    // returns empty chains.  Once an expiry is selected we always send it.
    if (!underlying || !expiryISO) { setData(null); return; }
    let stopped = false;
    setData(null);
    const load = async () => {
      try {
        const qs = new URLSearchParams({ underlying, expiry: expiryISO });
        const res = await fetch(`/api/options/chain_table?${qs.toString()}`, { cache: "no-store" });
        if (!res.ok) throw new Error("HTTP " + res.status);
        const j = await res.json();
        if (!j.ok) throw new Error(j.note || "bad response");
        if (!stopped) setData(j);
      } catch (e) {
        if (!stopped) setData({ error: String(e) });
      }
    };
    load();
    const t = setInterval(load, 30000);
    return () => { stopped = true; clearInterval(t); };
  }, [underlying, expiryISO]);
  return data;
}

function OptionsPage({ app, quotes }) {
  // Underlying + expiry in one state so they update atomically — switching
  // underlying always clears expiry in the SAME render, never producing a
  // (newUnderlying, oldExpiry) tuple that would fire a chain_table request
  // for a date that doesn't exist on the new symbol.
  const [sel, setSel] = useState({ underlying: "NIFTY", expiry: "" });
  const { underlying, expiry } = sel;
  const setUnderlying = (u) => setSel({ underlying: u, expiry: "" });
  const setExpiry     = (e) => setSel(s => ({ ...s, expiry: e }));

  const underlyings = useFnoUnderlyings();
  const expiries = useExpiryList(underlying);

  // Once the expiry list arrives, settle on the nearest expiry if none is
  // selected (or the previous selection isn't on this list).
  useEffect(() => {
    if (!expiries.list.length) return;
    if (!expiries.list.find(e => e.date === expiry)) {
      setExpiry(expiries.list[0].date);
    }
  }, [expiries.list, expiry]);

  // Real-chain fetch — replaces the previous seedRand synthetic generator.
  // Returns { spot, expiry, rows: [{strike, ce:{...}, pe:{...}}], pcr, max_pain, atm_iv }
  const chain = useOptionChain(underlying, expiry || null);

  const spot = chain?.spot || quotes[underlying]?.last || 0;

  // Translate the API's per-strike `ce`/`pe` rows into the table-shape rest
  // of this component already uses (ceOi, peOi, ceLtp, ...).
  const rows = useMemo(() => {
    if (!chain?.rows) return [];
    const atm = chain.rows.length ? chain.rows.reduce((b, r) => Math.abs(r.strike - spot) < Math.abs(b.strike - spot) ? r : b).strike : 0;
    return chain.rows.map(r => ({
      strike: r.strike,
      ceOi:    Math.round(r.ce.oi || 0),
      ceVol:   Math.round(r.ce.vol || 0),
      ceIv:    r.ce.iv || 0,
      ceLtp:   r.ce.ltp || 0,
      ceDelta: r.ce.delta || 0,
      ceGamma: r.ce.gamma || 0,
      ceTheta: r.ce.theta || 0,
      peOi:    Math.round(r.pe.oi || 0),
      peVol:   Math.round(r.pe.vol || 0),
      peIv:    r.pe.iv || 0,
      peLtp:   r.pe.ltp || 0,
      peDelta: r.pe.delta || 0,
      peGamma: r.pe.gamma || 0,
      peTheta: r.pe.theta || 0,
      atm:     Math.abs(r.strike - atm) < 1e-6,
    }));
  }, [chain, spot]);

  // Aggregates — backend already computed PCR + max pain over the FULL chain
  // (not just the trimmed window), so prefer those values.
  const pcr = (chain?.pcr != null ? chain.pcr : 0).toFixed(2);
  const maxPainRow = chain?.max_pain != null ? { strike: chain.max_pain, pain: 0 } : null;

  return (
    <div style={{ display: "grid", gridTemplateRows: "auto auto 1fr", gap: 8, height: "100%", padding: 8 }}>
      {/* Underlying selector + expiry tabs row */}
      <Panel title="Options Analytics" bodyFlush>
        <div style={{ display: "flex", alignItems: "stretch", gap: 0 }}>
          <div style={{ padding: "8px 12px", borderRight: "1px solid var(--border)", display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
            <div style={{ minWidth: 0 }}>
              <div className="h-xxs" style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }}>
                <span>UNDERLYING</span>
                <span className="faint" style={{ fontSize: 8 }}>
                  {underlyings.list.length ? `${underlyings.list.length} from scrip master` : (underlyings.error ? `error: ${underlyings.error}` : "loading…")}
                </span>
              </div>
              <div style={{ display: "grid", gridTemplateColumns: "repeat(8, auto)", gap: 3, marginTop: 3 }}>
                {underlyings.list.map(u => {
                  const inactive = underlying !== u.symbol;
                  const accent = u.kind === "INDEX"     ? "var(--cyan)"
                              :  u.kind === "COMMODITY" ? "var(--amber)"
                              :  null;
                  return (
                    <button key={u.symbol}
                      className={`chip ${underlying === u.symbol ? "active" : ""}`}
                      onClick={() => setUnderlying(u.symbol)}
                      title={`${u.kind} · ${u.segment} · ${u.contracts} contracts listed`}
                      style={{ fontSize: 10, padding: "2px 8px", whiteSpace: "nowrap",
                               borderColor: inactive && accent ? accent : undefined,
                               color: inactive && accent ? accent : undefined }}>
                      {u.label}
                    </button>
                  );
                })}
              </div>
            </div>
          </div>
          <div style={{ flex: 1, minWidth: 0, padding: "4px 8px", display: "flex", alignItems: "center" }}>
            <ExpiryTabs list={expiries.list} expiry={expiry} setExpiry={setExpiry}
                        loading={expiries.loading} error={expiries.error} />
          </div>
        </div>
      </Panel>

      {/* KPI strip for the selected expiry */}
      <Panel bodyFlush title={`${underlying} · ${expiries.list.find(e => e.date === (chain?.expiry || expiry))?.display || chain?.expiry || expiry || "—"}`}>
        <div style={{ display: "grid", gridTemplateColumns: "repeat(6, 1fr)", alignItems: "stretch" }}>
          <StatCell label="Spot"     value={spot ? spot.toFixed(2) : "—"} />
          <StatCell label="DTE"      value={(expiries.list.find(e => e.date === expiry)?.dte ?? "—") + (expiry ? "d" : "")} />
          <StatCell label="PCR (OI)" value={pcr} sub={<span className={pcr > 1.2 ? "down" : pcr < 0.8 ? "up" : "dim"}>{pcr > 1.2 ? "BEARISH" : pcr < 0.8 ? "BULLISH" : "NEUTRAL"}</span>} />
          <StatCell label="Max Pain" value={maxPainRow?.strike != null ? maxPainRow.strike.toLocaleString() : "—"} tone={maxPainRow && maxPainRow.strike < spot ? "down" : "up"} />
          <StatCell label="ATM IV"   value={chain?.atm_iv != null ? chain.atm_iv.toFixed(1) + "%" : "—"} />
          <StatCell label="Put-Call IV Skew" value={chain?.rows?.length ? ((rows.find(r => r.atm)?.peIv || 0) - (rows.find(r => r.atm)?.ceIv || 0)).toFixed(1) : "—"} />
        </div>
      </Panel>

      <Panel title="Option chain"
        tag={chain?.rows ? `${chain.strike_count_window || rows.length}/${chain.strike_count_full || rows.length} strikes` : ""}
        actions={chain?.error ? <span className="down" style={{ fontSize: 10 }}>{chain.error}</span>
                              : !chain ? <span className="faint" style={{ fontSize: 10 }}>loading…</span>
                              : <span className="faint" style={{ fontSize: 10 }}>live · 30s</span>}
        bodyFlush>
        <div style={{ overflow: "auto" }}>
          <table className="tbl tbl-compact" style={{ fontSize: 10 }}>
            <thead>
              <tr>
                <th colSpan="7" style={{ textAlign: "center", color: "var(--up)", background: "var(--bg-2)", borderRight: "2px solid var(--border-strong)" }}>━━ CALLS ━━</th>
                <th style={{ textAlign: "center", background: "var(--amber)", color: "var(--bg-0)" }}>STRIKE</th>
                <th colSpan="7" style={{ textAlign: "center", color: "var(--down)", background: "var(--bg-2)" }}>━━ PUTS ━━</th>
              </tr>
              <tr>
                <th className="num">OI</th><th className="num">Vol</th><th className="num">IV</th><th className="num">Δ</th><th className="num">Γ</th><th className="num">Θ</th><th className="num" style={{ borderRight: "2px solid var(--border-strong)" }}>LTP</th>
                <th className="num" style={{ background: "var(--bg-3)" }}>K</th>
                <th className="num">LTP</th><th className="num">Θ</th><th className="num">Γ</th><th className="num">Δ</th><th className="num">IV</th><th className="num">Vol</th><th className="num">OI</th>
              </tr>
            </thead>
            <tbody>
              {rows.map(r => {
                const itmCE = r.strike < spot;
                const itmPE = r.strike > spot;
                return (
                  <tr key={r.strike} style={{ background: r.atm ? "rgba(255,176,32,0.06)" : undefined }}>
                    <td className={`num ${itmCE ? "bright" : ""}`} style={{ background: itmCE ? "rgba(34,214,111,0.04)" : undefined }}>{r.ceOi.toLocaleString()}</td>
                    <td className="num">{r.ceVol.toLocaleString()}</td>
                    <td className="num">{r.ceIv.toFixed(1)}</td>
                    <td className="num">{r.ceDelta.toFixed(2)}</td>
                    <td className="num">{r.ceGamma.toFixed(4)}</td>
                    <td className="num">{r.ceTheta.toFixed(2)}</td>
                    <td className={`num bright`} style={{ borderRight: "2px solid var(--border-strong)" }}>{r.ceLtp.toFixed(2)}</td>
                    <td className={`num ${r.atm ? "amber" : "bright"}`} style={{ background: "var(--bg-3)", fontWeight: 600 }}>{r.strike}</td>
                    <td className="num bright">{r.peLtp.toFixed(2)}</td>
                    <td className="num">{r.peTheta.toFixed(2)}</td>
                    <td className="num">{r.peGamma.toFixed(4)}</td>
                    <td className="num">{r.peDelta.toFixed(2)}</td>
                    <td className="num">{r.peIv.toFixed(1)}</td>
                    <td className="num">{r.peVol.toLocaleString()}</td>
                    <td className={`num ${itmPE ? "bright" : ""}`} style={{ background: itmPE ? "rgba(255,77,77,0.04)" : undefined }}>{r.peOi.toLocaleString()}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      </Panel>
    </div>
  );
}

// ================================================================
// BACKTEST
// ================================================================
//
// Maps the backend `backtest_preset` result (snake_case, full schema) into
// the camelCase shape the existing UI components consume.  Centralising the
// transform here means the renderers below stay untouched.
function _mapBacktestResult(payload) {
  if (!payload || !payload.ok) return null;
  const m = payload.metrics || {};
  const eq = payload.equity_curve || [];
  const bh = payload.buy_hold_equity || [];
  const curve = eq.map((p, i) => ({
    i, time: p.time, strategy: p.equity, buyHold: (bh[i] || {}).equity ?? null,
  }));
  return {
    totalReturn: Number(m.total_return_pct) || 0,
    buyHold:     Number(m.buy_hold_return_pct) || 0,
    cagr:        Number(m.cagr_pct) || 0,
    sharpe:      Number(m.sharpe) || 0,
    maxDD:       Number(m.max_drawdown_pct ?? m.max_dd_pct ?? 0),
    winRate:     Number(m.win_rate_pct) || 0,
    trades: (payload.trades || []).map(t => ({
      entry: t.entry_time, exit: t.exit_time,
      side:  t.direction,
      entryPx: Number(t.entry_price) || 0,
      exitPx:  Number(t.exit_price)  || 0,
      pnl:     Number(t.pnl)         || 0,
      pnlPct:  Number(t.pnl_pct)     || 0,
      bars:    t.bars_held,
    })),
    curve,
    _raw: payload,
  };
}

function BacktestPage({ app }) {
  const [params, setParams] = useState({ symbol: "RELIANCE", strategy: "ema_crossover", timeframe: "daily", lookback: 500, capital: 100000, costs: 10, allowShort: false });
  const [result, setResult] = useState(null);
  const [running, setRunning] = useState(false);
  // Live progress shown while a backtest is streaming.  null when idle.
  // Shape: { phase: "fetch"|"sim"|"trades"|"equity"|"done"|"error", bars?, trades?, equity?, error?, started_at? }
  const [progress, setProgress] = useState(null);
  const abortRef = useRef(null);

  const run = async () => {
    if (running) return;
    setRunning(true); setResult(null);
    setProgress({ phase: "starting", started_at: Date.now() });
    const abortCtrl = new AbortController();
    abortRef.current = abortCtrl;
    try {
      const res = await fetch("/api/backtest/stream", {
        method: "POST",
        headers: { "Content-Type": "application/json", "Accept": "text/event-stream" },
        body: JSON.stringify({
          symbol: params.symbol,
          strategy: params.strategy,
          timeframe: params.timeframe,
          lookback_days: params.lookback,
          initial_capital: params.capital,
          cost_bps: params.costs,
          allow_short: params.allowShort,
          exchange_segment: "NSE_EQ",
          strategy_params: {},
        }),
        signal: abortCtrl.signal,
      });
      if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let buffer = "";
      let tradeCount = 0, equityCount = 0;
      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });
        let idx;
        while ((idx = buffer.indexOf("\n\n")) >= 0) {
          const frame = buffer.slice(0, idx);
          buffer = buffer.slice(idx + 2);
          if (!frame) continue;
          let evType = "message", evData = "";
          for (const line of frame.split("\n")) {
            if (line.startsWith("event:"))      evType = line.slice(6).trim();
            else if (line.startsWith("data:"))  evData += line.slice(5).trim();
          }
          if (!evData) continue;
          let payload;
          try { payload = JSON.parse(evData); } catch (_) { continue; }
          if (evType === "backtest_start") {
            setProgress(p => ({ ...p, phase: "fetch" }));
          } else if (evType === "fetch_done") {
            setProgress(p => ({ ...p, phase: "sim", bars: payload.bars, fetch_ms: payload.fetch_ms }));
          } else if (evType === "simulation_done") {
            setProgress(p => ({ ...p, phase: payload.ok ? "trades" : "error",
                                       sim_ms: payload.sim_ms,
                                       error: payload.error }));
          } else if (evType === "trade_simulated") {
            tradeCount += 1;
            setProgress(p => ({ ...p, phase: "trades", trades: tradeCount,
                                       trades_total: payload.total }));
          } else if (evType === "equity_tick") {
            equityCount += 1;
            setProgress(p => ({ ...p, phase: "equity", equity: equityCount,
                                       equity_total: payload.total }));
          } else if (evType === "backtest_done") {
            setResult(_mapBacktestResult(payload.result));
            setProgress(p => ({ ...p, phase: "done" }));
          } else if (evType === "error") {
            setProgress(p => ({ ...p, phase: "error", error: payload.error }));
          }
        }
      }
    } catch (err) {
      if (err && err.name !== "AbortError") {
        setProgress({ phase: "error", error: String(err && err.message || err) });
      }
    } finally {
      setRunning(false);
      abortRef.current = null;
    }
  };

  const cancel = () => {
    if (abortRef.current) {
      try { abortRef.current.abort(); } catch (_) {}
      setProgress(p => ({ ...(p || {}), phase: "cancelled" }));
      setRunning(false);
    }
  };

  return (
    <div style={{ display: "grid", gridTemplateColumns: "260px 1fr", gap: 8, height: "100%", padding: 8, overflow: "hidden" }}>
      <Panel title="Parameters">
        <div className="label">Symbol</div>
        <input className="input" value={params.symbol} onChange={e => setParams({ ...params, symbol: e.target.value.toUpperCase() })} />
        <div className="label" style={{ marginTop: 8 }}>Strategy</div>
        <select className="select" value={params.strategy} onChange={e => setParams({ ...params, strategy: e.target.value })}>
          <option>ema_crossover</option><option>rsi_reversion</option><option>bollinger_breakout</option><option>macd_crossover</option><option>buy_and_hold</option>
        </select>
        <div className="label" style={{ marginTop: 8 }}>Timeframe</div>
        <select className="select" value={params.timeframe} onChange={e => setParams({ ...params, timeframe: e.target.value })}>
          <option>daily</option><option>intraday_60</option><option>intraday_15</option>
        </select>
        <div className="label" style={{ marginTop: 8 }}>Lookback (bars)</div>
        <input className="input" type="number" value={params.lookback} onChange={e => setParams({ ...params, lookback: Number(e.target.value) })} />
        <div className="label" style={{ marginTop: 8 }}>Capital (INR)</div>
        <input className="input" type="number" value={params.capital} onChange={e => setParams({ ...params, capital: Number(e.target.value) })} />
        <div className="label" style={{ marginTop: 8 }}>Costs (bps)</div>
        <input className="input" type="number" value={params.costs} onChange={e => setParams({ ...params, costs: Number(e.target.value) })} />
        <label style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 10, fontSize: 11 }}>
          <input type="checkbox" className="checkbox" checked={params.allowShort} onChange={e => setParams({ ...params, allowShort: e.target.checked })}/>
          allow short
        </label>
        <button className="btn btn-primary" style={{ width: "100%", marginTop: 12 }} onClick={run} disabled={running}>
          {running ? "◉ RUNNING…" : "▸ RUN BACKTEST"}
        </button>
        {running ? (
          <button className="btn" style={{ width: "100%", marginTop: 6, fontSize: 9 }} onClick={cancel}>
            ✕ CANCEL
          </button>
        ) : null}
        {progress ? (
          <div style={{ marginTop: 10, padding: 8, background: "var(--bg-0)",
                        border: "1px solid var(--border)", fontSize: 10 }}>
            <div className="h-xxs" style={{ marginBottom: 4 }}>BACKEND</div>
            <div className={progress.phase === "error" ? "down" : progress.phase === "done" ? "up" : "amber"}>
              {progress.phase === "starting"   ? "Starting…" :
               progress.phase === "fetch"      ? "Fetching candles from Dhan…" :
               progress.phase === "sim"        ? `Simulating · ${progress.bars || 0} bars` :
               progress.phase === "trades"     ? `Trades streaming · ${progress.trades || 0}/${progress.trades_total ?? "?"}` :
               progress.phase === "equity"     ? `Equity ticks · ${progress.equity || 0}/${progress.equity_total ?? "?"}` :
               progress.phase === "done"       ? "✓ Complete" :
               progress.phase === "cancelled"  ? "✕ Cancelled" :
               progress.phase === "error"      ? `✕ ${progress.error || "error"}` :
               progress.phase}
            </div>
            <div className="faint" style={{ fontSize: 9, marginTop: 3 }}>
              {progress.bars   != null ? `${progress.bars} bars · ` : ""}
              {progress.fetch_ms ? `fetch ${progress.fetch_ms}ms · ` : ""}
              {progress.sim_ms ? `sim ${progress.sim_ms}ms` : ""}
            </div>
          </div>
        ) : null}
      </Panel>

      <div style={{ display: "grid", gridTemplateRows: result ? "auto 1fr auto" : "1fr", gap: 8, minHeight: 0 }}>
        {result ? (
          <>
            <Panel title="Metrics">
              <div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: 6 }}>
                <Mini label="Return" value={`${result.totalReturn.toFixed(1)}%`} tone={result.totalReturn >= 0 ? "up" : "down"}/>
                <Mini label="Buy&Hold" value={`${result.buyHold.toFixed(1)}%`}/>
                <Mini label="Alpha" value={`${(result.totalReturn - result.buyHold).toFixed(1)}%`} tone={result.totalReturn >= result.buyHold ? "up" : "down"}/>
                <Mini label="CAGR" value={`${result.cagr.toFixed(1)}%`}/>
                <Mini label="Sharpe" value={result.sharpe.toFixed(2)}/>
                <Mini label="MaxDD" value={`-${result.maxDD.toFixed(1)}%`} tone="down"/>
                <Mini label="Win rate" value={`${result.winRate.toFixed(0)}%`}/>
                <Mini label="# Trades" value={result.trades.length}/>
              </div>
            </Panel>
            <Panel title="Equity curve · strategy vs buy-hold">
              <EquityChart curve={result.curve}/>
            </Panel>
            <Panel title={`Trades · ${result.trades.length}`} bodyFlush>
              <div style={{ maxHeight: 180, overflow: "auto" }}>
                <table className="tbl tbl-compact" style={{ fontSize: 10 }}>
                  <thead><tr><th>Entry</th><th>Exit</th><th>Side</th><th className="num">Entry ₹</th><th className="num">Exit ₹</th><th className="num">P&L ₹</th><th className="num">P&L %</th><th className="num">Bars</th></tr></thead>
                  <tbody>
                    {result.trades.slice(0, 40).map((t, i) => (
                      <tr key={i}>
                        <td className="faint">{t.entry}</td><td className="faint">{t.exit}</td><td>{t.side}</td>
                        <td className="num">{t.entryPx.toFixed(2)}</td><td className="num">{t.exitPx.toFixed(2)}</td>
                        <td className={`num ${t.pnl >= 0 ? "up" : "down"}`}>{t.pnl >= 0 ? "+" : ""}{t.pnl.toFixed(0)}</td>
                        <td className={`num ${t.pnlPct >= 0 ? "up" : "down"}`}>{t.pnlPct >= 0 ? "+" : ""}{t.pnlPct.toFixed(2)}</td>
                        <td className="num">{t.bars}</td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            </Panel>
          </>
        ) : (
          <Panel title="Backtest result">
            <div style={{ padding: 40, textAlign: "center", color: "var(--fg-dim)" }}>
              Configure parameters on the left and run a backtest.
            </div>
          </Panel>
        )}
      </div>
    </div>
  );
}


function EquityChart({ curve }) {
  const w = 800, h = 220;
  const xs = curve.length;
  const allVals = curve.flatMap(c => [c.strategy, c.buyHold]);
  const min = Math.min(...allVals), max = Math.max(...allVals);
  const x = (i) => (i / (xs - 1)) * w;
  const y = (v) => h - ((v - min) / (max - min)) * h;
  const stratPath = "M " + curve.map((c, i) => `${x(i)},${y(c.strategy)}`).join(" L ");
  const bhPath = "M " + curve.map((c, i) => `${x(i)},${y(c.buyHold)}`).join(" L ");
  return (
    <div style={{ position: "relative" }}>
      <svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" width="100%" height="220">
        {[0.25, 0.5, 0.75].map(p => (
          <line key={p} x1={0} x2={w} y1={h * p} y2={h * p} stroke="var(--grid)" strokeDasharray="2 3"/>
        ))}
        <path d={bhPath} fill="none" stroke="var(--fg-faint)" strokeWidth="1"/>
        <path d={stratPath} fill="none" stroke="var(--amber)" strokeWidth="1.6"/>
      </svg>
      <div style={{ display: "flex", gap: 14, justifyContent: "flex-end", fontSize: 10, marginTop: 4 }}>
        <span><span style={{ display: "inline-block", width: 10, height: 2, background: "var(--amber)", marginRight: 4, verticalAlign: "middle" }}/>strategy</span>
        <span><span style={{ display: "inline-block", width: 10, height: 2, background: "var(--fg-faint)", marginRight: 4, verticalAlign: "middle" }}/>buy&hold</span>
      </div>
    </div>
  );
}

// ================================================================
// BOTS
// ================================================================
function BotsPage({ app }) {
  const [editing, setEditing] = useState(null);
  const runBot = app.runBotNow;
  // Live bots subscription — push-immediate on enable/disable/run_now/CRUD,
  // vs the 3s lag if we only read app.bots from the dashboard tick.
  const botsStream = useStream("/api/bots/stream", { bufferSize: 20 });
  const [streamedBots, setStreamedBots] = useState([]);
  useEffect(() => {
    if (botsStream.snapshot && Array.isArray(botsStream.snapshot.bots)) {
      setStreamedBots(botsStream.snapshot.bots);
    }
  }, [botsStream.snapshot]);
  useEffect(() => {
    const ev = botsStream.lastEvent;
    if (!ev) return;
    if (ev.type === "bots_changed" && ev.data && Array.isArray(ev.data.bots)) {
      setStreamedBots(ev.data.bots);
    }
  }, [botsStream.lastEvent]);
  const bots = streamedBots.length ? streamedBots : app.bots;
  return (
    <div style={{ display: "grid", gridTemplateRows: "auto 1fr", gap: 8, height: "100%", padding: 8, overflow: "hidden" }}>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
        <div>
          <div className="h-xs">STRATEGY BOTS</div>
          <div className="faint" style={{ fontSize: 10 }}>Scheduled scanners that generate proposals — proposals still require your confirm.</div>
        </div>
        <button className="btn btn-primary" onClick={() => setEditing(NEW_BOT_TEMPLATE())}>+ NEW BOT</button>
      </div>
      <Panel title="Bots" bodyFlush tag={bots.filter(b => b.enabled).length}>
        <div style={{ overflow: "auto" }}>
          <table className="tbl tbl-compact">
            <thead><tr>
              <th>Name</th><th>Symbols</th><th>Strategy</th><th>TF</th><th>Interval</th><th className="num">Size ₹</th><th>Pause ≥</th>
              <th className="num">Props</th><th className="num">Fills</th><th className="num">P&L</th><th>Last run</th><th>Status</th><th/>
            </tr></thead>
            <tbody>
              {bots.map(b => (
                <tr key={b.bot_id}>
                  <td>
                    <div className="bright" style={{ fontWeight: 500 }}>{b.name}</div>
                    <div className="faint" style={{ fontSize: 9 }}>{b.bot_id}</div>
                  </td>
                  <td style={{ fontSize: 10, maxWidth: 180 }} className="truncate">{b.symbols.join(", ")}</td>
                  <td style={{ fontSize: 10 }}>{b.strategy}<div className="faint" style={{ fontSize: 9 }}>{Object.entries(b.strategy_params || {}).map(([k,v]) => `${k}=${v}`).join(" ")}</div></td>
                  <td style={{ fontSize: 10 }}>{b.timeframe}</td>
                  <td style={{ fontSize: 10 }}>{b.interval_seconds < 3600 ? `${Math.round(b.interval_seconds/60)}m` : b.interval_seconds < 86400 ? `${Math.round(b.interval_seconds/3600)}h` : `${Math.round(b.interval_seconds/86400)}d`}</td>
                  <td className="num">{b.position_size_inr.toLocaleString("en-IN")}</td>
                  <td style={{ fontSize: 10 }}>{b.pause_on_volatility}</td>
                  <td className="num">{b.proposals_today}</td>
                  <td className="num">{b.fills_today}</td>
                  <td className={`num ${b.pnl_today >= 0 ? "up" : "down"}`}>{b.pnl_today >= 0 ? "+" : ""}{b.pnl_today.toFixed(0)}</td>
                  <td className="faint" style={{ fontSize: 9 }}>{b.last_run ? store.relTime(b.last_run) : "—"}</td>
                  <td>
                    {b.enabled ? <span className="pill pill-up"><span className="dot dot-live" style={{ width: 5, height: 5 }}/> RUNNING</span> : <span className="pill pill-dim">STOPPED</span>}
                  </td>
                  <td>
                    <div style={{ display: "flex", gap: 3 }}>
                      <button className="btn btn-xs" onClick={() => app.toggleBot(b.bot_id)}>{b.enabled ? "‖" : "▸"}</button>
                      <button className="btn btn-xs" onClick={() => runBot(b.bot_id)}>⚡</button>
                      <button className="btn btn-xs" onClick={() => setEditing(b)}>edit</button>
                      <button className="btn btn-xs btn-ghost" style={{ color: "var(--down)" }} onClick={() => confirm(`Delete ${b.name}?`) && app.deleteBot(b.bot_id)}>✕</button>
                    </div>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </Panel>
      {editing && <BotEditor bot={editing} onCancel={() => setEditing(null)} onSave={(b) => { if (b.bot_id) app.updateBot(b.bot_id, b); else app.addBot(b); setEditing(null); }} />}
    </div>
  );
}

function NEW_BOT_TEMPLATE() {
  return {
    name: "New Bot",
    symbols: ["RELIANCE", "TCS"],
    strategy: "ema_crossover",
    strategy_params: { fast: 9, slow: 21 },
    timeframe: "intraday_15",
    exchange_segment: "NSE_EQ",
    product_type: "INTRADAY",
    interval_seconds: 900,
    min_bias_score: 0.2,
    max_bias_score: 1.0,
    position_size_inr: 10000,
    stop_loss_pct: 1.5,
    take_profit_pct: 3,
    pause_on_volatility: "ELEVATED",
    enabled: false,
  };
}

function BotEditor({ bot, onCancel, onSave }) {
  const [b, setB] = useState(bot);
  const set = (k, v) => setB({ ...b, [k]: v });
  return (
    <div className="modal-backdrop" onClick={onCancel}>
      <div className="modal" onClick={e => e.stopPropagation()}>
        <div className="panel-header" style={{ padding: "10px 14px" }}>
          <span className="title">{bot.bot_id ? "EDIT BOT" : "NEW BOT"}</span>
          <button className="btn btn-xs btn-ghost" onClick={onCancel}>✕</button>
        </div>
        <div style={{ padding: 14, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
          <div style={{ gridColumn: "1 / -1" }}><div className="label">Name</div><input className="input" value={b.name} onChange={e => set("name", e.target.value)}/></div>
          <div style={{ gridColumn: "1 / -1" }}><div className="label">Symbols (comma-sep)</div><input className="input" value={b.symbols.join(",")} onChange={e => set("symbols", e.target.value.split(",").map(s => s.trim()).filter(Boolean))}/></div>
          <div><div className="label">Strategy</div>
            <select className="select" value={b.strategy} onChange={e => set("strategy", e.target.value)}>
              <option>ema_crossover</option><option>rsi_reversion</option><option>bollinger_breakout</option><option>macd_crossover</option><option>buy_and_hold</option>
            </select>
          </div>
          <div><div className="label">Timeframe</div>
            <select className="select" value={b.timeframe} onChange={e => set("timeframe", e.target.value)}>
              <option>daily</option><option>intraday_60</option><option>intraday_15</option><option>intraday_5</option>
            </select>
          </div>
          <div><div className="label">Exchange</div><input className="input" value={b.exchange_segment} onChange={e => set("exchange_segment", e.target.value)}/></div>
          <div><div className="label">Product</div><select className="select" value={b.product_type} onChange={e => set("product_type", e.target.value)}><option>INTRADAY</option><option>CNC</option><option>MARGIN</option></select></div>
          <div><div className="label">Interval (sec)</div><input className="input" type="number" value={b.interval_seconds} onChange={e => set("interval_seconds", Number(e.target.value))}/></div>
          <div><div className="label">Position size ₹</div><input className="input" type="number" value={b.position_size_inr} onChange={e => set("position_size_inr", Number(e.target.value))}/></div>
          <div><div className="label">Stop loss %</div><input className="input" type="number" step="0.1" value={b.stop_loss_pct} onChange={e => set("stop_loss_pct", Number(e.target.value))}/></div>
          <div><div className="label">Take profit %</div><input className="input" type="number" step="0.1" value={b.take_profit_pct} onChange={e => set("take_profit_pct", Number(e.target.value))}/></div>
          <div><div className="label">Min bias score</div><input className="input" type="number" step="0.1" value={b.min_bias_score} onChange={e => set("min_bias_score", Number(e.target.value))}/></div>
          <div><div className="label">Max bias score</div><input className="input" type="number" step="0.1" value={b.max_bias_score} onChange={e => set("max_bias_score", Number(e.target.value))}/></div>
          <div><div className="label">Pause on volatility ≥</div><select className="select" value={b.pause_on_volatility} onChange={e => set("pause_on_volatility", e.target.value)}><option>NONE</option><option>WATCH</option><option>ELEVATED</option><option>EXTREME</option></select></div>
          <div style={{ display: "flex", alignItems: "end" }}>
            <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11 }}>
              <input type="checkbox" className="checkbox" checked={!!b.enabled} onChange={e => set("enabled", e.target.checked)}/>
              Enable immediately
            </label>
          </div>
        </div>
        <div style={{ padding: 12, borderTop: "1px solid var(--border)", display: "flex", gap: 6, justifyContent: "flex-end" }}>
          <button className="btn" onClick={onCancel}>CANCEL</button>
          <button className="btn btn-primary" onClick={() => onSave(b)}>SAVE</button>
        </div>
      </div>
    </div>
  );
}

// ================================================================
// SETTINGS
// ================================================================
function SettingsPage({ app }) {
  const [showSecrets, setShowSecrets] = useState(false);
  const [confirmLive, setConfirmLive] = useState(false);
  const [settings, setSettings] = useState([]);
  const [loadError, setLoadError] = useState(null);
  const [savingKey, setSavingKey] = useState(null);

  const reload = async (revealSecrets = showSecrets) => {
    try {
      const r = await fetch(`/api/settings?include_secrets=${revealSecrets ? "true" : "false"}`);
      if (!r.ok) throw new Error(`GET /api/settings → HTTP ${r.status}`);
      const j = await r.json();
      setSettings(j.settings || []);
      setLoadError(null);
    } catch (e) {
      setLoadError(String(e));
    }
  };
  useEffect(() => { reload(); /* on mount */ }, []);
  useEffect(() => { reload(showSecrets); }, [showSecrets]);

  const saveSetting = async (key, value) => {
    setSavingKey(key);
    try {
      const r = await fetch(`/api/settings/${key}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ value }),
      });
      if (!r.ok) {
        const t = await r.text().catch(() => "");
        throw new Error(`PUT /api/settings/${key} → HTTP ${r.status} ${t}`);
      }
      await reload();
    } catch (e) {
      alert(`Save failed: ${e}`);
    } finally {
      setSavingKey(null);
    }
  };

  const goLive = async () => {
    if (!confirmLive) return;
    await saveSetting("LIVE_TRADING_ENABLED", "true");
    app.setMode("live");
    setConfirmLive(false);
  };
  const goPaper = async () => {
    await saveSetting("LIVE_TRADING_ENABLED", "false");
    app.setMode("paper");
  };

  const get = (key) => settings.find(s => s.key === key);

  return (
    <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, height: "100%", padding: 8, overflow: "hidden" }}>
      <div style={{ display: "flex", flexDirection: "column", gap: 8, minHeight: 0 }}>
        <Panel title="Trading Mode">
          <div style={{ padding: 10, border: `2px solid ${app.mode === "live" ? "var(--critical)" : "var(--amber)"}`, background: app.mode === "live" ? "rgba(255,56,96,0.08)" : "rgba(255,176,32,0.05)" }}>
            <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
              <span style={{
                width: 14, height: 14,
                background: app.mode === "live" ? "var(--critical)" : "var(--amber)",
                boxShadow: app.mode === "live" ? "0 0 12px var(--critical)" : "0 0 8px var(--amber)",
                animation: app.mode === "live" ? "pulse-dot 1s infinite" : "none",
              }}/>
              <div style={{ flex: 1 }}>
                <div className="bright" style={{ fontSize: 16, fontWeight: 500, letterSpacing: "0.05em" }}>
                  {app.mode === "live" ? "LIVE TRADING" : "PAPER TRADING"}
                </div>
                <div className="faint" style={{ fontSize: 10, marginTop: 2 }}>
                  {app.mode === "live"
                    ? "Real orders, real money. All proposals execute against Dhan broker on confirm."
                    : "Simulated orders. Proposals pass risk checks and log to audit, but no broker call."}
                </div>
              </div>
            </div>
          </div>

          {app.mode === "paper" ? (
            <div style={{ marginTop: 10 }}>
              <label style={{ display: "flex", alignItems: "start", gap: 8, padding: 8, background: "var(--bg-0)", border: "1px solid var(--border)", cursor: "pointer" }}>
                <input type="checkbox" className="checkbox" checked={confirmLive} onChange={e => setConfirmLive(e.target.checked)} style={{ marginTop: 2 }}/>
                <div style={{ fontSize: 10, lineHeight: 1.5 }}>
                  I understand switching to <span className="down" style={{ fontWeight: 600 }}>LIVE TRADING</span> will route orders to my real Dhan account. I have reviewed risk caps, checked regime (currently <span className={app.regime === "NORMAL" ? "up" : "down"}>{app.regime}</span>), and accept responsibility for any fills.
                </div>
              </label>
              <button className="btn btn-danger" style={{ width: "100%", marginTop: 8 }} disabled={!confirmLive} onClick={goLive}>
                ⚠ SWITCH TO LIVE TRADING
              </button>
            </div>
          ) : (
            <button className="btn btn-primary" style={{ width: "100%", marginTop: 10 }} onClick={goPaper}>
              ↩ REVERT TO PAPER
            </button>
          )}
        </Panel>

        <Panel title="Risk Caps (quick edit)">
          <dl className="kv">
            <dt>Per-order max</dt><dd>{get("MAX_ORDER_VALUE_INR")?.effective ? `₹${Number(get("MAX_ORDER_VALUE_INR").effective).toLocaleString("en-IN")}` : "—"}</dd>
            <dt>Total exposure</dt><dd>{get("MAX_TOTAL_EXPOSURE_INR")?.effective ? `₹${Number(get("MAX_TOTAL_EXPOSURE_INR").effective).toLocaleString("en-IN")}` : "—"}</dd>
            <dt>Daily loss</dt><dd>{get("MAX_DAILY_LOSS_INR")?.effective ? `₹${Number(get("MAX_DAILY_LOSS_INR").effective).toLocaleString("en-IN")}` : "—"}</dd>
            <dt>Orders/day</dt><dd>{get("MAX_ORDERS_PER_DAY")?.effective || "—"}</dd>
            <dt>Allowed exchanges</dt><dd>{get("ALLOWED_EXCHANGES")?.effective || "—"}</dd>
            <dt>Allowed products</dt><dd>{get("ALLOWED_PRODUCT_TYPES")?.effective || "—"}</dd>
          </dl>
        </Panel>
      </div>

      <Panel title="All Settings" bodyFlush
        actions={
          <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 10 }}>
            <input type="checkbox" className="checkbox" checked={showSecrets} onChange={e => setShowSecrets(e.target.checked)}/>
            reveal secrets
          </label>
        }
      >
        {loadError ? (
          <div style={{ padding: 10, color: "var(--critical)", fontSize: 11 }}>load failed: {loadError}</div>
        ) : null}
        <div style={{ overflow: "auto" }}>
          <table className="tbl tbl-compact">
            <thead><tr><th>KEY</th><th>VALUE</th><th>SOURCE</th><th/></tr></thead>
            <tbody>
              {settings.map(s => (
                <SettingRow key={s.key} s={s} reveal={showSecrets}
                            saving={savingKey === s.key}
                            onSave={v => saveSetting(s.key, v)} />
              ))}
            </tbody>
          </table>
        </div>
      </Panel>
    </div>
  );
}

function SettingRow({ s, reveal, saving, onSave }) {
  const initial = s.value ?? "";
  const [val, setVal] = useState(initial);
  useEffect(() => setVal(initial), [initial]);
  const masked = s.is_secret && !reveal;

  // Map backend source labels → short pill text + colour
  const srcMap = {
    secret_manager: { txt: "SM",      cls: "pill-up"    },
    env:            { txt: "ENV",     cls: "pill-cyan"  },
    db:             { txt: "DB",      cls: "pill-up"    },
    "env/default":  { txt: "DEFAULT", cls: "pill-dim"   },
    missing:        { txt: "MISSING", cls: "pill-amber" },
  };
  const src = srcMap[s.source] || { txt: s.source || "?", cls: "pill-dim" };

  return (
    <tr>
      <td>
        <div className="bright" style={{ fontSize: 11 }}>{s.key}</div>
        <div className="faint" style={{ fontSize: 9 }}>{s.description || ""}</div>
      </td>
      <td>
        <input className="input" type={masked ? "password" : "text"} value={val} onChange={e => setVal(e.target.value)} style={{ fontSize: 10, minWidth: 180 }}/>
      </td>
      <td><span className={`pill ${src.cls}`} style={{ fontSize: 9 }} title={s.source}>{src.txt}</span></td>
      <td><button className="btn btn-xs btn-primary" disabled={saving || val === initial} onClick={() => onSave(val)}>{saving ? "…" : "SAVE"}</button></td>
    </tr>
  );
}

// ============================================================================
// IndicatorChart — multi-panel SVG chart fed from /api/symbols/{sym}/indicators_timeseries
// ============================================================================
function IndicatorChart({ symbol }) {
  const [data, setData] = useState(null);
  const [active, setActive] = useState(new Set(["ema20", "ema50", "ema200", "bb_upper", "bb_lower"]));
  const [osc, setOsc]       = useState("rsi14");   // which oscillator to show below price

  useEffect(() => {
    let stopped = false;
    const seg = (store.WATCH.find(w => w.sym === symbol) || {}).exch || "NSE_EQ";
    (async () => {
      try {
        const res = await fetch(`/api/symbols/${encodeURIComponent(symbol)}/indicators_timeseries?days=120&exchange_segment=${encodeURIComponent(seg)}`);
        if (!res.ok) throw new Error("HTTP " + res.status);
        const d = await res.json();
        if (!stopped) setData(d);
      } catch (e) { if (!stopped) setData({ error: String(e) }); }
    })();
    return () => { stopped = true; };
  }, [symbol]);

  if (!data) return <div className="faint" style={{ padding: 20, textAlign: "center", fontSize: 11 }}>Loading indicator series…</div>;
  if (data.error) return <div className="down" style={{ padding: 12, fontSize: 11 }}>{data.error}</div>;

  const overlays = [
    { k: "ema20",    color: "var(--amber)" },
    { k: "ema50",    color: "var(--cyan)"  },
    { k: "ema200",   color: "var(--violet)"},
    { k: "bb_upper", color: "var(--fg-faint)", dash: true },
    { k: "bb_lower", color: "var(--fg-faint)", dash: true },
    { k: "vwap",     color: "#8cff8c" },
  ];
  const oscs = ["rsi14", "macd", "adx14", "cci20", "mfi14", "williams_r", "stoch_k", "roc10"];

  const close = data.ohlc.close;
  if (!close || close.length < 2) return <div className="faint">no data</div>;
  const W = 880, Hp = 180, Ho = 90, pad = 24;

  const xStep = (W - pad * 2) / (close.length - 1);
  const xAt = (i) => pad + i * xStep;

  // Price panel scale
  const priceMin = Math.min(...close.filter(v => v != null));
  const priceMax = Math.max(...close.filter(v => v != null));
  const yPrice = (v) => pad + (Hp - pad * 2) * (1 - (v - priceMin) / (priceMax - priceMin || 1));

  // Oscillator panel scale
  const oscSeries = data.series[osc] || [];
  const valid = oscSeries.filter(v => v != null);
  const oscMin = valid.length ? Math.min(...valid) : 0;
  const oscMax = valid.length ? Math.max(...valid) : 1;
  const yOsc = (v) => (Hp + 12) + pad + (Ho - pad * 2) * (1 - (v - oscMin) / (oscMax - oscMin || 1));

  // Path helper — skips null gaps
  const linePath = (arr, yFn) => {
    let d = "", started = false;
    arr.forEach((v, i) => {
      if (v == null) { started = false; return; }
      d += (started ? " L " : "M ") + `${xAt(i).toFixed(1)},${yFn(v).toFixed(1)}`;
      started = true;
    });
    return d;
  };

  const toggle = (k) => {
    setActive(prev => { const s = new Set(prev); s.has(k) ? s.delete(k) : s.add(k); return s; });
  };

  return (
    <div style={{ padding: 8 }}>
      <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 8 }}>
        {overlays.map(o => (
          <button key={o.k} onClick={() => toggle(o.k)}
            className={`chip ${active.has(o.k) ? "active" : ""}`}
            style={active.has(o.k) ? {} : { color: o.color, borderColor: o.color }}>
            {o.k}
          </button>
        ))}
        <div style={{ width: 1, background: "var(--border)" }}/>
        <span className="h-xxs" style={{ alignSelf: "center" }}>OSC</span>
        {oscs.map(o => (
          <button key={o} onClick={() => setOsc(o)} className={`chip ${osc === o ? "active" : ""}`}>{o}</button>
        ))}
      </div>

      <svg width="100%" viewBox={`0 0 ${W} ${Hp + Ho + 12}`} style={{ display: "block" }}>
        {/* Price panel bg */}
        <rect x="0" y="0" width={W} height={Hp} fill="var(--bg-0)"/>
        {/* Price line */}
        <path d={linePath(close, yPrice)} stroke="var(--fg-bright)" strokeWidth="1.3" fill="none"/>
        {/* Overlays */}
        {overlays.map(o => active.has(o.k) ? (
          <path key={o.k} d={linePath(data.series[o.k] || [], yPrice)}
            stroke={o.color} strokeWidth="1" fill="none"
            strokeDasharray={o.dash ? "3,3" : undefined} opacity="0.95"/>
        ) : null)}
        {/* Price min/max labels */}
        <text x={pad} y={12} fontSize="9" fill="var(--fg-dim)">{priceMax.toFixed(2)}</text>
        <text x={pad} y={Hp - 4} fontSize="9" fill="var(--fg-dim)">{priceMin.toFixed(2)}</text>

        {/* Divider */}
        <line x1="0" y1={Hp + 6} x2={W} y2={Hp + 6} stroke="var(--border)"/>

        {/* Oscillator panel bg */}
        <rect x="0" y={Hp + 12} width={W} height={Ho} fill="var(--bg-0)"/>
        {/* Guides for RSI */}
        {osc === "rsi14" ? (
          <>
            <line x1={pad} y1={yOsc(70)} x2={W - pad} y2={yOsc(70)} stroke="var(--down)" strokeDasharray="2,3" opacity="0.5"/>
            <line x1={pad} y1={yOsc(30)} x2={W - pad} y2={yOsc(30)} stroke="var(--up)"   strokeDasharray="2,3" opacity="0.5"/>
          </>
        ) : null}
        <path d={linePath(oscSeries, yOsc)} stroke="var(--amber)" strokeWidth="1.2" fill="none"/>
        <text x={pad} y={Hp + 22} fontSize="9" fill="var(--fg-dim)">{osc}  {oscMax.toFixed(1)}</text>
        <text x={pad} y={Hp + Ho + 6} fontSize="9" fill="var(--fg-dim)">{oscMin.toFixed(1)}</text>
      </svg>
      <div className="faint" style={{ fontSize: 9, marginTop: 4 }}>
        {data.candles} daily bars · computed fresh from OHLCV (no separate time-series storage needed)
      </div>
    </div>
  );
}

// ============================================================================
// SymbolGraphAll — symbol price chart stacked with every available indicator,
// fed by /api/symbols/{sym}/indicators_timeseries.  All values are real (no
// placeholders) — the backend recomputes every series per bar from OHLCV.
// ============================================================================
function SymbolGraphAll({ symbol }) {
  const [data, setData] = useState(null);
  const [overlays, setOverlays] = useState(new Set([
    "ema20", "ema50", "ema200", "bb_upper", "bb_lower", "vwap",
  ]));
  const [oscs, setOscs] = useState(new Set([
    "rsi14", "macd", "adx14",
  ]));
  const [hover, setHover] = useState(null);

  useEffect(() => {
    let stopped = false;
    setData(null);
    const seg = (store.WATCH.find(w => w.sym === symbol) || {}).exch || "NSE_EQ";
    (async () => {
      try {
        const res = await fetch(`/api/symbols/${encodeURIComponent(symbol)}/indicators_timeseries?days=180&exchange_segment=${encodeURIComponent(seg)}`);
        if (!res.ok) throw new Error("HTTP " + res.status);
        const j = await res.json();
        if (!stopped) setData(j);
      } catch (e) { if (!stopped) setData({ error: String(e) }); }
    })();
    return () => { stopped = true; };
  }, [symbol]);

  if (!data) return <div className="faint" style={{ padding: 20, textAlign: "center", fontSize: 11 }}>Loading {symbol} indicator series…</div>;
  if (data.error) return <div className="down" style={{ padding: 12, fontSize: 11 }}>{data.error}</div>;

  const close = data.ohlc.close;
  if (!close || close.length < 2) return <div className="faint" style={{ padding: 12 }}>no data</div>;

  const OVERLAY_DEFS = [
    { k: "ema9",     color: "#a3d977", dash: false },
    { k: "ema20",    color: "#ffb020", dash: false },
    { k: "ema50",    color: "#4ec9f0", dash: false },
    { k: "ema100",   color: "#80deea", dash: false },
    { k: "ema200",   color: "#b18cff", dash: false },
    { k: "bb_upper", color: "#8a909b", dash: true  },
    { k: "bb_mid",   color: "#8a909b", dash: true  },
    { k: "bb_lower", color: "#8a909b", dash: true  },
    { k: "vwap",     color: "#22d66f", dash: false },
    { k: "kelt_upper", color: "#5ec5b8", dash: true },
    { k: "kelt_lower", color: "#5ec5b8", dash: true },
    { k: "donch_upper", color: "#f48fb1", dash: true },
    { k: "donch_lower", color: "#f48fb1", dash: true },
    { k: "ichi_tenkan", color: "#ffd966", dash: false },
    { k: "ichi_kijun",  color: "#ff8a65", dash: false },
    { k: "hi_52w",   color: "#ff6b6b", dash: true },
    { k: "lo_52w",   color: "#aed581", dash: true },
    { k: "ha_close", color: "#9fa8da", dash: false },
  ];
  const OSC_DEFS = [
    { k: "rsi14",      label: "RSI 14",     color: "#b18cff", guides: [30, 70], range: [0, 100] },
    { k: "macd",       label: "MACD",       color: "#22d66f", signalKey: "macd_signal", zero: true },
    { k: "adx14",      label: "ADX 14",     color: "#ffb020", guides: [25],     range: [0, 60]  },
    { k: "plus_di",    label: "+DI / -DI",  color: "#22d66f", signalKey: "minus_di", range: [0, 60],
                       signalColor: "#ff4d4d" },
    { k: "stoch_k",    label: "Stoch %K",   color: "#4ec9f0", guides: [20, 80], range: [0, 100] },
    { k: "cci20",      label: "CCI 20",     color: "#ff7ad9", guides: [-100, 100], zero: true   },
    { k: "mfi14",      label: "MFI 14",     color: "#5ec5b8", guides: [20, 80], range: [0, 100] },
    { k: "williams_r", label: "Williams %R",color: "#ffc850", guides: [-80, -20], range: [-100, 0] },
    { k: "roc10",      label: "ROC 10",     color: "#ff8a4d", zero: true },
    { k: "tsi",        label: "TSI",        color: "#9fa8da", zero: true },
    { k: "dpo20",      label: "DPO 20",     color: "#a3d977", zero: true },
    { k: "obv",        label: "OBV",        color: "#7fa7ff" },
    { k: "cmf20",      label: "CMF 20",     color: "#ce93d8", zero: true },
    { k: "vortex_plus",label: "Vortex",     color: "#22d66f", signalKey: "vortex_minus",
                       signalColor: "#ff4d4d" },
    { k: "aroon_up",   label: "Aroon",      color: "#22d66f", signalKey: "aroon_down", range: [0, 100],
                       signalColor: "#ff4d4d", guides: [50] },
    { k: "rvol20",     label: "RVOL 20",    color: "#ffb020", guides: [1, 1.5], zero: false },
    { k: "atr_pct",    label: "ATR%",       color: "#ff8a65", guides: [3] },
    { k: "zscore20",   label: "Z 20",       color: "#ff7ad9", guides: [-2, 2], zero: true },
    { k: "pos_52w",    label: "52w pos %",  color: "#aed581", guides: [20, 80], range: [0, 100] },
    { k: "chop14",     label: "Chop 14",    color: "#ffd966", guides: [38.2, 61.8], range: [0, 100] },
    { k: "supertrend_dir", label: "Super dir", color: "#80deea", guides: [0], range: [-1.2, 1.2] },
    { k: "ha_dir",     label: "HA dir",     color: "#f48fb1", guides: [0], range: [-1.2, 1.2] },
  ];

  // Geometry
  const W = 920, padL = 28, padR = 56;
  const H_PRICE = 240, H_OSC = 70, GAP = 6;
  const activeOscs = OSC_DEFS.filter(o => oscs.has(o.k));
  const totalH = H_PRICE + activeOscs.length * (H_OSC + GAP);

  const xStep = (W - padL - padR) / Math.max(1, close.length - 1);
  const xAt = (i) => padL + i * xStep;

  // Price scale — include any active overlay extents
  const priceVals = [...close];
  OVERLAY_DEFS.forEach(o => {
    if (overlays.has(o.k) && data.series[o.k]) {
      data.series[o.k].forEach(v => { if (v != null) priceVals.push(v); });
    }
  });
  const valid = priceVals.filter(v => v != null && Number.isFinite(v));
  const pMin = Math.min(...valid);
  const pMax = Math.max(...valid);
  const yPrice = (v) => 8 + (H_PRICE - 16) * (1 - (v - pMin) / (pMax - pMin || 1));

  // Path helper — gap-aware
  const linePath = (arr, yFn) => {
    let d = "", started = false;
    arr.forEach((v, i) => {
      if (v == null || !Number.isFinite(v)) { started = false; return; }
      d += (started ? " L " : "M ") + `${xAt(i).toFixed(1)},${yFn(v).toFixed(1)}`;
      started = true;
    });
    return d;
  };

  // Volume bars at bottom of price panel
  const vol = data.ohlc.volume || [];
  const volMax = vol.length ? Math.max(...vol.filter(v => v != null && Number.isFinite(v))) : 0;
  const volBarH = 36;
  const yVol = (v) => H_PRICE - (volBarH * (v / (volMax || 1)));

  // Per-oscillator panel positions
  const oscRows = activeOscs.map((o, idx) => {
    const yTop = H_PRICE + GAP + idx * (H_OSC + GAP);
    const series = data.series[o.k] || [];
    const sigSeries = o.signalKey ? (data.series[o.signalKey] || []) : null;
    const validS = series.filter(v => v != null && Number.isFinite(v));
    let lo = o.range ? o.range[0] : (validS.length ? Math.min(...validS) : 0);
    let hi = o.range ? o.range[1] : (validS.length ? Math.max(...validS) : 1);
    if (sigSeries) {
      sigSeries.forEach(v => { if (v != null && Number.isFinite(v)) { lo = Math.min(lo, v); hi = Math.max(hi, v); } });
    }
    if (lo === hi) hi = lo + 1;
    const yFn = (v) => yTop + 4 + (H_OSC - 8) * (1 - (v - lo) / (hi - lo));
    return { def: o, yTop, series, sigSeries, lo, hi, yFn };
  });

  const lastClose = close[close.length - 1];
  const firstClose = close.find(v => v != null) || lastClose;
  const periodChg = lastClose != null && firstClose ? ((lastClose - firstClose) / firstClose) * 100 : 0;

  const toggleOverlay = (k) => setOverlays(p => { const s = new Set(p); s.has(k) ? s.delete(k) : s.add(k); return s; });
  const toggleOsc     = (k) => setOscs(p => { const s = new Set(p); s.has(k) ? s.delete(k) : s.add(k); return s; });

  // Hover crosshair
  const onMove = (e) => {
    const svg = e.currentTarget;
    const rect = svg.getBoundingClientRect();
    const px = (e.clientX - rect.left) * (W / rect.width);
    const idx = Math.max(0, Math.min(close.length - 1, Math.round((px - padL) / xStep)));
    setHover(idx);
  };

  const T = data.time || [];

  return (
    <div style={{ padding: 8, overflow: "auto", height: "100%" }}>
      {/* Header strip — what we're looking at */}
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 6, gap: 12, flexWrap: "wrap" }}>
        <div>
          <span className="bright" style={{ fontSize: 14, fontWeight: 500 }}>{data.symbol}</span>
          <span className="faint" style={{ fontSize: 10, marginLeft: 8 }}>{data.candles} daily bars</span>
          <span className={`tnum ${periodChg >= 0 ? "up" : "down"}`} style={{ fontSize: 11, marginLeft: 12 }}>
            {periodChg >= 0 ? "+" : ""}{periodChg.toFixed(2)}% over period
          </span>
          {data.computed_at ? (() => {
            const t = new Date(data.computed_at);
            const hh = String(t.getHours()).padStart(2, "0");
            const mm = String(t.getMinutes()).padStart(2, "0");
            const ss = String(t.getSeconds()).padStart(2, "0");
            return (
              <span style={{ fontSize: 10, marginLeft: 12, color: data.cache === "hit" ? "var(--up)" : "var(--amber)" }}
                title={`Background batch · ${data.cache === "hit" ? "cache hit" : "cache miss"} · batch took ${data.compute_ms?.toFixed?.(0) ?? "?"}ms${data.symbol_compute_ms != null ? ` · this symbol ${data.symbol_compute_ms.toFixed(0)}ms` : ""}`}>
                {data.cache === "hit" ? "✓" : "·"} computed {hh}:{mm}:{ss}
                {data.compute_ms != null ? ` · ${data.compute_ms.toFixed(0)}ms` : ""}
              </span>
            );
          })() : null}
        </div>
        {hover != null && T[hover] ? (
          <div className="faint tnum" style={{ fontSize: 10 }}>
            {T[hover]} · O {fmt(data.ohlc.open[hover], 2)} H {fmt(data.ohlc.high[hover], 2)} L {fmt(data.ohlc.low[hover], 2)} C {fmt(close[hover], 2)}
          </div>
        ) : null}
      </div>

      {/* Overlay toggles */}
      <div style={{ display: "flex", gap: 4, flexWrap: "wrap", marginBottom: 6, alignItems: "center" }}>
        <span className="h-xxs" style={{ alignSelf: "center" }}>OVERLAYS</span>
        {OVERLAY_DEFS.map(o => (
          <button key={o.k} onClick={() => toggleOverlay(o.k)}
            className={`chip ${overlays.has(o.k) ? "active" : ""}`}
            style={overlays.has(o.k) ? {} : { color: o.color, borderColor: o.color }}>
            {o.k}
          </button>
        ))}
      </div>

      {/* Oscillator toggles */}
      <div style={{ display: "flex", gap: 4, flexWrap: "wrap", marginBottom: 8, alignItems: "center" }}>
        <span className="h-xxs" style={{ alignSelf: "center" }}>PANELS</span>
        {OSC_DEFS.map(o => (
          <button key={o.k} onClick={() => toggleOsc(o.k)}
            className={`chip ${oscs.has(o.k) ? "active" : ""}`}
            style={oscs.has(o.k) ? {} : { color: o.color, borderColor: o.color }}>
            {o.label}
          </button>
        ))}
      </div>

      <svg width="100%" height={totalH} viewBox={`0 0 ${W} ${totalH}`}
           preserveAspectRatio="none"
           style={{ display: "block", background: "var(--bg-0)" }}
           onMouseMove={onMove} onMouseLeave={() => setHover(null)}>
        {/* Price panel background + frame */}
        <rect x="0" y="0" width={W} height={H_PRICE} fill="var(--bg-0)" stroke="var(--border)"/>

        {/* Volume bars (behind price) */}
        {vol.length ? vol.map((v, i) => v == null ? null : (
          <rect key={`v${i}`} x={xAt(i) - Math.max(1, xStep * 0.4)} y={yVol(v)}
            width={Math.max(1, xStep * 0.8)} height={H_PRICE - yVol(v)} fill="var(--border)" opacity="0.45"/>
        )) : null}

        {/* Price line */}
        <path d={linePath(close, yPrice)} stroke="var(--fg-bright)" strokeWidth="1.4" fill="none"/>

        {/* Overlays */}
        {OVERLAY_DEFS.map(o => overlays.has(o.k) && data.series[o.k] ? (
          <path key={o.k} d={linePath(data.series[o.k], yPrice)}
            stroke={o.color} strokeWidth="1" fill="none"
            strokeDasharray={o.dash ? "3,3" : undefined} opacity="0.95"/>
        ) : null)}

        {/* Price min/max axis labels */}
        <text x={W - padR + 4} y={12} fontSize="9" fill="var(--fg-dim)">{pMax.toFixed(2)}</text>
        <text x={W - padR + 4} y={H_PRICE - 4} fontSize="9" fill="var(--fg-dim)">{pMin.toFixed(2)}</text>
        <text x={W - padR + 4} y={yPrice(lastClose) + 3} fontSize="9" fill="var(--amber)" fontWeight="500">
          {lastClose != null ? lastClose.toFixed(2) : ""}
        </text>
        <line x1={padL} y1={yPrice(lastClose)} x2={W - padR} y2={yPrice(lastClose)} stroke="var(--amber)" strokeDasharray="2,3" opacity="0.4"/>

        {/* Oscillator panels */}
        {oscRows.map(({ def, yTop, series, sigSeries, lo, hi, yFn }) => (
          <g key={def.k}>
            <rect x="0" y={yTop} width={W} height={H_OSC} fill="var(--bg-0)" stroke="var(--border)"/>
            {/* Guides */}
            {def.guides ? def.guides.map(g => (
              <line key={`g${g}`} x1={padL} y1={yFn(g)} x2={W - padR} y2={yFn(g)}
                stroke={(g === 30 || g === 20 || g < 0 ? "var(--up)" : "var(--down)")}
                strokeDasharray="2,3" opacity="0.4"/>
            )) : null}
            {def.zero ? (
              <line x1={padL} y1={yFn(0)} x2={W - padR} y2={yFn(0)} stroke="var(--fg-dim)" strokeDasharray="2,3" opacity="0.4"/>
            ) : null}
            {/* MACD histogram (when applicable) */}
            {def.k === "macd" && sigSeries ? series.map((v, i) => {
              if (v == null || sigSeries[i] == null) return null;
              const h = v - sigSeries[i];
              const yZero = yFn(0);
              const y = yFn(h);
              return <rect key={`h${i}`} x={xAt(i) - Math.max(1, xStep * 0.35)} y={Math.min(yZero, y)}
                width={Math.max(1, xStep * 0.7)} height={Math.abs(yZero - y)}
                fill={h >= 0 ? "var(--up)" : "var(--down)"} opacity="0.5"/>;
            }) : null}
            {/* Signal line — uses signalColor when set (Vortex/Aroon/DI), else amber */}
            {sigSeries ? (
              <path d={linePath(sigSeries, yFn)} stroke={def.signalColor || "var(--amber)"} strokeWidth="1" fill="none" opacity="0.85"/>
            ) : null}
            {/* Main oscillator line */}
            <path d={linePath(series, yFn)} stroke={def.color} strokeWidth="1.2" fill="none"/>
            {/* Labels */}
            <text x={padL + 2} y={yTop + 11} fontSize="9" fill="var(--fg-dim)" fontWeight="500">{def.label}</text>
            <text x={W - padR + 4} y={yTop + 11} fontSize="9" fill="var(--fg-dim)">{Number(hi).toFixed(2)}</text>
            <text x={W - padR + 4} y={yTop + H_OSC - 2} fontSize="9" fill="var(--fg-dim)">{Number(lo).toFixed(2)}</text>
            {/* Last value */}
            {(() => {
              const lastV = [...series].reverse().find(v => v != null && Number.isFinite(v));
              if (lastV == null) return null;
              return <text x={W - padR + 4} y={yFn(lastV) + 3} fontSize="9" fill={def.color} fontWeight="500">{Number(lastV).toFixed(2)}</text>;
            })()}
          </g>
        ))}

        {/* Crosshair */}
        {hover != null ? (
          <line x1={xAt(hover)} y1="0" x2={xAt(hover)} y2={totalH} stroke="var(--fg-dim)" strokeDasharray="2,3" opacity="0.6"/>
        ) : null}
      </svg>

      <div className="faint" style={{ fontSize: 9, marginTop: 6 }}>
        live · all series recomputed per bar from real OHLCV via /api/symbols/{symbol}/indicators_timeseries · click chips to toggle
      </div>
    </div>
  );
}

// Distinct colour palette for RRG sector / stock series.
const COLORS_PALETTE = ["#ffb020", "#4ec9f0", "#b18cff", "#22d66f", "#ff7ad9",
                        "#5ec5b8", "#ffc850", "#ff8a4d", "#7fa7ff", "#ce93d8",
                        "#a3d977", "#ff6b6b", "#ffd966", "#80deea", "#f48fb1",
                        "#aed581", "#ff8a65", "#9fa8da"];

// ============================================================================
// SectorRRG — Relative Rotation Graph (Julius de Kempenaer 4-quadrant view)
// fed from /api/sectors/rrg.  X = RS-Ratio, Y = RS-Momentum, both centred 100.
// Each sector is a coloured tail of the last N weekly points ending at a
// labelled head dot.  Quadrants:
//   TR  LEADING       (strong + accelerating)
//   BR  WEAKENING     (strong, momentum rolling over)
//   BL  LAGGING       (weak + decelerating)
//   TL  IMPROVING     (weak, momentum turning up)
// ============================================================================
function SectorRRG() {
  const [data, setData] = useState(null);
  const [tail, setTail] = useState(10);
  const [hover, setHover] = useState(null);
  const [mode, setMode] = useState("sectors");      // "sectors" | "stocks"
  const [hidden, setHidden] = useState(new Set());  // labels the user has unchecked
  const [filterOpen, setFilterOpen] = useState(false);

  useEffect(() => {
    let stopped = false;
    setData(null);
    const load = async () => {
      try {
        const res = await fetch(`/api/sectors/rrg?tail_weeks=${tail}&mode=${mode}`, { cache: "no-store" });
        if (!res.ok) throw new Error("HTTP " + res.status);
        const j = await res.json();
        if (!stopped) setData(j);
      } catch (e) { if (!stopped) setData({ error: String(e) }); }
    };
    load();
    const t = setInterval(load, 5 * 60 * 1000);
    return () => { stopped = true; clearInterval(t); };
  }, [tail, mode]);

  // Reset filter when switching mode (different label universes)
  useEffect(() => { setHidden(new Set()); }, [mode]);

  if (!data) return <div className="faint" style={{ padding: 24, textAlign: "center", fontSize: 11 }}>Loading RRG…</div>;
  if (data.error) return <div className="down" style={{ padding: 12, fontSize: 11 }}>{data.error}</div>;
  const allSectors = data.sectors || [];
  const sectors = allSectors.filter(s => !hidden.has(s.label));
  if (!allSectors.length) return <div className="faint" style={{ padding: 12 }}>no data — backend may need more history</div>;
  if (!sectors.length) return <div className="faint" style={{ padding: 12 }}>all items hidden — pick at least one in the filter</div>;

  // SVG geometry — wide rectangular viewBox.  RS-Ratio (X) usually has a
  // wider data range than RS-Momentum (Y) over short windows, so the
  // wider X axis gives more room for sector labels to spread out.
  const W = 960, H = 540, padL = 44, padR = 20, padT = 24, padB = 32;
  const center = data.axis_center || 100;

  // Auto-scale.  X and Y are scaled INDEPENDENTLY around 100 so a wide
  // momentum range doesn't squash relative-strength resolution (and vice
  // versa).  Heads of all sectors fit comfortably; tail outliers clip to
  // the chart edge.  Each axis gets a symmetric range around 100 with a
  // sensible minimum so an all-clustered universe still spans visibly.
  const heads = sectors.map(s => s.head);
  const tails = sectors.flatMap(s => s.tail);  // include some tail influence
  // Use the 90th percentile of |head - center| + a touch of recent tail to
  // pick the span, ignoring the 10% most extreme tail outliers.
  const headXs = heads.map(p => p.x), headYs = heads.map(p => p.y);
  // Recent tail = last 4 points per sector — keeps trail visible.
  const recentTails = sectors.flatMap(s => s.tail.slice(-4));
  const considerXs = [...headXs, ...recentTails.map(p => p.x)];
  const considerYs = [...headYs, ...recentTails.map(p => p.y)];
  const xSpan = Math.max(4, Math.max(...considerXs.map(v => Math.abs(v - center)))) + 1.5;
  const ySpan = Math.max(4, Math.max(...considerYs.map(v => Math.abs(v - center)))) + 1.5;
  const xMin = center - xSpan, xMax = center + xSpan;
  const yMin = center - ySpan, yMax = center + ySpan;
  const xRange = xMax - xMin, yRange = yMax - yMin;
  // Clamp helper for tail points that fall outside the view.
  const clampX = (v) => Math.max(xMin, Math.min(xMax, v));
  const clampY = (v) => Math.max(yMin, Math.min(yMax, v));

  const xAt = (v) => padL + ((v - xMin) / xRange) * (W - padL - padR);
  const yAt = (v) => padT + (1 - (v - yMin) / yRange) * (H - padT - padB);

  const colorOf = (i) => COLORS_PALETTE[i % COLORS_PALETTE.length];

  // Compute label y-offsets so overlapping labels stagger.  Sort by head y,
  // walk top-down, push each label down if it's within minGap of the prior.
  const labelOffsets = (() => {
    const minGap = 12; // viewBox units
    const items = sectors.map((s, i) => ({ i, sy: yAt(s.head.y) }))
                         .sort((a, b) => a.sy - b.sy);
    const out = {};
    let last = -Infinity;
    items.forEach(it => {
      const ny = Math.max(last + minGap, it.sy);
      out[it.i] = ny - it.sy;
      last = ny;
    });
    return out;
  })();

  const QUAD_LABELS = [
    { x: xMax, y: yMax, txt: "LEADING",   tone: "var(--up)",     align: "end",   baseline: "hanging" },
    { x: xMax, y: yMin, txt: "WEAKENING", tone: "var(--amber)",  align: "end",   baseline: "baseline" },
    { x: xMin, y: yMin, txt: "LAGGING",   tone: "var(--down)",   align: "start", baseline: "baseline" },
    { x: xMin, y: yMax, txt: "IMPROVING", tone: "var(--cyan)",   align: "start", baseline: "hanging" },
  ];

  const visibleCount = sectors.length;
  const totalCount = allSectors.length;
  const noun = mode === "stocks" ? "stocks" : "sectors";

  return (
    <div style={{ padding: 8, position: "relative" }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8, flexWrap: "wrap", gap: 8 }}>
        <div className="faint" style={{ fontSize: 10, display: "flex", alignItems: "center", gap: 8 }}>
          {/* Mode toggle */}
          <span className="h-xxs">MODE</span>
          <button className={`chip ${mode === "sectors" ? "active" : ""}`} onClick={() => setMode("sectors")} style={{ fontSize: 9, padding: "2px 8px" }}>SECTORS</button>
          <button className={`chip ${mode === "stocks" ? "active" : ""}`} onClick={() => setMode("stocks")} style={{ fontSize: 9, padding: "2px 8px" }}>STOCKS</button>
          <span style={{ width: 1, height: 14, background: "var(--border)" }}/>
          {/* Tail length */}
          benchmark <span className="bright">{data.benchmark}</span> · weekly · tail
          {[6, 10, 14].map(n => (
            <button key={n} className={`chip ${tail === n ? "active" : ""}`} onClick={() => setTail(n)}
              style={{ fontSize: 9, padding: "1px 6px" }}>{n}w</button>
          ))}
        </div>
        <div className="faint" style={{ fontSize: 10, display: "flex", alignItems: "center", gap: 8 }}>
          <button className={`chip ${filterOpen ? "active" : ""}`}
            onClick={() => setFilterOpen(o => !o)}
            style={{ fontSize: 9, padding: "2px 8px" }}
            title="show / hide individual items">
            FILTER · {visibleCount}/{totalCount}
          </button>
          <span>{visibleCount} {noun} · {data.weeks_used} weekly bars</span>
        </div>
      </div>

      {/* Filter dropdown — checkboxes per item */}
      {filterOpen ? (
        <div style={{
          position: "absolute", right: 8, top: 36, zIndex: 20,
          background: "var(--bg-1)", border: "1px solid var(--border)",
          padding: 8, maxHeight: 280, overflow: "auto", minWidth: 220,
          boxShadow: "0 4px 16px rgba(0,0,0,0.6)",
        }}>
          <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6, gap: 6 }}>
            <button className="chip" style={{ fontSize: 9 }} onClick={() => setHidden(new Set())}>show all</button>
            <button className="chip" style={{ fontSize: 9 }} onClick={() => setHidden(new Set(allSectors.map(s => s.label)))}>hide all</button>
            <button className="chip" style={{ fontSize: 9 }} onClick={() => setFilterOpen(false)}>close</button>
          </div>
          {allSectors.map((s, idx) => {
            const checked = !hidden.has(s.label);
            return (
              <label key={s.label}
                style={{ display: "flex", alignItems: "center", gap: 6, padding: "3px 4px",
                         cursor: "pointer", fontSize: 11 }}>
                <input type="checkbox" checked={checked} onChange={() => {
                  setHidden(p => { const n = new Set(p); checked ? n.add(s.label) : n.delete(s.label); return n; });
                }}/>
                <span style={{ width: 10, height: 10, background: COLORS_PALETTE[idx % COLORS_PALETTE.length], display: "inline-block", borderRadius: 2 }}/>
                <span className="bright" style={{ flex: 1 }}>{s.label}</span>
                <span className="faint" style={{ fontSize: 9 }}>{s.quadrant}</span>
              </label>
            );
          })}
        </div>
      ) : null}

      <div style={{ width: "100%", maxWidth: 1200, margin: "0 auto", aspectRatio: `${W} / ${H}`, maxHeight: "62vh" }}>
      <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet"
        style={{ display: "block", width: "100%", height: "100%", background: "var(--bg-0)", border: "1px solid var(--border)" }}>
        {/* Quadrant backgrounds — subtle tints */}
        <rect x={xAt(center)} y={padT}        width={W - padR - xAt(center)} height={yAt(center) - padT}     fill="rgba(34, 214, 111, 0.05)"/>
        <rect x={xAt(center)} y={yAt(center)} width={W - padR - xAt(center)} height={H - padB - yAt(center)} fill="rgba(255, 176, 32, 0.05)"/>
        <rect x={padL}        y={yAt(center)} width={xAt(center) - padL}     height={H - padB - yAt(center)} fill="rgba(255, 77, 77, 0.05)"/>
        <rect x={padL}        y={padT}        width={xAt(center) - padL}     height={yAt(center) - padT}     fill="rgba(78, 201, 240, 0.05)"/>

        {/* Quadrant labels */}
        {QUAD_LABELS.map(q => (
          <text key={q.txt} x={xAt(q.x) + (q.align === "end" ? -6 : 6)} y={yAt(q.y) + (q.baseline === "hanging" ? 14 : -4)}
            fontSize="11" fontWeight="600" letterSpacing="0.1em" fill={q.tone} textAnchor={q.align}>
            {q.txt}
          </text>
        ))}

        {/* Reference grid — lines every 'step' units around 100 */}
        {(() => {
          const step = xSpan > 20 ? 10 : xSpan > 10 ? 5 : 2;
          const ticks = [];
          for (let v = Math.ceil(xMin / step) * step; v <= xMax; v += step) ticks.push(v);
          return ticks.map(v => v === center ? null : (
            <g key={`gx${v}`}>
              <line x1={xAt(v)} y1={padT} x2={xAt(v)} y2={H - padB} stroke="var(--border)" opacity="0.25"/>
              <text x={xAt(v)} y={H - padB + 12} fontSize="8" fill="var(--fg-faint)" textAnchor="middle">{v.toFixed(0)}</text>
            </g>
          ));
        })()}
        {(() => {
          const step = ySpan > 20 ? 10 : ySpan > 10 ? 5 : 2;
          const ticks = [];
          for (let v = Math.ceil(yMin / step) * step; v <= yMax; v += step) ticks.push(v);
          return ticks.map(v => v === center ? null : (
            <g key={`gy${v}`}>
              <line x1={padL} y1={yAt(v)} x2={W - padR} y2={yAt(v)} stroke="var(--border)" opacity="0.25"/>
              <text x={padL - 4} y={yAt(v) + 3} fontSize="8" fill="var(--fg-faint)" textAnchor="end">{v.toFixed(0)}</text>
            </g>
          ));
        })()}

        {/* Center cross */}
        <line x1={padL} y1={yAt(center)} x2={W - padR} y2={yAt(center)} stroke="var(--border-strong)" strokeWidth="1.2"/>
        <line x1={xAt(center)} y1={padT} x2={xAt(center)} y2={H - padB} stroke="var(--border-strong)" strokeWidth="1.2"/>

        {/* Axis labels */}
        <text x={W - padR} y={H - 4} fontSize="9" fill="var(--fg-dim)" textAnchor="end">RS-Ratio (relative strength) →</text>
        <text x={padL - 4} y={padT + 8} fontSize="9" fill="var(--fg-dim)" textAnchor="end" transform={`rotate(-90, ${padL - 4}, ${padT + 8})`}>↑ RS-Momentum</text>
        <text x={xAt(center) + 4} y={H - padB + 12} fontSize="9" fill="var(--fg-dim)" fontWeight="500">{center}</text>
        <text x={padL - 4} y={yAt(center) + 3} fontSize="9" fill="var(--fg-dim)" textAnchor="end" fontWeight="500">{center}</text>

        {/* Tails + heads per sector */}
        {sectors.map((s, i) => {
          const c = colorOf(i);
          const isHover = hover === s.label;
          const opacity = hover && !isHover ? 0.25 : 1.0;
          const path = s.tail.map((p, j) => `${j === 0 ? "M" : "L"} ${xAt(p.x).toFixed(1)},${yAt(p.y).toFixed(1)}`).join(" ");
          const head = s.head;
          return (
            <g key={s.label} opacity={opacity}
              onMouseEnter={() => setHover(s.label)} onMouseLeave={() => setHover(null)}
              style={{ cursor: "pointer" }}>
              {/* Tail line — clamped so far-history outliers don't blow up the view */}
              {(() => {
                const clamped = s.tail.map(p => `${xAt(clampX(p.x)).toFixed(1)},${yAt(clampY(p.y)).toFixed(1)}`);
                const d = clamped.length ? "M " + clamped.join(" L ") : "";
                return <path d={d} fill="none" stroke={c} strokeWidth={isHover ? 2 : 1.2} opacity="0.7"/>;
              })()}
              {/* Tail dots, fading from old to new */}
              {s.tail.map((p, j) => (
                <circle key={j} cx={xAt(clampX(p.x))} cy={yAt(clampY(p.y))} r={1.5}
                  fill={c} opacity={0.3 + 0.7 * (j / Math.max(1, s.tail.length - 1))}/>
              ))}
              {/* Head dot + label (label staggered to avoid collisions) */}
              <circle cx={xAt(head.x)} cy={yAt(head.y)} r={isHover ? 6 : 4.5}
                fill={c} stroke="var(--bg-0)" strokeWidth="1.5"/>
              {(() => {
                const dy = labelOffsets[i] || 0;
                const ly = yAt(head.y) + dy + 3;
                return (
                  <>
                    {Math.abs(dy) > 0.5 ? (
                      <line x1={xAt(head.x) + 5} y1={yAt(head.y)} x2={xAt(head.x) + 7} y2={ly - 3}
                        stroke={c} strokeWidth="0.8" opacity="0.6"/>
                    ) : null}
                    <text x={xAt(head.x) + 9} y={ly} fontSize="10"
                      fill="var(--fg-bright)" fontWeight="500">{s.label}</text>
                  </>
                );
              })()}
            </g>
          );
        })}
      </svg>
      </div>

      {/* Legend / quadrant counts */}
      <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 6, marginTop: 6, fontSize: 10 }}>
        {["LEADING", "IMPROVING", "WEAKENING", "LAGGING"].map(q => {
          const list = sectors.filter(s => s.quadrant === q);
          const tone = q === "LEADING" ? "up" : q === "WEAKENING" ? "amber" : q === "LAGGING" ? "down" : "cyan";
          return (
            <div key={q} style={{ padding: "4px 8px", border: "1px solid var(--border)", background: "var(--bg-0)" }}>
              <div className={`h-xxs ${tone}`}>{q}</div>
              <div className="bright tnum">{list.length}</div>
              <div className="faint" style={{ fontSize: 9 }}>{list.map(s => s.label).join(" · ") || "—"}</div>
            </div>
          );
        })}
      </div>
      <div className="faint" style={{ fontSize: 9, marginTop: 4 }}>
        Rotation goes clockwise: Improving → Leading → Weakening → Lagging → Improving.
      </div>
    </div>
  );
}

// ============================================================================
// SectorRotationPanel — pulls /api/sectors/rotation
// Pass `embedded` to render without the outer Panel (caller wraps it).
// ============================================================================
function SectorRotationPanel({ embedded = false } = {}) {
  const [data, setData] = useState(null);
  const [window, setWindow] = useState(30);

  useEffect(() => {
    let stopped = false;
    const load = async () => {
      try {
        const res = await fetch(`/api/sectors/rotation?lookback_days=${window}`);
        if (!res.ok) throw new Error("HTTP " + res.status);
        const d = await res.json();
        if (!stopped) setData(d);
      } catch (e) { if (!stopped) setData({ error: String(e) }); }
    };
    load();
    const t = setInterval(load, 60000);
    return () => { stopped = true; clearInterval(t); };
  }, [window]);

  const lookbackChips = (
    <div className="row" style={{ gap: 4 }}>
      {[5, 10, 30, 90].map(w => (
        <button key={w} className={`chip ${window === w ? "active" : ""}`} onClick={() => setWindow(w)}>{w}d</button>
      ))}
    </div>
  );

  const body = (
    !data ? <div className="faint" style={{ padding: 12 }}>Loading sectors…</div>
     : data.error ? <div className="down" style={{ padding: 12 }}>{data.error}</div>
     : data.sectors.length === 0 ? <div className="faint" style={{ padding: 12 }}>No sector indices resolved.</div>
     : (
      <table className="tbl tbl-compact">
        <thead><tr>
          <th>Sector</th><th className="num">Last</th>
          <th className="num">1D %</th><th className="num">{window}D %</th>
          <th style={{ width: 120 }}>Trend</th>
        </tr></thead>
        <tbody>
          {data.sectors.map(s => {
            const d1 = s.pct_1d;
            const dn = s.pct_n;
            const spk = s.sparkline || [];
            const max = spk.length ? Math.max(...spk) : 1;
            const min = spk.length ? Math.min(...spk) : 0;
            const rng = max - min || 1;
            const w = 100, h = 16;
            const path = spk.length >= 2
              ? "M " + spk.map((v, i) => `${(i / (spk.length - 1)) * w},${h - ((v - min) / rng) * h}`).join(" L ")
              : "";
            const up = spk.length ? spk[spk.length - 1] >= spk[0] : true;
            return (
              <tr key={s.label}>
                <td className="bright">{s.label}</td>
                <td className="num tnum">{s.last != null ? s.last.toFixed(2) : "—"}</td>
                <td className={`num tnum ${(d1 || 0) >= 0 ? "up" : "down"}`}>
                  {d1 != null ? (d1 >= 0 ? "+" : "") + d1.toFixed(2) + "%" : "—"}
                </td>
                <td className={`num tnum ${(dn || 0) >= 0 ? "up" : "down"}`}>
                  {dn != null ? (dn >= 0 ? "+" : "") + dn.toFixed(2) + "%" : "—"}
                </td>
                <td>
                  {path ? (
                    <svg width={w} height={h} style={{ display: "block" }}>
                      <path d={path} fill="none" stroke={up ? "var(--up)" : "var(--down)"} strokeWidth="1"/>
                    </svg>
                  ) : null}
                </td>
              </tr>
            );
          })}
        </tbody>
      </table>
    )
  );

  // When `embedded`, skip the outer Panel — caller already wraps us.  Show
  // the lookback chips inline at the top of the body instead.
  if (embedded) {
    return (
      <div>
        <div style={{ padding: "6px 10px", borderBottom: "1px solid var(--grid)", display: "flex", justifyContent: "flex-end" }}>
          {lookbackChips}
        </div>
        {body}
      </div>
    );
  }
  return (
    <Panel title="Sector Rotation" tag={data?.sectors?.length || ""} bodyFlush actions={lookbackChips}>
      {body}
    </Panel>
  );
}

// ============================================================================
// GlobalPage — international markets via TradingView free embeddable widgets.
//
// Why this exists:
//   For pre-market positioning we need overnight global moves (US, EU, Asia,
//   FX, commodities) that Dhan doesn't expose.  TradingView ships free public
//   widgets — no API key, no signup — with real-time data on most major
//   indices, futures, forex, and commodities (Indian indices delayed 15min
//   on free tier).  We just inject TV's widget script and let it render.
//
// Widgets used:
//   Market Overview — multi-tab indices/futures/forex/crypto card
//   Ticker Tape     — strip running across the top
//   Heatmap         — sector treemap fallback
//   Economic Cal    — TV's built-in econ calendar (releases + consensus + actual)
// ============================================================================

// Generic TradingView widget injector — takes the embed script URL and a JSON
// config object.  Mounts a fresh container each time props change so widgets
// reload cleanly when the user toggles tabs.
function TVWidget({ src, config, height = 400 }) {
  const ref = useRef(null);
  useEffect(() => {
    if (!ref.current) return;
    ref.current.innerHTML = "";
    const container = document.createElement("div");
    container.className = "tradingview-widget-container__widget";
    ref.current.appendChild(container);
    const script = document.createElement("script");
    script.type = "text/javascript";
    script.src = src;
    script.async = true;
    script.innerHTML = JSON.stringify(config);
    ref.current.appendChild(script);
  }, [src, JSON.stringify(config)]);
  return (
    <div ref={ref} className="tradingview-widget-container" style={{ width: "100%", height, overflow: "hidden", background: "var(--bg-0)" }}/>
  );
}

function GlobalPage() {
  const [tab, setTab] = useState("OVERVIEW");

  // Shared TV theming
  const TV_THEME = "dark";
  const TV_LOCALE = "en";
  const TV_BG = "rgba(5, 7, 10, 1)";

  return (
    <div style={{ padding: 8, height: "100%", overflow: "auto", display: "grid", gap: 8, gridAutoRows: "max-content" }}>
      {/* Tab strip */}
      <Panel title="Global markets · TradingView (real-time, free)"
        bodyFlush
        actions={
          <div style={{ display: "flex", gap: 4 }}>
            {["OVERVIEW", "INDICES", "FUTURES", "FOREX", "CRYPTO", "HEATMAP", "CALENDAR"].map(k => (
              <button key={k} className={`chip ${tab === k ? "active" : ""}`} onClick={() => setTab(k)}>{k}</button>
            ))}
          </div>
        }>
        <div style={{ padding: 8, fontSize: 10, color: "var(--fg-dim)" }}>
          No API key needed · no signup · TV branding watermark visible · Indian symbols 15-min delayed on free tier (real-time on TV Pro+)
        </div>
      </Panel>

      {tab === "OVERVIEW" ? (
        <Panel title="Market overview" bodyFlush>
          <TVWidget
            src="https://s3.tradingview.com/external-embedding/embed-widget-market-overview.js"
            height={520}
            config={{
              colorTheme: TV_THEME, dateRange: "12M", showChart: true, locale: TV_LOCALE,
              largeChartUrl: "", isTransparent: true, showSymbolLogo: true,
              showFloatingTooltip: true, plotLineColorGrowing: "rgba(34, 214, 111, 1)",
              plotLineColorFalling: "rgba(255, 77, 77, 1)", gridLineColor: "rgba(28, 34, 48, 1)",
              scaleFontColor: "rgba(155, 165, 180, 1)",
              belowLineFillColorGrowing: "rgba(34, 214, 111, 0.12)",
              belowLineFillColorFalling: "rgba(255, 77, 77, 0.12)",
              belowLineFillColorGrowingBottom: "rgba(34, 214, 111, 0)",
              belowLineFillColorFallingBottom: "rgba(255, 77, 77, 0)",
              symbolActiveColor: "rgba(255, 176, 32, 0.20)",
              tabs: [
                { title: "Indices", symbols: [
                  { s: "FOREXCOM:SPXUSD", d: "S&P 500" },
                  { s: "FOREXCOM:NSXUSD", d: "Nasdaq 100" },
                  { s: "FOREXCOM:DJI",    d: "Dow 30" },
                  { s: "INDEX:NKY",       d: "Nikkei 225" },
                  { s: "INDEX:DEU40",     d: "DAX" },
                  { s: "FOREXCOM:UKXGBP", d: "FTSE 100" },
                  { s: "BSE:SENSEX",      d: "Sensex" },
                  { s: "NSE:NIFTY",       d: "Nifty 50" },
                  { s: "NSE:BANKNIFTY",   d: "Bank Nifty" },
                  { s: "TVC:HSI",         d: "Hang Seng" },
                ]},
                { title: "Futures", symbols: [
                  { s: "CME_MINI:ES1!",  d: "S&P 500 fut" },
                  { s: "CME_MINI:NQ1!",  d: "Nasdaq fut" },
                  { s: "NYMEX:CL1!",     d: "Crude WTI" },
                  { s: "NYMEX:NG1!",     d: "Nat Gas" },
                  { s: "COMEX:GC1!",     d: "Gold" },
                  { s: "COMEX:SI1!",     d: "Silver" },
                  { s: "CBOT:ZN1!",      d: "10Y Treasury" },
                  { s: "SGX:NIFTY1!",    d: "SGX Nifty" },
                ]},
                { title: "Forex", symbols: [
                  { s: "FX:EURUSD",      d: "EUR/USD" },
                  { s: "FX:USDJPY",      d: "USD/JPY" },
                  { s: "FX:GBPUSD",      d: "GBP/USD" },
                  { s: "FX:USDINR",      d: "USD/INR" },
                  { s: "TVC:DXY",        d: "Dollar Index" },
                  { s: "FX:AUDUSD",      d: "AUD/USD" },
                ]},
                { title: "Crypto", symbols: [
                  { s: "BITSTAMP:BTCUSD", d: "Bitcoin" },
                  { s: "BITSTAMP:ETHUSD", d: "Ethereum" },
                  { s: "BINANCE:SOLUSDT", d: "Solana" },
                ]},
              ],
              width: "100%", height: 520,
            }}/>
        </Panel>
      ) : null}

      {tab === "INDICES" ? (
        <Panel title="Global indices · ticker tape" bodyFlush>
          <TVWidget
            src="https://s3.tradingview.com/external-embedding/embed-widget-ticker-tape.js"
            height={62}
            config={{
              symbols: [
                { proName: "FOREXCOM:SPXUSD", title: "S&P 500" },
                { proName: "FOREXCOM:NSXUSD", title: "Nasdaq 100" },
                { proName: "FOREXCOM:DJI",    title: "Dow 30" },
                { proName: "INDEX:NKY",       title: "Nikkei" },
                { proName: "TVC:HSI",         title: "Hang Seng" },
                { proName: "INDEX:DEU40",     title: "DAX" },
                { proName: "FOREXCOM:UKXGBP", title: "FTSE 100" },
                { proName: "BSE:SENSEX",      title: "Sensex" },
                { proName: "NSE:NIFTY",       title: "Nifty 50" },
              ],
              showSymbolLogo: true, isTransparent: true,
              displayMode: "adaptive", colorTheme: TV_THEME, locale: TV_LOCALE,
            }}/>
          <div style={{ padding: 12 }}>
            <TVWidget
              src="https://s3.tradingview.com/external-embedding/embed-widget-symbol-overview.js"
              height={420}
              config={{
                symbols: [
                  ["S&P 500",     "FOREXCOM:SPXUSD|12M"],
                  ["Nasdaq 100",  "FOREXCOM:NSXUSD|12M"],
                  ["Nifty 50",    "NSE:NIFTY|12M"],
                  ["Bank Nifty",  "NSE:BANKNIFTY|12M"],
                  ["Nikkei 225",  "INDEX:NKY|12M"],
                  ["DAX",         "INDEX:DEU40|12M"],
                ],
                chartOnly: false, width: "100%", height: 420, locale: TV_LOCALE, colorTheme: TV_THEME,
                isTransparent: true, autosize: true, showVolume: false, showMA: false,
                hideDateRanges: false, hideMarketStatus: false,
              }}/>
          </div>
        </Panel>
      ) : null}

      {tab === "FUTURES" ? (
        <Panel title="Pre-market futures (overnight signal)" bodyFlush>
          <TVWidget
            src="https://s3.tradingview.com/external-embedding/embed-widget-market-overview.js"
            height={520}
            config={{
              colorTheme: TV_THEME, dateRange: "1D", showChart: true, locale: TV_LOCALE,
              isTransparent: true, showSymbolLogo: true,
              tabs: [{ title: "Pre-market futures", symbols: [
                { s: "CME_MINI:ES1!", d: "S&P 500 fut" },
                { s: "CME_MINI:NQ1!", d: "Nasdaq fut" },
                { s: "CBOT:YM1!",     d: "Dow fut" },
                { s: "SGX:NIFTY1!",   d: "SGX Nifty" },
                { s: "NYMEX:CL1!",    d: "Crude" },
                { s: "COMEX:GC1!",    d: "Gold" },
                { s: "COMEX:SI1!",    d: "Silver" },
                { s: "CBOT:ZN1!",     d: "US 10Y" },
                { s: "TVC:DXY",       d: "DXY" },
              ]}],
              width: "100%", height: 520,
            }}/>
        </Panel>
      ) : null}

      {tab === "FOREX" ? (
        <Panel title="Forex · INR pairs and majors" bodyFlush>
          <TVWidget
            src="https://s3.tradingview.com/external-embedding/embed-widget-forex-cross-rates.js"
            height={420}
            config={{
              currencies: ["EUR","USD","JPY","GBP","CHF","AUD","CAD","INR","CNY"],
              isTransparent: true, colorTheme: TV_THEME, locale: TV_LOCALE,
              width: "100%", height: 420,
            }}/>
        </Panel>
      ) : null}

      {tab === "CRYPTO" ? (
        <Panel title="Crypto major coins" bodyFlush>
          <TVWidget
            src="https://s3.tradingview.com/external-embedding/embed-widget-crypto-mkt-screener.js"
            height={520}
            config={{
              defaultColumn: "overview", screener_type: "crypto_mkt", displayCurrency: "USD",
              colorTheme: TV_THEME, locale: TV_LOCALE, isTransparent: true,
              width: "100%", height: 520,
            }}/>
        </Panel>
      ) : null}

      {tab === "HEATMAP" ? (
        <Panel title="Stock heatmap · NSE" bodyFlush>
          <TVWidget
            src="https://s3.tradingview.com/external-embedding/embed-widget-stock-heatmap.js"
            height={560}
            config={{
              exchanges: ["NSE"], dataSource: "SENSEX",
              grouping: "sector", blockSize: "market_cap_basic", blockColor: "change",
              locale: TV_LOCALE, symbolUrl: "", colorTheme: TV_THEME,
              hasTopBar: true, isDataSetEnabled: true, isZoomEnabled: true, hasSymbolTooltip: true,
              isTransparent: true, width: "100%", height: 560,
            }}/>
        </Panel>
      ) : null}

      {tab === "CALENDAR" ? (
        <Panel title="Economic calendar" bodyFlush>
          <TVWidget
            src="https://s3.tradingview.com/external-embedding/embed-widget-events.js"
            height={560}
            config={{
              colorTheme: TV_THEME, isTransparent: true, locale: TV_LOCALE,
              importanceFilter: "0,1", countryFilter: "in,us,gb,eu,jp,cn",
              width: "100%", height: 560,
            }}/>
        </Panel>
      ) : null}
    </div>
  );
}

// ============================================================================
// CalendarsPage — FII/DII flow + economic events + earnings calendar.
// All three feeds powered by free public sources (NSE, TradingView events
// JSON, NSE corporate filings).  See calendars.py.  No API key required.
// ============================================================================
function CalendarsPage() {
  const [tab, setTab] = useState("FII_DII");  // FII_DII | ECONOMIC | EARNINGS
  return (
    <div style={{ padding: 8, height: "100%", overflow: "auto", display: "grid", gap: 8, gridAutoRows: "max-content" }}>
      <Panel title="Market calendars · FII/DII · Economic · Earnings"
        bodyFlush
        actions={
          <div style={{ display: "flex", gap: 4 }}>
            <button className={`chip ${tab === "FII_DII"  ? "active" : ""}`} onClick={() => setTab("FII_DII")}>FII/DII</button>
            <button className={`chip ${tab === "ECONOMIC" ? "active" : ""}`} onClick={() => setTab("ECONOMIC")}>ECONOMIC</button>
            <button className={`chip ${tab === "EARNINGS" ? "active" : ""}`} onClick={() => setTab("EARNINGS")}>EARNINGS</button>
            <span style={{ width: 1, background: "var(--border)", height: 14, margin: "0 6px" }}/>
            <button className="chip" onClick={async () => {
              try { await fetch("/api/calendars/refresh_all", { method: "POST" }); window.location.reload(); }
              catch (e) {}
            }} title="Force a refresh from NSE / TradingView">REFRESH</button>
          </div>
        }>
        <div style={{ padding: 8, fontSize: 10, color: "var(--fg-dim)" }}>
          Free public data · NSE participant turnover · TradingView calendar JSON · NSE corporate filings · auto-refresh on backend
        </div>
      </Panel>

      {tab === "FII_DII"  ? <FiiDiiPanel/>   : null}
      {tab === "ECONOMIC" ? <EconomicPanel/> : null}
      {tab === "EARNINGS" ? <EarningsPanel/> : null}
    </div>
  );
}

function FiiDiiPanel() {
  // Sub-tab selector: cash flow vs F&O participant breakdown (OI snapshot vs traded volume).
  const [sub, setSub] = useState("cash");  // "cash" | "fno_oi" | "fno_vol"
  return (
    <div style={{ display: "grid", gap: 8 }}>
      <Panel title="FII / DII data" bodyFlush
        actions={
          <div style={{ display: "flex", gap: 4 }}>
            <button className={`chip ${sub === "cash"    ? "active" : ""}`} onClick={() => setSub("cash")}>CASH</button>
            <button className={`chip ${sub === "fno_oi"  ? "active" : ""}`} onClick={() => setSub("fno_oi")}>F&amp;O OI</button>
            <button className={`chip ${sub === "fno_vol" ? "active" : ""}`} onClick={() => setSub("fno_vol")}>F&amp;O VOL</button>
          </div>
        }>
        <div style={{ padding: 8, fontSize: 10, color: "var(--fg-dim)" }}>
          {sub === "cash"
            ? "Cash-segment buy/sell/net values from NSE participant turnover (₹ Cr)."
            : sub === "fno_oi"
              ? "End-of-day open-interest snapshot in equity derivatives, by participant (number of contracts). Source: NSE archives."
              : "Today's traded volume in equity derivatives, by participant (number of contracts). Source: NSE archives."}
        </div>
      </Panel>
      {sub === "cash"   ? <FiiDiiCashPanel/> : null}
      {sub === "fno_oi" ? <FiiFnoPanel metric="oi"/>  : null}
      {sub === "fno_vol"? <FiiFnoPanel metric="vol"/> : null}
    </div>
  );
}

function FiiDiiCashPanel() {
  const [data, setData] = useState(null);
  const [days, setDays] = useState(30);
  useEffect(() => {
    let stop = false;
    setData(null);
    fetch(`/api/calendars/fii_dii?days=${days}`)
      .then(r => r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status)))
      .then(j => !stop && setData(j))
      .catch(e => !stop && setData({ error: String(e) }));
    return () => { stop = true; };
  }, [days]);

  if (!data) return <Panel title="FII/DII flow"><div className="faint" style={{ padding: 12 }}>loading…</div></Panel>;
  if (data.error) return <Panel title="FII/DII flow"><div className="down" style={{ padding: 12 }}>{data.error}</div></Panel>;

  const rows = data.rows || [];
  const sum = data.summary_5d || {};
  const W = 700, H = 200, pad = 28;
  // Build a tiny chart of FII vs DII net per day
  const recent = rows.slice(0, 22).reverse();
  const maxAbs = Math.max(1, ...recent.flatMap(r => [Math.abs(r.fii_net), Math.abs(r.dii_net)]));
  const xStep = recent.length > 1 ? (W - pad * 2) / (recent.length - 1) : 0;
  const yMid = H / 2;
  const yScale = (v) => yMid - (v / maxAbs) * (H / 2 - pad / 2);

  return (
    <Panel title="FII / DII flow · cash segment"
      tag={data.count + " days"}
      actions={
        <div className="row" style={{ gap: 4 }}>
          {[5, 10, 30, 60, 90].map(n => (
            <button key={n} className={`chip ${days === n ? "active" : ""}`} onClick={() => setDays(n)}>{n}d</button>
          ))}
        </div>
      }>
      <div style={{ padding: 8 }}>
        {/* 5-day summary tiles */}
        <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 6, marginBottom: 8 }}>
          <Mini label="FII 5d net (₹Cr)"   value={(sum.fii_net != null ? (sum.fii_net >= 0 ? "+" : "") + sum.fii_net.toLocaleString("en-IN") : "—")} tone={sum.fii_net > 0 ? "up" : sum.fii_net < 0 ? "down" : null}/>
          <Mini label="FII 5d trend"        value={sum.fii_trend || "—"} tone={sum.fii_trend === "buying" ? "up" : sum.fii_trend === "selling" ? "down" : null}/>
          <Mini label="DII 5d net (₹Cr)"   value={(sum.dii_net != null ? (sum.dii_net >= 0 ? "+" : "") + sum.dii_net.toLocaleString("en-IN") : "—")} tone={sum.dii_net > 0 ? "up" : sum.dii_net < 0 ? "down" : null}/>
          <Mini label="DII 5d trend"        value={sum.dii_trend || "—"} tone={sum.dii_trend === "buying" ? "up" : sum.dii_trend === "selling" ? "down" : null}/>
        </div>

        {/* Mini divergence chart (FII green/red bars + DII cyan line) */}
        {recent.length >= 2 ? (
          <svg width="100%" viewBox={`0 0 ${W} ${H}`} style={{ display: "block", background: "var(--bg-0)", border: "1px solid var(--border)", marginBottom: 8 }}>
            <line x1={pad} y1={yMid} x2={W - pad} y2={yMid} stroke="var(--border-strong)"/>
            {recent.map((r, i) => {
              const x = pad + i * xStep;
              const fy = yScale(r.fii_net);
              return (
                <rect key={`fb${i}`} x={x - 3} y={Math.min(yMid, fy)} width={6}
                  height={Math.abs(yMid - fy)}
                  fill={r.fii_net >= 0 ? "rgba(34, 214, 111, 0.6)" : "rgba(255, 77, 77, 0.6)"}/>
              );
            })}
            <path d={recent.map((r, i) => `${i === 0 ? "M" : "L"} ${(pad + i * xStep).toFixed(1)},${yScale(r.dii_net).toFixed(1)}`).join(" ")}
              stroke="var(--cyan)" strokeWidth="1.5" fill="none"/>
            <text x={W - pad} y={14} fontSize="9" fill="var(--cyan)" textAnchor="end">DII (line)</text>
            <text x={W - pad} y={26} fontSize="9" fill="var(--fg-dim)" textAnchor="end">FII (bars · red=selling)</text>
          </svg>
        ) : null}

        {/* Daily table */}
        <div style={{ overflow: "auto" }}>
          <table className="tbl tbl-compact" style={{ fontSize: 10, width: "100%" }}>
            <thead><tr>
              <th>Day</th>
              <th className="num">FII Buy</th><th className="num">FII Sell</th><th className="num">FII Net</th>
              <th className="num">DII Buy</th><th className="num">DII Sell</th><th className="num">DII Net</th>
              <th className="num">Total Net</th>
            </tr></thead>
            <tbody>
              {rows.map(r => (
                <tr key={r.day}>
                  <td className="bright">{r.day}</td>
                  <td className="num tnum">{r.fii_buy.toLocaleString("en-IN", { maximumFractionDigits: 2 })}</td>
                  <td className="num tnum">{r.fii_sell.toLocaleString("en-IN", { maximumFractionDigits: 2 })}</td>
                  <td className={`num tnum ${r.fii_net >= 0 ? "up" : "down"}`}>{(r.fii_net >= 0 ? "+" : "") + r.fii_net.toLocaleString("en-IN", { maximumFractionDigits: 2 })}</td>
                  <td className="num tnum">{r.dii_buy.toLocaleString("en-IN", { maximumFractionDigits: 2 })}</td>
                  <td className="num tnum">{r.dii_sell.toLocaleString("en-IN", { maximumFractionDigits: 2 })}</td>
                  <td className={`num tnum ${r.dii_net >= 0 ? "up" : "down"}`}>{(r.dii_net >= 0 ? "+" : "") + r.dii_net.toLocaleString("en-IN", { maximumFractionDigits: 2 })}</td>
                  <td className={`num tnum ${(r.fii_net + r.dii_net) >= 0 ? "up" : "down"}`}>{((r.fii_net + r.dii_net) >= 0 ? "+" : "") + (r.fii_net + r.dii_net).toLocaleString("en-IN", { maximumFractionDigits: 2 })}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </Panel>
  );
}

// ----------------------------------------------------------------------------
// F&O participant breakdown panel.
// `metric` ∈ "oi" (open interest snapshot) | "vol" (traded volume).
// API returns one record per day with `by_client` keyed by client_type.
// ----------------------------------------------------------------------------
function FiiFnoPanel({ metric }) {
  const [data, setData]     = useState(null);
  const [days, setDays]     = useState(30);
  const [client, setClient] = useState("FII");  // FII | DII | Pro | Client
  useEffect(() => {
    let stop = false;
    setData(null);
    fetch(`/api/calendars/fii_fno?metric=${metric}&days=${days}`)
      .then(r => r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status)))
      .then(j => !stop && setData(j))
      .catch(e => !stop && setData({ error: String(e) }));
    return () => { stop = true; };
  }, [metric, days]);

  const title = metric === "oi" ? "F&O participant · Open Interest (contracts)"
                                : "F&O participant · Traded Volume (contracts)";

  if (!data)         return <Panel title={title}><div className="faint" style={{ padding: 12 }}>loading…</div></Panel>;
  if (data.error)    return <Panel title={title}><div className="down"  style={{ padding: 12 }}>{data.error}</div></Panel>;
  const allDays = data.days || [];
  if (allDays.length === 0) {
    return (
      <Panel title={title}>
        <div className="faint" style={{ padding: 12 }}>
          No data yet — backend backfill is still running, or NSE hasn't published today's archive.
        </div>
      </Panel>
    );
  }

  // Build per-day row of the selected client_type
  const rows = allDays
    .map(d => ({ day: d.day, ...(d.by_client?.[client] || {}) }))
    .filter(r => r.total_long != null);

  // Latest-day summary tiles
  const latest = rows[0] || {};
  const fmt = (n) => n == null ? "—"
    : (n >= 0 ? "+" : "") + Math.round(n).toLocaleString("en-IN");
  const tone = (n) => n == null ? null : n > 0 ? "up" : n < 0 ? "down" : null;

  // Tiny chart: selected client's Idx-Fut Net over time
  const recent = rows.slice(0, 30).reverse();
  const W = 700, H = 160, pad = 24;
  const series = recent.map(r => r.fut_idx_net || 0);
  const maxAbs = Math.max(1, ...series.map(Math.abs));
  const xStep = recent.length > 1 ? (W - pad * 2) / (recent.length - 1) : 0;
  const yMid  = H / 2;
  const yScale = (v) => yMid - (v / maxAbs) * (H / 2 - pad / 2);

  return (
    <Panel title={title}
      tag={`${rows.length} days`}
      actions={
        <div className="row" style={{ gap: 4, flexWrap: "wrap" }}>
          <span className="h-xxs">CLIENT</span>
          {["FII", "DII", "Pro", "Client"].map(c => (
            <button key={c} className={`chip ${client === c ? "active" : ""}`} onClick={() => setClient(c)}>{c.toUpperCase()}</button>
          ))}
          <span style={{ width: 1, background: "var(--border)", height: 14, margin: "0 4px" }}/>
          <span className="h-xxs">DAYS</span>
          {[5, 10, 30, 60, 90].map(n => (
            <button key={n} className={`chip ${days === n ? "active" : ""}`} onClick={() => setDays(n)}>{n}d</button>
          ))}
        </div>
      }>
      <div style={{ padding: 8 }}>
        {/* Latest-day summary tiles for the selected client */}
        <div style={{ display: "grid", gridTemplateColumns: "repeat(6, 1fr)", gap: 6, marginBottom: 8 }}>
          <Mini label="Idx Fut Net"    value={fmt(latest.fut_idx_net)}      tone={tone(latest.fut_idx_net)}/>
          <Mini label="Stk Fut Net"    value={fmt(latest.fut_stk_net)}      tone={tone(latest.fut_stk_net)}/>
          <Mini label="Idx Call Net"   value={fmt(latest.opt_idx_call_net)} tone={tone(latest.opt_idx_call_net)}/>
          <Mini label="Idx Put Net"    value={fmt(latest.opt_idx_put_net)}  tone={tone(latest.opt_idx_put_net)}/>
          <Mini label="Stk Opt Net"    value={fmt((latest.opt_stk_call_net || 0) + (latest.opt_stk_put_net || 0))}
                                       tone={tone((latest.opt_stk_call_net || 0) + (latest.opt_stk_put_net || 0))}/>
          <Mini label="Total Net"      value={fmt(latest.total_net)}        tone={tone(latest.total_net)}/>
        </div>

        {/* Idx Futures net over time — green/red bars around zero */}
        {recent.length >= 2 ? (
          <svg width="100%" viewBox={`0 0 ${W} ${H}`} style={{ display: "block", background: "var(--bg-0)", border: "1px solid var(--border)", marginBottom: 8 }}>
            <line x1={pad} y1={yMid} x2={W - pad} y2={yMid} stroke="var(--border-strong)"/>
            {recent.map((r, i) => {
              const x  = pad + i * xStep;
              const fy = yScale(r.fut_idx_net || 0);
              return (
                <rect key={i} x={x - 3} y={Math.min(yMid, fy)} width={6}
                  height={Math.abs(yMid - fy)}
                  fill={(r.fut_idx_net || 0) >= 0 ? "rgba(34, 214, 111, 0.6)" : "rgba(255, 77, 77, 0.6)"}/>
              );
            })}
            <text x={W - pad} y={14} fontSize="9" fill="var(--fg-dim)" textAnchor="end">
              {client} Index Futures Net (long − short, contracts)
            </text>
          </svg>
        ) : null}

        {/* Daily breakdown table — one row per day */}
        <div style={{ overflow: "auto" }}>
          <table className="tbl tbl-compact" style={{ fontSize: 10, width: "100%" }}>
            <thead><tr>
              <th>Day</th>
              <th className="num" title="Index Futures Long">IdxFut L</th>
              <th className="num" title="Index Futures Short">IdxFut S</th>
              <th className="num" title="Index Futures Net (Long − Short)">IdxFut Net</th>
              <th className="num" title="Stock Futures Net">StkFut Net</th>
              <th className="num" title="Index Calls Net">IdxCE Net</th>
              <th className="num" title="Index Puts Net">IdxPE Net</th>
              <th className="num" title="Stock Calls Net">StkCE Net</th>
              <th className="num" title="Stock Puts Net">StkPE Net</th>
              <th className="num">Total Net</th>
            </tr></thead>
            <tbody>
              {rows.map(r => (
                <tr key={r.day}>
                  <td className="bright">{r.day}</td>
                  <td className="num tnum">{Math.round(r.fut_idx_long).toLocaleString("en-IN")}</td>
                  <td className="num tnum">{Math.round(r.fut_idx_short).toLocaleString("en-IN")}</td>
                  <td className={`num tnum ${r.fut_idx_net >= 0 ? "up" : "down"}`}>{fmt(r.fut_idx_net)}</td>
                  <td className={`num tnum ${r.fut_stk_net >= 0 ? "up" : "down"}`}>{fmt(r.fut_stk_net)}</td>
                  <td className={`num tnum ${r.opt_idx_call_net >= 0 ? "up" : "down"}`}>{fmt(r.opt_idx_call_net)}</td>
                  <td className={`num tnum ${r.opt_idx_put_net  >= 0 ? "up" : "down"}`}>{fmt(r.opt_idx_put_net)}</td>
                  <td className={`num tnum ${r.opt_stk_call_net >= 0 ? "up" : "down"}`}>{fmt(r.opt_stk_call_net)}</td>
                  <td className={`num tnum ${r.opt_stk_put_net  >= 0 ? "up" : "down"}`}>{fmt(r.opt_stk_put_net)}</td>
                  <td className={`num tnum ${r.total_net >= 0 ? "up" : "down"}`}>{fmt(r.total_net)}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </Panel>
  );
}

function EconomicPanel() {
  const [data, setData]   = useState(null);
  const [imp, setImp]     = useState(2);
  const [forward, setFwd] = useState(14);
  useEffect(() => {
    let stop = false;
    setData(null);
    fetch(`/api/calendars/economic?days_forward=${forward}&importance_min=${imp}`)
      .then(r => r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status)))
      .then(j => !stop && setData(j))
      .catch(e => !stop && setData({ error: String(e) }));
    return () => { stop = true; };
  }, [imp, forward]);

  if (!data) return <Panel title="Economic calendar"><div className="faint" style={{ padding: 12 }}>loading…</div></Panel>;
  if (data.error) return <Panel title="Economic calendar"><div className="down" style={{ padding: 12 }}>{data.error}</div></Panel>;

  return (
    <Panel title="Economic calendar"
      tag={data.count + " events"}
      actions={
        <div className="row" style={{ gap: 4 }}>
          <span className="h-xxs">IMP</span>
          {[1, 2, 3].map(n => (
            <button key={n} className={`chip ${imp === n ? "active" : ""}`} onClick={() => setImp(n)}>{"●".repeat(n)}</button>
          ))}
          <span style={{ width: 1, background: "var(--border)", height: 14, margin: "0 4px" }}/>
          <span className="h-xxs">DAYS</span>
          {[3, 7, 14, 30].map(n => (
            <button key={n} className={`chip ${forward === n ? "active" : ""}`} onClick={() => setFwd(n)}>{n}</button>
          ))}
        </div>
      }>
      <div style={{ overflow: "auto", padding: 8 }}>
        {data.rows.length === 0 ? (
          <div className="faint" style={{ padding: 12 }}>No events in window — backend may still be fetching</div>
        ) : (
          <table className="tbl tbl-compact" style={{ fontSize: 10, width: "100%" }}>
            <thead><tr>
              <th>Time (IST)</th><th>Country</th><th>Imp</th><th>Event</th>
              <th className="num">Previous</th><th className="num">Consensus</th>
              <th className="num">Actual</th><th className="num">Surprise</th>
            </tr></thead>
            <tbody>
              {data.rows.map(e => {
                const tone = e.surprise_score == null ? null
                           : e.surprise_score > 0 ? "up" : e.surprise_score < 0 ? "down" : null;
                const t = e.event_time ? new Date(e.event_time) : null;
                const tStr = t ? t.toLocaleString("en-IN", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }) : "—";
                const dots = "●".repeat(e.importance) + "○".repeat(3 - e.importance);
                return (
                  <tr key={e.event_id}>
                    <td className="faint" style={{ fontSize: 9 }}>{tStr}</td>
                    <td className="bright">{e.country}</td>
                    <td className={e.importance >= 3 ? "down" : e.importance === 2 ? "amber" : "faint"} style={{ fontSize: 9 }}>{dots}</td>
                    <td className="bright">{e.event_name}</td>
                    <td className="num tnum">{e.previous != null ? e.previous : "—"}</td>
                    <td className="num tnum">{e.consensus != null ? e.consensus : "—"}</td>
                    <td className={`num tnum ${tone || ""}`}>{e.actual != null ? e.actual : "—"}</td>
                    <td className={`num tnum ${tone || ""}`}>
                      {e.surprise_score != null ? (e.surprise_score >= 0 ? "+" : "") + (e.surprise_score * 100).toFixed(1) + "%" : "—"}
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        )}
      </div>
    </Panel>
  );
}

function EarningsPanel() {
  const [data, setData]   = useState(null);
  const [forward, setFwd] = useState(14);
  const [type, setType]   = useState("RESULTS");
  useEffect(() => {
    let stop = false;
    setData(null);
    const qs = new URLSearchParams({ days_forward: String(forward) });
    if (type !== "ALL") qs.set("event_types", type);
    fetch(`/api/calendars/earnings?${qs.toString()}`)
      .then(r => r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status)))
      .then(j => !stop && setData(j))
      .catch(e => !stop && setData({ error: String(e) }));
    return () => { stop = true; };
  }, [forward, type]);

  if (!data) return <Panel title="Earnings calendar"><div className="faint" style={{ padding: 12 }}>loading…</div></Panel>;
  if (data.error) return <Panel title="Earnings calendar"><div className="down" style={{ padding: 12 }}>{data.error}</div></Panel>;

  return (
    <Panel title="Earnings calendar"
      tag={data.count + " events"}
      actions={
        <div className="row" style={{ gap: 4 }}>
          {["RESULTS", "BOARD_MEETING", "DIVIDEND", "ALL"].map(t => (
            <button key={t} className={`chip ${type === t ? "active" : ""}`} onClick={() => setType(t)}>{t}</button>
          ))}
          <span style={{ width: 1, background: "var(--border)", height: 14, margin: "0 4px" }}/>
          {[7, 14, 30, 60].map(n => (
            <button key={n} className={`chip ${forward === n ? "active" : ""}`} onClick={() => setFwd(n)}>{n}d</button>
          ))}
        </div>
      }>
      <div style={{ overflow: "auto", padding: 8 }}>
        {data.rows.length === 0 ? (
          <div className="faint" style={{ padding: 12 }}>No events in window — backend may still be fetching from NSE</div>
        ) : (
          <table className="tbl tbl-compact" style={{ fontSize: 10, width: "100%" }}>
            <thead><tr>
              <th>Date</th><th>Symbol</th><th>Company</th><th>Type</th><th>Period</th><th>Purpose</th>
            </tr></thead>
            <tbody>
              {data.rows.map(e => (
                <tr key={e.event_id}>
                  <td className="bright tnum">{e.event_date}</td>
                  <td className="bright">{e.symbol}</td>
                  <td className="dim" style={{ fontSize: 9, maxWidth: 200, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={e.company_name}>{e.company_name}</td>
                  <td>
                    <span className={`pill ${e.event_type === "RESULTS" ? "pill-amber" : e.event_type === "DIVIDEND" ? "pill-up" : "pill-dim"}`}
                      style={{ fontSize: 9, padding: "0 4px" }}>{e.event_type}</span>
                  </td>
                  <td className="faint" style={{ fontSize: 9 }}>{e.period || "—"}</td>
                  <td className="dim" style={{ fontSize: 9, maxWidth: 400, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={e.purpose}>{e.purpose}</td>
                </tr>
              ))}
            </tbody>
          </table>
        )}
      </div>
    </Panel>
  );
}

// =============================================================================
// TickPage — read-only observability for the dedicated tick VM.
// Backend proxies /api/tick/* to the tick VM's HTTP server (services/tick/main.py).
// All data is GET-only; the service is fully automatic, no controls in this UI.
// =============================================================================
function TickPage({ app }) {
  const [health, setHealth]         = useState(null);
  const [healthErr, setHealthErr]   = useState(null);
  const [symbols, setSymbols]       = useState([]);
  const [symbolsErr, setSymbolsErr] = useState(null);
  const [seedLogs, setSeedLogs]     = useState([]);
  const [streamLogs, setStreamLogs] = useState([]);
  const [grep, setGrep]             = useState("");
  const [autoScroll, setAutoScroll] = useState(true);
  const [paused, setPaused]         = useState(false);
  const [sortKey, setSortKey]       = useState("idle_s");
  const [sortDir, setSortDir]       = useState("asc");
  const [filter, setFilter]         = useState("");
  const [expanded, setExpanded]     = useState(null);
  const [expandedData, setExpandedData] = useState(null);
  const [retryNonce, setRetryNonce] = useState(0);

  // ─── /api/tick/health (2s poll) ────────────────────────────────────────────
  useEffect(() => {
    let cancelled = false;
    const fetchHealth = async () => {
      try {
        const r = await fetch("/api/tick/health");
        if (!r.ok) {
          let body = null;
          try { body = await r.json(); } catch (_) {}
          if (!cancelled) {
            setHealthErr({
              status: r.status,
              error: (body && body.detail) || (body && body.error) || `HTTP ${r.status}`,
            });
            setHealth(null);
          }
        } else {
          const data = await r.json();
          if (!cancelled) { setHealth(data); setHealthErr(null); }
        }
      } catch (e) {
        if (!cancelled) {
          setHealthErr({ status: 0, error: String(e && e.message || e) });
          setHealth(null);
        }
      }
    };
    fetchHealth();
    const t = setInterval(fetchHealth, 2000);
    return () => { cancelled = true; clearInterval(t); };
  }, [retryNonce]);

  // ─── /api/tick/symbols (3s poll, paused on health error) ───────────────────
  useEffect(() => {
    if (healthErr) return;
    let cancelled = false;
    const fetchSymbols = async () => {
      try {
        const r = await fetch("/api/tick/symbols");
        if (!r.ok) {
          if (!cancelled) setSymbolsErr(`HTTP ${r.status}`);
          return;
        }
        const data = await r.json();
        if (!cancelled) { setSymbols(data.symbols || []); setSymbolsErr(null); }
      } catch (e) {
        if (!cancelled) setSymbolsErr(String(e && e.message || e));
      }
    };
    fetchSymbols();
    const t = setInterval(fetchSymbols, 3000);
    return () => { cancelled = true; clearInterval(t); };
  }, [!!healthErr, retryNonce]);

  // ─── Seed logs panel with last 500 lines once on mount/retry ───────────────
  useEffect(() => {
    if (healthErr) return;
    let cancelled = false;
    fetch("/api/tick/logs?lines=500")
      .then(r => r.ok ? r.json() : null)
      .then(data => {
        if (cancelled || !data) return;
        setSeedLogs(data.lines || []);
        setStreamLogs([]);
      })
      .catch(() => {});
    return () => { cancelled = true; };
  }, [!!healthErr, retryNonce]);

  // ─── Live tail via SSE ─────────────────────────────────────────────────────
  const streamUrl = (!healthErr && !paused) ? "/api/tick/logs/stream" : null;
  const stream = useStream(streamUrl, {
    bufferSize: 1500,
    onEvent: (ev) => {
      if (ev.type === "log") {
        setStreamLogs(prev => {
          const next = prev.length >= 1500 ? prev.slice(prev.length - 1499).concat(ev) : prev.concat(ev);
          return next;
        });
      }
      // upstream_closed / error events surface via stream.status — no action.
    },
  });

  // ─── Symbol drill-down ─────────────────────────────────────────────────────
  useEffect(() => {
    if (!expanded) { setExpandedData(null); return; }
    let cancelled = false;
    fetch(`/api/tick/symbols/${encodeURIComponent(expanded)}?limit=20`)
      .then(r => r.ok ? r.json() : null)
      .then(data => { if (!cancelled) setExpandedData(data); })
      .catch(() => { if (!cancelled) setExpandedData({ recent: [], error: "fetch failed" }); });
    return () => { cancelled = true; };
  }, [expanded]);

  // ─── Derived: filtered + sorted symbol rows ────────────────────────────────
  const rows = useMemo(() => {
    const needle = filter.trim().toUpperCase();
    let filtered = needle ? symbols.filter(s => s.symbol.includes(needle)) : symbols;
    const dir = sortDir === "asc" ? 1 : -1;
    return [...filtered].sort((a, b) => {
      const av = a[sortKey], bv = b[sortKey];
      if (av == null && bv == null) return 0;
      if (av == null) return 1;
      if (bv == null) return -1;
      if (typeof av === "number") return (av - bv) * dir;
      return String(av).localeCompare(String(bv)) * dir;
    });
  }, [symbols, filter, sortKey, sortDir]);

  const setSort = (k) => {
    if (sortKey === k) setSortDir(d => d === "asc" ? "desc" : "asc");
    else { setSortKey(k); setSortDir(k === "symbol" ? "asc" : "desc"); }
  };

  const sortArrow = (k) => sortKey === k ? (sortDir === "asc" ? " ▲" : " ▼") : "";

  // ─── Logs view: seed + stream, then optional grep filter ───────────────────
  const allLogs = useMemo(() => seedLogs.concat(streamLogs), [seedLogs, streamLogs]);
  const visibleLogs = useMemo(() => {
    const needle = grep.trim().toLowerCase();
    if (!needle) return allLogs;
    return allLogs.filter(l =>
      l.message.toLowerCase().includes(needle) ||
      l.logger.toLowerCase().includes(needle) ||
      l.level.toLowerCase().includes(needle)
    );
  }, [allLogs, grep]);

  // Auto-scroll the logs panel to bottom on new lines.
  const logBodyRef = useRef(null);
  useEffect(() => {
    if (autoScroll && logBodyRef.current) {
      logBodyRef.current.scrollTop = logBodyRef.current.scrollHeight;
    }
  }, [visibleLogs.length, autoScroll]);

  // ─── Render ────────────────────────────────────────────────────────────────
  if (healthErr) {
    return (
      <div style={{ padding: 12, height: "100%", overflow: "auto" }}>
        <Panel title="Tick Service · Unreachable" tag="ERROR">
          <div style={{ padding: 16 }}>
            <div className="pill pill-red-solid" style={{ fontSize: 11, padding: "3px 8px" }}>
              ● TICK VM UNREACHABLE
            </div>
            <div style={{ marginTop: 12, color: "var(--down)", fontSize: 12 }}>
              Backend returned {healthErr.status || "no response"}.
            </div>
            <div className="dim" style={{ marginTop: 6, fontSize: 11, fontFamily: "inherit", whiteSpace: "pre-wrap" }}>
              {healthErr.error}
            </div>
            <div className="faint" style={{ marginTop: 14, fontSize: 10, lineHeight: 1.5 }}>
              The frontend reads from <span className="bright">/api/tick/*</span>.
              In distributed mode the backend proxies to <span className="bright">TICK_SERVICE_URL</span>;
              in single-VM mode it serves from the in-process tick_service
              (requires <span className="bright">TICK_SERVICE_BOOT=1</span>, the default).
              Restart the backend after changing either env var.
            </div>
            <button className="btn btn-primary" style={{ marginTop: 14 }}
              onClick={() => setRetryNonce(n => n + 1)}>RETRY</button>
          </div>
        </Panel>
      </div>
    );
  }

  const modeTone = (() => {
    if (!health) return "pill-dim";
    if (health.degraded_reason) return "pill-down";
    if (health.mode === "websocket") return "pill-up";
    if (health.mode === "polling")   return "pill-amber";
    return "pill-dim";
  })();
  const ageTone = (sec) => {
    if (sec == null) return "down";
    if (sec < 5)  return "up";
    if (sec < 30) return "amber";
    return "down";
  };

  return (
    <div style={{ padding: 8, height: "100%", display: "flex", flexDirection: "column", gap: 8, minHeight: 0 }}>
      {/* ── Status header ─────────────────────────────────────────────────── */}
      <Panel title="Tick Service · Live State" tag={health && health.automatic ? "AUTOMATIC · READ-ONLY" : null}>
        <div style={{ display: "flex", alignItems: "stretch", flexWrap: "wrap" }}>
          <div style={{ padding: "8px 12px", borderRight: "1px solid var(--border)" }}>
            <div className="h-xxs">MODE</div>
            <div style={{ marginTop: 4 }}>
              <span className={`pill ${modeTone}`} style={{ fontSize: 11, padding: "2px 8px", fontWeight: 600 }}>
                {health ? (health.mode || "—").toUpperCase() : "…"}
              </span>
            </div>
            {health && health.intended_mode && health.intended_mode !== health.mode ? (
              <div className="faint" style={{ fontSize: 9, marginTop: 3 }}>intended: {health.intended_mode}</div>
            ) : null}
          </div>
          <StatCell
            label="LAST TICK AGE"
            value={health && health.latest_tick_age_s != null ? `${health.latest_tick_age_s.toFixed(1)}s` : "—"}
            tone={ageTone(health && health.latest_tick_age_s)}
          />
          <StatCell label="SUBSCRIBED" value={health ? String(health.subscribed_count) : "—"} />
          <StatCell label="PINNED"     value={health ? String(health.pinned_count)     : "—"} />
          <StatCell label="EXCHANGE"   value={health ? (health.exchange_segment || "—") : "—"} />
          <StatCell
            label="UPTIME"
            value={health ? formatUptime(health.uptime_s) : "—"}
          />
          {health && health.degraded_reason ? (
            <div style={{ padding: "8px 12px", flex: 1, minWidth: 240 }}>
              <div className="h-xxs">DEGRADED REASON</div>
              <div className="down" style={{ fontSize: 11, marginTop: 3, whiteSpace: "normal" }}>{health.degraded_reason}</div>
            </div>
          ) : null}
        </div>
        {health && Array.isArray(health.pinned_sample) && health.pinned_sample.length ? (
          <div style={{ display: "flex", flexWrap: "wrap", gap: 4, padding: "6px 12px 8px", borderTop: "1px solid var(--border)" }}>
            <span className="h-xxs" style={{ alignSelf: "center", marginRight: 4 }}>SYMBOLS</span>
            {health.pinned_sample.map(s => (
              <span key={s} className="chip" style={{ fontSize: 9, padding: "1px 5px", color: "var(--fg)" }}>{s}</span>
            ))}
            {health.pinned_count > health.pinned_sample.length ? (
              <span className="faint" style={{ fontSize: 9, alignSelf: "center", marginLeft: 4 }}>
                +{health.pinned_count - health.pinned_sample.length} more
              </span>
            ) : null}
          </div>
        ) : null}
      </Panel>

      {/* ── Body: symbol table (left) + logs (right) ──────────────────────── */}
      <div style={{ display: "grid", gridTemplateColumns: "1.2fr 1fr", gap: 8, flex: 1, minHeight: 0 }}>

        {/* SYMBOL TABLE */}
        <Panel
          title={`Pinned Symbols${rows.length !== symbols.length ? ` · ${rows.length} of ${symbols.length}` : ` · ${symbols.length}`}`}
          tag={symbolsErr ? "ERROR" : null}
          actions={
            <input
              className="input"
              placeholder="filter symbol…"
              value={filter}
              onChange={e => setFilter(e.target.value)}
              style={{ width: 160, fontSize: 10 }}
            />
          }
          bodyFlush
        >
          <div style={{ height: "100%", overflow: "auto" }}>
            {symbolsErr ? (
              <div style={{ padding: 12 }} className="down">{symbolsErr}</div>
            ) : null}
            <table className="tbl tbl-compact">
              <thead>
                <tr>
                  <th style={{ cursor: "pointer", width: 110 }} onClick={() => setSort("symbol")}>SYMBOL{sortArrow("symbol")}</th>
                  <th className="num" style={{ cursor: "pointer", width: 90 }}  onClick={() => setSort("last_price")}>LTP{sortArrow("last_price")}</th>
                  <th className="num" style={{ cursor: "pointer", width: 110 }} onClick={() => setSort("last_ts")}>LAST TICK{sortArrow("last_ts")}</th>
                  <th className="num" style={{ cursor: "pointer", width: 70 }}  onClick={() => setSort("idle_s")}>IDLE (s){sortArrow("idle_s")}</th>
                  <th className="num" style={{ cursor: "pointer", width: 70 }}  onClick={() => setSort("ticks_seen")}>TICKS{sortArrow("ticks_seen")}</th>
                </tr>
              </thead>
              <tbody>
                {rows.length === 0 && !symbolsErr ? (
                  <tr><td colSpan="5" className="faint" style={{ padding: 16, textAlign: "center" }}>no symbols (waiting on first tick service health response)</td></tr>
                ) : null}
                {rows.map(r => {
                  const tone = r.idle_s == null ? "var(--bg-1)" : r.idle_s >= 300 ? "rgba(255,77,77,0.08)" : r.idle_s >= 60 ? "rgba(255,176,32,0.06)" : "var(--bg-1)";
                  const isExp = expanded === r.symbol;
                  return (
                    <React.Fragment key={r.symbol}>
                      <tr style={{ background: tone, cursor: "pointer" }}
                          onClick={() => setExpanded(isExp ? null : r.symbol)}>
                        <td className="bright">
                          {r.symbol}
                          {r.source === "parquet" ? (
                            <span className="pill pill-cyan" style={{ marginLeft: 6, fontSize: 8, padding: "0 4px", letterSpacing: "0.06em" }} title="Loaded from today's parquet (no live tick yet since restart)">DISK</span>
                          ) : null}
                        </td>
                        <td className="num tnum">{r.last_price != null ? Number(r.last_price).toFixed(2) : "—"}</td>
                        <td className="num tnum faint">{formatHMS(r.last_ts)}</td>
                        <td className="num tnum">{r.idle_s != null ? r.idle_s.toFixed(1) : "—"}</td>
                        <td className="num tnum">{r.ticks_seen}</td>
                      </tr>
                      {isExp ? (
                        <tr style={{ background: "var(--bg-2)" }}>
                          <td colSpan="5" style={{ padding: 8 }}>
                            {expandedData == null ? (
                              <div className="faint">loading recent ticks…</div>
                            ) : expandedData.error ? (
                              <div className="down">{expandedData.error}</div>
                            ) : (expandedData.recent || []).length === 0 ? (
                              <div className="faint">0 recent ticks (buffer empty)</div>
                            ) : (
                              <>
                                {expandedData.source === "parquet" ? (
                                  <div className="cyan" style={{ fontSize: 9, marginBottom: 4, letterSpacing: "0.05em" }}>
                                    ◉ FROM DISK · ring buffer empty since restart, showing today's parquet tail
                                  </div>
                                ) : null}
                              <table className="tbl tbl-compact" style={{ background: "var(--bg-2)" }}>
                                <thead><tr><th style={{ width: 110 }}>TIME</th><th className="num" style={{ width: 80 }}>PRICE</th><th className="num" style={{ width: 80 }}>VOL</th><th>SOURCE</th></tr></thead>
                                <tbody>
                                  {expandedData.recent.slice().reverse().map((t, i) => (
                                    <tr key={i}>
                                      <td className="faint tnum">{formatHMS(t.ts)}</td>
                                      <td className="num tnum bright">{t.last_price != null ? Number(t.last_price).toFixed(2) : "—"}</td>
                                      <td className="num tnum dim">{t.volume != null ? Number(t.volume).toFixed(0) : "—"}</td>
                                      <td className="dim" style={{ fontSize: 10 }}>{t.source || "—"}</td>
                                    </tr>
                                  ))}
                                </tbody>
                              </table>
                              </>
                            )}
                          </td>
                        </tr>
                      ) : null}
                    </React.Fragment>
                  );
                })}
              </tbody>
            </table>
          </div>
        </Panel>

        {/* LOGS PANEL */}
        <Panel
          title={`Tick Logs${visibleLogs.length !== allLogs.length ? ` · ${visibleLogs.length} of ${allLogs.length}` : ` · ${allLogs.length}`}`}
          tag={
            paused ? "PAUSED" :
            stream.status === "open" ? "LIVE" :
            stream.status === "reconnecting" ? "RECONNECT" :
            stream.status === "error" ? "ERROR" :
            stream.status || ""
          }
          actions={
            <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
              <input
                className="input"
                placeholder="grep…"
                value={grep}
                onChange={e => setGrep(e.target.value)}
                style={{ width: 130, fontSize: 10 }}
              />
              <label className="dim" style={{ fontSize: 9, display: "flex", alignItems: "center", gap: 3 }}>
                <input className="checkbox" type="checkbox" checked={autoScroll} onChange={e => setAutoScroll(e.target.checked)} />
                AUTOSCROLL
              </label>
              <button className="btn btn-xs" onClick={() => setPaused(p => !p)}>
                {paused ? "RESUME" : "PAUSE"}
              </button>
            </div>
          }
          bodyFlush
        >
          <div ref={logBodyRef} style={{ height: "100%", overflow: "auto", fontFamily: "inherit", fontSize: 10, lineHeight: 1.45 }}>
            {visibleLogs.length === 0 ? (
              <div className="faint" style={{ padding: 12 }}>
                {allLogs.length === 0 ? "waiting for first log line…" : "no lines match grep"}
              </div>
            ) : (
              visibleLogs.map((l, i) => {
                const lvlColor = l.level === "ERROR" || l.level === "CRITICAL" ? "var(--down)" :
                                 l.level === "WARNING" ? "var(--amber)" :
                                 "var(--fg)";
                return (
                  <div key={i} style={{ padding: "1px 8px", display: "flex", gap: 8, borderBottom: "1px solid var(--grid)" }}>
                    <span className="faint tnum" style={{ flex: "0 0 130px" }}>{l.ts}</span>
                    <span style={{ flex: "0 0 60px", color: lvlColor, fontWeight: 500 }}>{l.level}</span>
                    <span className="dim" style={{ flex: "0 0 160px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} title={l.logger}>{l.logger}</span>
                    <span style={{ flex: 1, color: lvlColor, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{l.message}</span>
                  </div>
                );
              })
            )}
          </div>
        </Panel>
      </div>
    </div>
  );
}

// =============================================================================
// SystemPage — admin-only telemetry: every microservice's health in one place.
// Gated by ADMIN_EMAILS on the backend; FE also hides the nav for non-admins
// as a UX nicety (the backend 403 is still authoritative).
// =============================================================================
function SystemPage({ app, currentUser }) {
  const [snapshot, setSnapshot] = useState(null);
  const [err, setErr] = useState(null);

  const stream = useStream("/api/admin/telemetry/stream", {
    onEvent: (ev) => {
      if (ev && ev.services) setSnapshot(ev);
    },
  });

  useEffect(() => {
    if (stream.snapshot && stream.snapshot.services) setSnapshot(stream.snapshot);
  }, [stream.snapshot]);

  useEffect(() => {
    if (stream.status === "error" || stream.status === "reconnecting") {
      setErr(stream.error);
    } else if (stream.status === "open") {
      setErr(null);
    }
  }, [stream.status, stream.error]);

  if (err && !snapshot) {
    return (
      <div style={{ padding: 12, height: "100%", overflow: "auto" }}>
        <Panel title="System · Telemetry" tag="ERROR">
          <div style={{ padding: 16 }}>
            <div className="pill pill-red-solid" style={{ fontSize: 11, padding: "3px 8px" }}>● UNREACHABLE</div>
            <div className="down" style={{ marginTop: 12, fontSize: 12 }}>{err}</div>
            <div className="faint" style={{ marginTop: 10, fontSize: 10 }}>
              Likely causes: not signed in as an admin email, or the backend hasn't rebuilt with /api/admin/* yet.
            </div>
          </div>
        </Panel>
      </div>
    );
  }

  const services = snapshot ? snapshot.services || {} : {};
  const order = ["backend", "tick", "arbitrage", "auto_executor", "scheduler", "redis"];
  const rows = order.filter(k => services[k]).map(k => ({ key: k, ...services[k] }));
  const liveCount = rows.filter(r => r.state === "live").length;

  return (
    <div style={{ padding: 8, height: "100%", display: "flex", flexDirection: "column", gap: 8, minHeight: 0 }}>
      {/* Header strip */}
      <Panel title="System · Microservice Telemetry" tag={
        stream.status === "open" ? "LIVE · 5s push" :
        stream.status === "reconnecting" ? "RECONNECTING" :
        stream.status === "error" ? "ERROR" :
        stream.status || "…"
      }>
        <div style={{ display: "flex", alignItems: "stretch" }}>
          <StatCell label="SERVICES LIVE" value={`${liveCount} / ${rows.length}`} tone={liveCount === rows.length ? "up" : liveCount === 0 ? "down" : null}/>
          <StatCell label="SNAPSHOT AGE" value={snapshot ? `${Math.max(0, Math.floor(Date.now() / 1000 - snapshot.generated_at))}s` : "—"}/>
          <div style={{ padding: "8px 12px", flex: 1 }}>
            <div className="h-xxs">ADMIN</div>
            <div className="dim" style={{ fontSize: 11, marginTop: 3 }}>{currentUser ? currentUser.email : "—"}</div>
          </div>
        </div>
      </Panel>

      {/* Service grid */}
      <Panel title="Services" tag={`${rows.length} known`} bodyFlush>
        <div style={{ height: "100%", overflow: "auto", padding: 8, display: "flex", flexDirection: "column", gap: 6 }}>
          {rows.length === 0 ? (
            <div className="faint" style={{ padding: 12 }}>waiting for first telemetry frame…</div>
          ) : rows.map(r => <ServiceCard key={r.key} svc={r} />)}
        </div>
      </Panel>
    </div>
  );
}

function ServiceCard({ svc }) {
  const stateColor = (s) => {
    if (s === "live") return "var(--up)";
    if (s === "down") return "var(--down)";
    if (s === "tripped") return "var(--down)";
    if (s === "idle") return "var(--cyan)";
    if (s === "not_configured" || s === "not_installed") return "var(--fg-dim)";
    return "var(--amber)";
  };
  const pillClass = (s) => {
    if (s === "live") return "pill-up";
    if (s === "down" || s === "tripped") return "pill-red-solid";
    if (s === "idle") return "pill-cyan";
    return "pill-dim";
  };
  const url = svc.url || svc.body?.exchange_segment ? svc.url : svc.url;
  return (
    <div style={{ border: "1px solid var(--border)", padding: "8px 10px", display: "grid", gridTemplateColumns: "180px 1fr 220px", gap: 12, alignItems: "center" }}>
      <div>
        <div style={{ fontSize: 11, color: "var(--fg-bright)", fontWeight: 600, letterSpacing: "0.06em", textTransform: "uppercase" }}>{svc.key}</div>
        <div className="faint" style={{ fontSize: 9, marginTop: 2 }}>{svc.role || "—"}</div>
      </div>
      <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
        <span className={`pill ${pillClass(svc.state)}`} style={{ fontSize: 10, padding: "2px 6px", fontWeight: 600 }}>
          ● {(svc.state || "—").toUpperCase()}
        </span>
        {svc.latency_ms != null ? (
          <span className="dim tnum" style={{ fontSize: 10 }}>{svc.latency_ms.toFixed(0)}ms</span>
        ) : null}
        {svc.url ? (
          <span className="faint tnum" style={{ fontSize: 9 }}>{svc.url}</span>
        ) : null}
        {svc.detail ? (
          <span className="dim" style={{ fontSize: 10 }}>{svc.detail}</span>
        ) : null}
        {svc.error ? (
          <span className="down" style={{ fontSize: 10 }}>{svc.error}</span>
        ) : null}
      </div>
      <div style={{ fontSize: 9, color: "var(--fg-dim)", textAlign: "right" }}>
        {svc.body && typeof svc.body === "object" ? <ServiceBodyDetail body={svc.body}/> : null}
        {svc.running != null ? <span>running={String(svc.running)} </span> : null}
        {svc.tripped ? <span className="down">tripped </span> : null}
        {svc.daily_count != null ? <span>day:{svc.daily_count} </span> : null}
        {svc.version ? <span>v{svc.version} </span> : null}
        {svc.tick_boot != null ? <span>tick_boot={svc.tick_boot}</span> : null}
      </div>
    </div>
  );
}

function ServiceBodyDetail({ body }) {
  const fields = [];
  if (body.subscribed_count != null) fields.push(`subs:${body.subscribed_count}`);
  if (body.pinned_count != null)     fields.push(`pin:${body.pinned_count}`);
  if (body.latest_tick_age_s != null) fields.push(`age:${body.latest_tick_age_s.toFixed(1)}s`);
  if (body.uptime_s != null) fields.push(`up:${Math.floor(body.uptime_s)}s`);
  if (body.mode) fields.push(`mode:${body.mode}`);
  if (body.degraded_reason) fields.push(`deg:${body.degraded_reason}`);
  if (!fields.length) return null;
  return <span>{fields.join(" ")} </span>;
}

function formatHMS(ts) {
  if (ts == null) return "—";
  // ts is unix seconds (int). Convert to HH:MM:SS local time.
  const ms = (typeof ts === "number" && ts < 1e12) ? ts * 1000 : ts;
  const d = new Date(ms);
  if (isNaN(d.getTime())) return "—";
  return d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}

function formatUptime(sec) {
  if (sec == null) return "—";
  const s = Math.floor(sec);
  const d = Math.floor(s / 86400);
  const h = Math.floor((s % 86400) / 3600);
  const m = Math.floor((s % 3600) / 60);
  if (d) return `${d}d ${h}h`;
  if (h) return `${h}h ${m}m`;
  return `${m}m ${s % 60}s`;
}

Object.assign(window, { ScannerPage, SymbolsPage, SectorsPage, SectorGroupCard, SectorRRG, OptionsPage, BacktestPage, BotsPage, SettingsPage, IndicatorChart, SymbolGraphAll, SectorRotationPanel, GlobalPage, TVWidget, MarketDepth, CalendarsPage, FiiDiiPanel, FiiDiiCashPanel, FiiFnoPanel, EconomicPanel, EarningsPanel, TickPage, SystemPage });
