/**
 * 中控行情区:K 线 + 成交量;Hub 后台轮询 + SSE 直推尾部 K 线;「自动」控制价格轴与视口跟随.
 */
(function () {
  const CHART_WATCH_HEARTBEAT_MS = 25000;
  const CHART_SSE_FALLBACK_MS = 15000;
  const DEFAULT_VISIBLE_BARS = 200;
  const CHART_LOAD_LEFT_THRESHOLD = 25;
  const CHART_INITIAL_LIMITS = {
    "1m": 2000,
    "5m": 2000,
    "15m": 2000,
    "1h": 1000,
    "2h": 1000,
    "4h": 1000,
    "1d": 500,
    "1w": 500,
  };
  const CHART_CHUNK_LIMITS = {
    "1m": 500,
    "5m": 500,
    "15m": 500,
    "1h": 300,
    "2h": 300,
    "4h": 300,
    "1d": 200,
    "1w": 150,
  };
  const CHART_MEMORY_CAPS = {
    "1m": 5000,
    "5m": 5000,
    "15m": 5000,
    "1h": 1000,
    "2h": 1000,
    "4h": 1000,
    "1d": 1000,
    "1w": 500,
  };
  const RIGHT_OFFSET_BARS = 10;
  const CANDLE_SCALE_BOTTOM = 0.26;
  const VOLUME_SCALE_TOP = 0.73;
  const VOLUME_SCALE_BOTTOM = 0.06;
  const PANEL_VOL_H = 0.12;
  const PANEL_MACD_H = 0.14;
  const PANEL_RSI_H = 0.14;
  const SWING_LOOKBACK = 4;
  const MAX_DIV_MARKERS = 4;
  const TF_MS = {
    "1m": 60_000,
    "5m": 5 * 60_000,
    "15m": 15 * 60_000,
    "1h": 60 * 60_000,
    "2h": 2 * 60 * 60_000,
    "4h": 4 * 60 * 60_000,
    "1d": 24 * 60 * 60_000,
    "1w": 7 * 24 * 60 * 60_000,
  };
  const TF_BY_MINUTES = {
    "1": "1m",
    "5": "5m",
    "15": "15m",
    "60": "1h",
    "120": "2h",
    "240": "4h",
    "1440": "1d",
    "10080": "1w",
  };
  const TF_MINUTE_KEYS = Object.keys(TF_BY_MINUTES).sort(function (a, b) {
    return b.length - a.length;
  });
  const TF_CN_LABEL = {
    "1m": "1分钟",
    "5m": "5分钟",
    "15m": "15分钟",
    "1h": "1小时",
    "2h": "2小时",
    "4h": "4小时",
    "1d": "日线",
    "1w": "周线",
  };
  const TF_DIGIT_TIMEOUT_MS = 650;
  const CHART_TZ_OFFSET_SEC = 8 * 60 * 60;

  function pad2(n) {
    return n < 10 ? "0" + n : String(n);
  }

  function utcSecToBjDate(utcSec) {
    return new Date((Number(utcSec) + CHART_TZ_OFFSET_SEC) * 1000);
  }

  function formatChartTimeBj(utcSec, withDate) {
    const d = utcSecToBjDate(utcSec);
    const h = pad2(d.getUTCHours());
    const mi = pad2(d.getUTCMinutes());
    if (!withDate) return h + ":" + mi;
    return (
      d.getUTCFullYear() +
      "-" +
      pad2(d.getUTCMonth() + 1) +
      "-" +
      pad2(d.getUTCDate()) +
      " " +
      h +
      ":" +
      mi
    );
  }

  function chartLocalizationBj() {
    return {
      locale: "zh-CN",
      dateFormat: "yyyy-MM-dd",
      timeFormatter: function (time) {
        if (typeof time === "number") return formatChartTimeBj(time, true);
        if (time && typeof time === "object" && time.year) {
          return time.year + "-" + pad2(time.month) + "-" + pad2(time.day);
        }
        return "";
      },
      tickMarkFormatter: function (time, tickMarkType) {
        if (typeof time !== "number") {
          if (time && typeof time === "object" && time.year) {
            return time.year + "-" + pad2(time.month) + "-" + pad2(time.day);
          }
          return "";
        }
        const d = utcSecToBjDate(time);
        if (tickMarkType === 0) return String(d.getUTCFullYear());
        if (tickMarkType === 1) return pad2(d.getUTCMonth() + 1);
        if (tickMarkType === 2) return pad2(d.getUTCDate());
        return formatChartTimeBj(time, false);
      },
    };
  }

  function buildChartLocalization() {
    const loc = chartLocalizationBj();
    loc.priceFormatter = function (p) {
      return fmtPrice(p);
    };
    return loc;
  }

  const chartHost = document.getElementById("market-chart");
  if (!chartHost) return;

  const elDrawToolbar = document.getElementById("market-draw-toolbar");
  const elDrawCanvas = document.getElementById("market-draw-canvas");
  const elChartMain = chartHost.closest(".market-chart-main");
  let drawAttached = false;

  const elExchange = document.getElementById("market-exchange");
  const elSymbol = document.getElementById("market-symbol");
  const elVolRankMeta = document.getElementById("market-vol-rank-meta");
  const elVolRankList = document.getElementById("market-vol-rank-list");
  const elVolRankSheet = document.getElementById("market-vol-rank-sheet");
  const elVolRankAnchor = document.getElementById("market-vol-rank-anchor");
  const elVolRankAnchorFs = document.getElementById("market-vol-rank-anchor-fs");
  let activeScanTab = "top20";
  let scanSheetOpen = false;
  const elTf = document.getElementById("market-timeframe");
  const elRefresh = document.getElementById("market-refresh");
  const elStatus = document.getElementById("market-status");
  const elUpdated = document.getElementById("market-updated");
  const elBarCountdown = document.getElementById("market-bar-countdown");
  const elO = document.getElementById("mkt-o");
  const elH = document.getElementById("mkt-h");
  const elL = document.getElementById("mkt-l");
  const elC = document.getElementById("mkt-c");
  const elV = document.getElementById("mkt-v");
  const elAmp = document.getElementById("mkt-amp");
  const elPriceTag = document.getElementById("market-price-tag");
  const elPriceTagValue = document.getElementById("market-price-tag-value");
  const elPriceTagTime = document.getElementById("market-price-tag-time");
  const elExLabel = document.getElementById("mkt-exchange-label");
  const elExBadge = document.getElementById("market-exchange-badge");
  const elSymLabel = document.getElementById("mkt-symbol-label");
  const elTfLabel = document.getElementById("mkt-tf-label");
  const elPriceAuto = document.getElementById("market-price-auto");
  const elPosPanel = document.getElementById("market-pos-panel");
  const elPosSide = document.getElementById("mkt-pos-side");
  const elPosEntry = document.getElementById("mkt-pos-entry");
  const elPosSl = document.getElementById("mkt-pos-sl");
  const elPosTp = document.getElementById("mkt-pos-tp");
  const elPosSize = document.getElementById("mkt-pos-size");
  const elPosPnl = document.getElementById("mkt-pos-pnl");
  const elPosOrders = document.getElementById("market-pos-orders");
  const elPosClear = document.getElementById("market-pos-clear");
  const elChartWrap = document.getElementById("market-chart-wrap");
  const elFsBtn = document.getElementById("market-chart-fullscreen");
  const elFsExit = document.getElementById("market-chart-fs-exit");
  const elIndEma = document.getElementById("market-ind-ema");
  const elIndEma144 = document.getElementById("market-ind-ema144");
  const elIndMacd = document.getElementById("market-ind-macd");
  const elIndRsi = document.getElementById("market-ind-rsi");
  const elPrevCloseLine = document.getElementById("market-prev-close-line");
  const elPrevHlLines = document.getElementById("market-prev-hl-lines");
  const elDaySplit = document.getElementById("market-day-split");
  const PREV_CLOSE_LINE_STORAGE_KEY = "hub-market-prev-close-line";
  const PREV_HL_LINES_STORAGE_KEY = "hub-market-prev-hl-lines";
  const DAY_SPLIT_STORAGE_KEY = "hub-market-day-split";
  const BJ_OFFSET_SEC = 8 * 60 * 60;
  const elFsToolbar = document.getElementById("market-fs-toolbar");
  const elFsExchange = document.getElementById("market-fs-exchange");
  const elFsSymbol = document.getElementById("market-fs-symbol");
  const elFsTf = document.getElementById("market-fs-timeframe");
  const elFsLoad = document.getElementById("market-fs-load");
  const elDivLegend = document.getElementById("market-div-legend");

  const HUB_MARKET_POS_CTX_KEY = "hubMarketPosContext";
  const EMA_FAST = 21;
  const EMA_SLOW = 55;
  const EMA_TREND = 144;

  let chartFullscreen = false;
  const indicatorState = { ema: false, ema144: false, macd: false, rsi: false };
  const indSeries = {
    ema21: null,
    ema55: null,
    ema144: null,
    macdLine: null,
    macdSignal: null,
    macdHist: null,
    rsi: null,
    rsi30: null,
    rsi70: null,
  };
  let divergenceMarkers = [];

  let chart = null;
  let candleSeries = null;
  let volumeSeries = null;
  let priceTick = null;
  let priceAutoScale = true;
  let rangeMarkers = [];
  let yesterdayPriceLines = [];
  let positionLines = [];
  let posContext = null;
  let posPnlTimer = null;
  const SL_DRAG_HIT_PX = 12;
  let slDrag = null;
  let currentPriceLine = null;
  let lastCandles = [];
  let candleByTime = {};
  let chartMeta = null;
  let loadToken = 0;
  let marketInited = false;
  let refreshTimer = null;
  let chartWatchTimer = null;
  let chartEventSource = null;
  let chartSseReconnectTimer = null;
  let localChartVersion = 0;
  let localSeriesVersion = 0;
  let lastViewKey = "";
  let currentTf = "1d";
  let exhaustedLeft = false;
  let loadingLeft = false;
  let chartDataLoading = false;
  let chartViewEpoch = 0;
  let rangeUiTimer = null;
  let loadOlderTimer = null;
  let chartRangeUserLocked = false;
  let chartRangeLockTimer = null;
  let suppressRangeUserLock = false;
  const CHART_TAIL_REFRESH_LIMIT = 30;
  let priceTagTimer = null;
  let tfDigitBuf = "";
  let tfDigitTimer = null;
  let tfHintTimer = null;

  function escHtml(s) {
    return String(s || "")
      .replace(/&/g, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;");
  }

  function normalizeMarketSymbol(sym) {
    const s = String(sym || "").trim().toUpperCase();
    const m = s.match(/^([A-Z0-9]+)\/([A-Z0-9]+)(?::([A-Z0-9]+))?$/);
    if (!m) return s;
    return m[1] + "/" + m[2];
  }

  function loadPosContextFromStorage() {
    try {
      const raw = sessionStorage.getItem(HUB_MARKET_POS_CTX_KEY);
      if (!raw) return null;
      return JSON.parse(raw);
    } catch (e) {
      return null;
    }
  }

  function posContextMatches(ctx, exKey, sym) {
    if (!ctx) return false;
    const ctxSym = normalizeMarketSymbol(ctx.symbol || "");
    const ctxEx = String(ctx.exchange_key || "").trim();
    return ctxSym === normalizeMarketSymbol(sym) && ctxEx === String(exKey || "").trim();
  }

  function clearPosPanel() {
    if (elPosPanel) elPosPanel.classList.add("hidden");
    if (elPosSide) {
      elPosSide.textContent = "";
      elPosSide.className = "market-pos-side";
    }
    ["entry", "sl", "tp", "size"].forEach(function (k) {
      const el = { entry: elPosEntry, sl: elPosSl, tp: elPosTp, size: elPosSize }[k];
      if (el) el.textContent = "—";
    });
    if (elPosPnl) {
      elPosPnl.textContent = "—";
      elPosPnl.className = "market-pos-pnl";
    }
    if (elPosOrders) elPosOrders.innerHTML = "";
    syncChartWrapLayout();
  }

  function loadBoolPref(key, defaultValue) {
    try {
      const raw = localStorage.getItem(key);
      if (raw === "1" || raw === "true") return true;
      if (raw === "0" || raw === "false") return false;
    } catch (_) {}
    return !!defaultValue;
  }

  function saveBoolPref(key, on) {
    try {
      localStorage.setItem(key, on ? "1" : "0");
    } catch (_) {}
  }

  function loadDaySplitPref() {
    return loadBoolPref(DAY_SPLIT_STORAGE_KEY, false);
  }

  function saveDaySplitPref(on) {
    saveBoolPref(DAY_SPLIT_STORAGE_KEY, on);
  }

  function loadPrevCloseLinePref() {
    return loadBoolPref(PREV_CLOSE_LINE_STORAGE_KEY, false);
  }

  function savePrevCloseLinePref(on) {
    saveBoolPref(PREV_CLOSE_LINE_STORAGE_KEY, on);
  }

  function loadPrevHlLinesPref() {
    return loadBoolPref(PREV_HL_LINES_STORAGE_KEY, false);
  }

  function savePrevHlLinesPref(on) {
    saveBoolPref(PREV_HL_LINES_STORAGE_KEY, on);
  }

  function chartResetHour() {
    return chartMeta && chartMeta.volume_rank_reset_hour != null
      ? Number(chartMeta.volume_rank_reset_hour)
      : 8;
  }

  function utcSecToBjParts(utcSec) {
    const d = new Date((Number(utcSec) + BJ_OFFSET_SEC) * 1000);
    return {
      y: d.getUTCFullYear(),
      m: d.getUTCMonth(),
      d: d.getUTCDate(),
      h: d.getUTCHours(),
    };
  }

  function tradingDayKeyFromUtcSec(utcSec, resetHour) {
    const p = utcSecToBjParts(utcSec);
    let y = p.y;
    let m = p.m;
    let d = p.d;
    if (p.h < resetHour) {
      const prev = new Date(Date.UTC(y, m, d) - 86400000);
      y = prev.getUTCFullYear();
      m = prev.getUTCMonth();
      d = prev.getUTCDate();
    }
    return (
      y +
      "-" +
      String(m + 1).padStart(2, "0") +
      "-" +
      String(d).padStart(2, "0")
    );
  }

  function prevTradingDayKey(tdKey) {
    const parts = String(tdKey || "").split("-");
    if (parts.length !== 3) return "";
    const dt = new Date(Date.UTC(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])));
    const prev = new Date(dt.getTime() - 86400000);
    return (
      prev.getUTCFullYear() +
      "-" +
      String(prev.getUTCMonth() + 1).padStart(2, "0") +
      "-" +
      String(prev.getUTCDate()).padStart(2, "0")
    );
  }

  function computePrevTradingDayOhlc(candles, resetHour) {
    if (!candles || !candles.length) return null;
    const curTd = tradingDayKeyFromUtcSec(candles[candles.length - 1].time, resetHour);
    const prevTd = prevTradingDayKey(curTd);
    if (!prevTd) return null;
    const dayCandles = candles
      .filter(function (c) {
        return c && tradingDayKeyFromUtcSec(c.time, resetHour) === prevTd;
      })
      .sort(function (a, b) {
        return a.time - b.time;
      });
    if (!dayCandles.length) return null;
    let hi = null;
    let lo = null;
    dayCandles.forEach(function (c) {
      if (!hi || c.high > hi) hi = c.high;
      if (!lo || c.low < lo) lo = c.low;
    });
    const last = dayCandles[dayCandles.length - 1];
    return {
      close: last.close,
      high: hi,
      low: lo,
      tradingDay: prevTd,
    };
  }

  function syncPrevDayLineUi() {
    const closeOn = !!(elPrevCloseLine && elPrevCloseLine.checked);
    const hlOn = !!(elPrevHlLines && elPrevHlLines.checked);
    savePrevCloseLinePref(closeOn);
    savePrevHlLinesPref(hlOn);
    updateYesterdayPriceLines();
  }

  function applyTradingDaySplit(enabled) {
    if (window.HubChartDraw && typeof window.HubChartDraw.setTradingDaySplit === "function") {
      window.HubChartDraw.setTradingDaySplit(enabled);
    }
  }

  function syncTradingDaySplitUi() {
    const on = !!(elDaySplit && elDaySplit.checked);
    saveDaySplitPref(on);
    applyTradingDaySplit(on);
  }

  function ensureDrawLayer() {
    if (drawAttached || !window.HubChartDraw || !chart || !candleSeries) return;
    window.HubChartDraw.attach({
      chart: chart,
      series: candleSeries,
      hostEl: chartHost,
      mainEl: elChartMain,
      canvasEl: elDrawCanvas,
      toolbarEl: elDrawToolbar,
      getCandles: function () {
        return lastCandles;
      },
    });
    window.HubChartDraw.setViewKey(currentChartViewKey());
    applyTradingDaySplit(elDaySplit ? elDaySplit.checked : loadDaySplitPref());
    drawAttached = true;
  }

  function syncDrawViewKey() {
    if (window.HubChartDraw && drawAttached) {
      window.HubChartDraw.setViewKey(currentChartViewKey());
    }
  }

  function resizeChart() {
    if (!chart || !chartHost) return;
    chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
    updatePriceTag();
    if (window.HubChartDraw && drawAttached) {
      window.HubChartDraw.resize();
    }
  }

  let resizeChartRaf = 0;
  function scheduleChartResize() {
    if (resizeChartRaf) cancelAnimationFrame(resizeChartRaf);
    resizeChartRaf = requestAnimationFrame(function () {
      resizeChartRaf = 0;
      syncChartWrapLayout();
    });
  }

  function syncChartWrapLayout() {
    const wrap = elChartWrap || (chartHost && chartHost.closest(".market-chart-wrap"));
    if (wrap && elPosPanel && !chartFullscreen) {
      wrap.classList.toggle("has-pos-panel", !elPosPanel.classList.contains("hidden"));
    }
    resizeChart();
  }

  function readIndicatorState() {
    indicatorState.ema = !!(elIndEma && elIndEma.checked);
    indicatorState.ema144 = !!(elIndEma144 && elIndEma144.checked);
    indicatorState.macd = !!(elIndMacd && elIndMacd.checked);
    indicatorState.rsi = !!(elIndRsi && elIndRsi.checked);
  }

  function emaArray(values, period) {
    const result = new Array(values.length).fill(null);
    const k = 2 / (period + 1);
    let ema = null;
    for (let i = 0; i < values.length; i++) {
      const v = values[i];
      if (v == null || !Number.isFinite(v)) continue;
      if (ema == null) {
        if (i < period - 1) continue;
        let sum = 0;
        let ok = true;
        for (let j = i - period + 1; j <= i; j++) {
          const x = values[j];
          if (x == null || !Number.isFinite(x)) {
            ok = false;
            break;
          }
          sum += x;
        }
        if (!ok) continue;
        ema = sum / period;
      } else {
        ema = v * k + ema * (1 - k);
      }
      result[i] = ema;
    }
    return result;
  }

  function buildEmaSeries(candles, period) {
    const closes = candles.map(function (c) {
      return Number(c.close);
    });
    const vals = emaArray(closes, period);
    const out = [];
    for (let i = 0; i < candles.length; i++) {
      if (vals[i] == null) continue;
      out.push({ time: candles[i].time, value: vals[i] });
    }
    return out;
  }

  function buildMacdData(candles) {
    const closes = candles.map(function (c) {
      return Number(c.close);
    });
    const ema12 = emaArray(closes, 12);
    const ema26 = emaArray(closes, 26);
    const macd = new Array(closes.length).fill(null);
    for (let i = 0; i < closes.length; i++) {
      if (ema12[i] == null || ema26[i] == null) continue;
      macd[i] = ema12[i] - ema26[i];
    }
    const signal = emaArray(macd, 9);
    const macdLine = [];
    const signalLine = [];
    const histData = [];
    for (let i = 0; i < candles.length; i++) {
      const t = candles[i].time;
      if (macd[i] != null) macdLine.push({ time: t, value: macd[i] });
      if (signal[i] != null) signalLine.push({ time: t, value: signal[i] });
      if (macd[i] != null && signal[i] != null) {
        const h = macd[i] - signal[i];
        histData.push({
          time: t,
          value: h,
          color: h >= 0 ? "rgba(0, 255, 157, 0.55)" : "rgba(255, 77, 109, 0.55)",
        });
      }
    }
    return { macdLine, signalLine, histData };
  }

  function buildRsiSeries(candles, period) {
    const out = [];
    if (!candles || candles.length < period + 1) return out;
    let avgGain = 0;
    let avgLoss = 0;
    for (let i = 1; i <= period; i++) {
      const ch = Number(candles[i].close) - Number(candles[i - 1].close);
      if (ch >= 0) avgGain += ch;
      else avgLoss -= ch;
    }
    avgGain /= period;
    avgLoss /= period;
    let rsi = 50;
    if (avgLoss <= 0) rsi = 100;
    else if (avgGain <= 0) rsi = 0;
    else rsi = 100 - 100 / (1 + avgGain / avgLoss);
    out.push({ time: candles[period].time, value: rsi });

    for (let i = period + 1; i < candles.length; i++) {
      const ch = Number(candles[i].close) - Number(candles[i - 1].close);
      const gain = ch > 0 ? ch : 0;
      const loss = ch < 0 ? -ch : 0;
      avgGain = (avgGain * (period - 1) + gain) / period;
      avgLoss = (avgLoss * (period - 1) + loss) / period;
      if (avgLoss <= 0) rsi = 100;
      else if (avgGain <= 0) rsi = 0;
      else rsi = 100 - 100 / (1 + avgGain / avgLoss);
      out.push({ time: candles[i].time, value: rsi });
    }
    return out;
  }

  function createLineSeries(opts) {
    if (!chart) return null;
    const base = {
      lineWidth: 1,
      priceLineVisible: false,
      lastValueVisible: false,
    };
    const o = Object.assign(base, opts || {});
    if (typeof chart.addLineSeries === "function") return chart.addLineSeries(o);
    if (
      typeof chart.addSeries === "function" &&
      window.LightweightCharts &&
      window.LightweightCharts.LineSeries
    ) {
      return chart.addSeries(window.LightweightCharts.LineSeries, o);
    }
    return null;
  }

  function createHistSeries(opts) {
    if (!chart) return null;
    const base = { priceLineVisible: false, lastValueVisible: false };
    const o = Object.assign(base, opts || {});
    if (typeof chart.addHistogramSeries === "function") return chart.addHistogramSeries(o);
    if (
      typeof chart.addSeries === "function" &&
      window.LightweightCharts &&
      window.LightweightCharts.HistogramSeries
    ) {
      return chart.addSeries(window.LightweightCharts.HistogramSeries, o);
    }
    return null;
  }

  function clearIndicatorSeries() {
    if (!chart) return;
    [indSeries.rsi30, indSeries.rsi70].forEach(function (pl) {
      if (pl && indSeries.rsi) {
        try {
          indSeries.rsi.removePriceLine(pl);
        } catch (e) {}
      }
    });
    indSeries.rsi30 = null;
    indSeries.rsi70 = null;
    Object.keys(indSeries).forEach(function (k) {
      if (k === "rsi30" || k === "rsi70") return;
      if (indSeries[k]) {
        try {
          chart.removeSeries(indSeries[k]);
        } catch (e) {}
        indSeries[k] = null;
      }
    });
  }

  function findSwings(values, lookback) {
    const lows = [];
    const highs = [];
    const lb = lookback || SWING_LOOKBACK;
    for (let i = lb; i < values.length - lb; i++) {
      const v = values[i];
      if (v == null || !Number.isFinite(v)) continue;
      let isLow = true;
      let isHigh = true;
      for (let j = 1; j <= lb; j++) {
        const lv = values[i - j];
        const rv = values[i + j];
        if (lv == null || rv == null || v > lv || v > rv) isLow = false;
        if (lv == null || rv == null || v < lv || v < rv) isHigh = false;
      }
      if (isLow) lows.push({ i: i, v: v });
      if (isHigh) highs.push({ i: i, v: v });
    }
    return { lows, highs };
  }

  function detectDivergences(candles, indicatorByIndex, sourceLabel) {
    const markers = [];
    if (!candles.length || !indicatorByIndex.length) return markers;

    const closes = candles.map(function (c) {
      return Number(c.close);
    });
    const priceSw = findSwings(closes, SWING_LOOKBACK);
    const indSw = findSwings(indicatorByIndex, SWING_LOOKBACK);

    function pushMarker(idx, kind, label) {
      const c = candles[idx];
      if (!c || c.time == null) return;
      const bull = kind === "bull";
      markers.push({
        time: c.time,
        position: bull ? "belowBar" : "aboveBar",
        color: bull ? "#00ff9d" : "#ff4d6d",
        shape: bull ? "arrowUp" : "arrowDown",
        text: label,
      });
    }

    const pLows = priceSw.lows;
    const iLows = indSw.lows;
    if (pLows.length >= 2 && iLows.length >= 2) {
      const p1 = pLows[pLows.length - 2];
      const p2 = pLows[pLows.length - 1];
      const i1 = iLows[iLows.length - 2];
      const i2 = iLows[iLows.length - 1];
      if (Math.abs(p1.i - i1.i) < 30 && Math.abs(p2.i - i2.i) < 30) {
        if (p2.v < p1.v && i2.v > i1.v) {
          pushMarker(p2.i, "bull", sourceLabel + "底背离");
        }
      }
    }

    const pHighs = priceSw.highs;
    const iHighs = indSw.highs;
    if (pHighs.length >= 2 && iHighs.length >= 2) {
      const p1 = pHighs[pHighs.length - 2];
      const p2 = pHighs[pHighs.length - 1];
      const i1 = iHighs[iHighs.length - 2];
      const i2 = iHighs[iHighs.length - 1];
      if (Math.abs(p1.i - i1.i) < 30 && Math.abs(p2.i - i2.i) < 30) {
        if (p2.v > p1.v && i2.v < i1.v) {
          pushMarker(p2.i, "bear", sourceLabel + "顶背离");
        }
      }
    }

    return markers.slice(-MAX_DIV_MARKERS);
  }

  function buildRsiByIndex(candles, period) {
    const series = buildRsiSeries(candles, period);
    const byIdx = new Array(candles.length).fill(null);
    let si = 0;
    for (let i = 0; i < candles.length; i++) {
      if (si < series.length && series[si].time === candles[i].time) {
        byIdx[i] = series[si].value;
        si++;
      }
    }
    return { series, byIdx };
  }

  function buildMacdByIndex(candles) {
    const closes = candles.map(function (c) {
      return Number(c.close);
    });
    const ema12 = emaArray(closes, 12);
    const ema26 = emaArray(closes, 26);
    const macd = new Array(closes.length).fill(null);
    for (let i = 0; i < closes.length; i++) {
      if (ema12[i] == null || ema26[i] == null) continue;
      macd[i] = ema12[i] - ema26[i];
    }
    return macd;
  }

  function panelLayout() {
    const rsiOn = indicatorState.rsi;
    const macdOn = indicatorState.macd;
    if (!rsiOn && !macdOn) {
      return {
        candle: { top: 0.06, bottom: CANDLE_SCALE_BOTTOM },
        volume: { top: VOLUME_SCALE_TOP, bottom: VOLUME_SCALE_BOTTOM },
        macd: null,
        rsi: null,
      };
    }

    const gap = 0.02;
    let stackBottom = gap;
    let rsiMargins = null;
    let macdMargins = null;

    if (rsiOn) {
      rsiMargins = {
        top: 1 - stackBottom - PANEL_RSI_H,
        bottom: stackBottom,
      };
      stackBottom += PANEL_RSI_H;
    }
    if (macdOn) {
      macdMargins = {
        top: 1 - stackBottom - PANEL_MACD_H,
        bottom: stackBottom,
      };
      stackBottom += PANEL_MACD_H;
    }

    const volBottom = stackBottom;
    const volTop = 1 - volBottom - PANEL_VOL_H;
    const candleBottom = Math.max(CANDLE_SCALE_BOTTOM, 1 - volTop + 0.01);

    return {
      candle: { top: 0.06, bottom: candleBottom },
      volume: { top: volTop, bottom: volBottom },
      macd: macdMargins,
      rsi: rsiMargins,
    };
  }

  function applyScaleLayout() {
    if (!chart) return;
    const L = panelLayout();
    chart.priceScale("right").applyOptions({
      scaleMargins: L.candle,
    });
    if (volumeSeries && volumeSeries.priceScale) {
      volumeSeries.priceScale().applyOptions({
        scaleMargins: L.volume,
        borderColor: "#2a4058",
      });
    }
    if (indSeries.macdLine && indSeries.macdLine.priceScale) {
      indSeries.macdLine.priceScale().applyOptions({
        scaleMargins: L.macd,
        borderColor: "#2a4058",
        autoScale: true,
      });
    }
    if (indSeries.rsi && indSeries.rsi.priceScale) {
      indSeries.rsi.priceScale().applyOptions({
        scaleMargins: L.rsi,
        borderColor: "#2a4058",
        autoScale: true,
      });
    }
  }

  function updateDivergenceLegend(rsiDiv, macdDiv) {
    if (!elDivLegend) return;
    const parts = [];
    if (indicatorState.rsi && rsiDiv.length) {
      parts.push("RSI " + rsiDiv.map(function (m) { return m.text; }).join(" · "));
    }
    if (indicatorState.macd && macdDiv.length) {
      parts.push("MACD " + macdDiv.map(function (m) { return m.text; }).join(" · "));
    }
    if (!parts.length) {
      elDivLegend.textContent = "";
      elDivLegend.classList.add("hidden");
      return;
    }
    elDivLegend.textContent = parts.join(" ｜ ");
    elDivLegend.classList.remove("hidden");
  }

  function applyCandleDivergenceMarkers() {
    if (!candleSeries || !candleSeries.setMarkers) return;
    const sorted = divergenceMarkers
      .slice()
      .sort(function (a, b) {
        return a.time > b.time ? 1 : a.time < b.time ? -1 : 0;
      });
    candleSeries.setMarkers(sorted);
  }

  function updateIndicators() {
    if (!chart || !lastCandles.length) return;
    readIndicatorState();
    clearIndicatorSeries();
    divergenceMarkers = [];

    if (indicatorState.ema) {
      const pf = tickToPriceFormat(priceTick);
      indSeries.ema21 = createLineSeries({
        color: "#f0c040",
        title: "EMA21",
        priceScaleId: "right",
        priceFormat: pf,
      });
      indSeries.ema55 = createLineSeries({
        color: "#c878ff",
        title: "EMA55",
        priceScaleId: "right",
        priceFormat: pf,
      });
      if (indSeries.ema21) indSeries.ema21.setData(buildEmaSeries(lastCandles, EMA_FAST));
      if (indSeries.ema55) indSeries.ema55.setData(buildEmaSeries(lastCandles, EMA_SLOW));
    }

    if (indicatorState.ema144) {
      const pf144 = tickToPriceFormat(priceTick);
      indSeries.ema144 = createLineSeries({
        color: "#5ce0b8",
        title: "EMA144",
        priceScaleId: "right",
        priceFormat: pf144,
      });
      if (indSeries.ema144) indSeries.ema144.setData(buildEmaSeries(lastCandles, EMA_TREND));
    }

    let rsiDiv = [];
    let macdDiv = [];

    if (indicatorState.macd) {
      const macd = buildMacdData(lastCandles);
      const macdByIdx = buildMacdByIndex(lastCandles);
      indSeries.macdLine = createLineSeries({
        color: "#5b9cf5",
        title: "MACD",
        priceScaleId: "macd",
        priceLineVisible: false,
        lastValueVisible: false,
      });
      indSeries.macdSignal = createLineSeries({
        color: "#ffb84d",
        title: "Signal",
        priceScaleId: "macd",
        priceLineVisible: false,
        lastValueVisible: false,
      });
      indSeries.macdHist = createHistSeries({
        priceScaleId: "macd",
        priceLineVisible: false,
        lastValueVisible: false,
      });
      if (indSeries.macdLine) indSeries.macdLine.setData(macd.macdLine);
      if (indSeries.macdSignal) indSeries.macdSignal.setData(macd.signalLine);
      if (indSeries.macdHist) indSeries.macdHist.setData(macd.histData);
      macdDiv = detectDivergences(lastCandles, macdByIdx, "MACD");
      divergenceMarkers = divergenceMarkers.concat(macdDiv);
    }

    if (indicatorState.rsi) {
      const rsiPack = buildRsiByIndex(lastCandles, 14);
      indSeries.rsi = createLineSeries({
        color: "#8fc8ff",
        title: "RSI(14)",
        priceScaleId: "rsi",
        priceFormat: { type: "price", precision: 1, minMove: 0.1 },
        priceLineVisible: false,
        lastValueVisible: true,
      });
      if (indSeries.rsi) {
        indSeries.rsi.setData(rsiPack.series);
        try {
          indSeries.rsi30 = indSeries.rsi.createPriceLine({
            price: 30,
            color: "rgba(255, 77, 109, 0.75)",
            lineWidth: 1,
            lineStyle: 2,
            axisLabelVisible: true,
            title: "30",
          });
          indSeries.rsi70 = indSeries.rsi.createPriceLine({
            price: 70,
            color: "rgba(0, 255, 157, 0.75)",
            lineWidth: 1,
            lineStyle: 2,
            axisLabelVisible: true,
            title: "70",
          });
        } catch (e) {}
      }
      rsiDiv = detectDivergences(lastCandles, rsiPack.byIdx, "RSI");
      divergenceMarkers = divergenceMarkers.concat(rsiDiv);
    }

    updateDivergenceLegend(rsiDiv, macdDiv);
    applyCandleDivergenceMarkers();
    applyScaleLayout();
    scheduleChartResize();
  }

  function syncFsToolbarFromMain() {
    if (!chartFullscreen) return;
    if (elFsExchange && elExchange) elFsExchange.value = elExchange.value;
    if (elFsSymbol && elSymbol) elFsSymbol.value = elSymbol.value;
    if (elFsTf && elTf) elFsTf.value = elTf.value;
  }

  function syncMainFromFsToolbar() {
    if (elExchange && elFsExchange) elExchange.value = elFsExchange.value;
    if (elSymbol && elFsSymbol) elSymbol.value = elFsSymbol.value.trim().toUpperCase();
    if (elTf && elFsTf) elTf.value = elFsTf.value;
    updateExchangeDisplay();
    updateHeaderLabels(elSymbol && elSymbol.value, elTf && elTf.value);
  }

  function isMarketPageActive() {
    const page = document.getElementById("page-market");
    return !!(page && !page.classList.contains("hidden"));
  }

  function isTypingInField(target) {
    if (!target) return false;
    const tag = (target.tagName || "").toLowerCase();
    if (tag === "input" || tag === "textarea" || tag === "select") return true;
    return !!target.isContentEditable;
  }

  function canUseTfKeyboard(e) {
    if (!isMarketPageActive()) return false;
    if (e.altKey || e.ctrlKey || e.metaKey) return false;
    if (isTypingInField(e.target)) return false;
    return true;
  }

  function canExtendTfDigitBuffer(buf) {
    if (!buf) return false;
    return TF_MINUTE_KEYS.some(function (k) {
      return k.length > buf.length && k.indexOf(buf) === 0;
    });
  }

  function shouldCommitTfBufferNow(buf) {
    const tf = resolveTfFromDigitBuffer(buf);
    if (!tf) return false;
    return !canExtendTfDigitBuffer(buf);
  }

  function resolveTfFromDigitBuffer(buf) {
    if (!buf) return null;
    return TF_BY_MINUTES[buf] || null;
  }

  function flashTfSwitchHint(tf) {
    const label = TF_CN_LABEL[tf] || tf;
    const text = "周期 → " + label + "(" + tf + ")";
    if (elTfLabel) elTfLabel.textContent = tf;
    if (elBarCountdown) {
      if (tfHintTimer) clearTimeout(tfHintTimer);
      elBarCountdown.textContent = text;
      elBarCountdown.classList.add("market-tf-key-hint");
      tfHintTimer = setTimeout(function () {
        tfHintTimer = null;
        elBarCountdown.classList.remove("market-tf-key-hint");
        tickLiveClock();
      }, 1200);
      return;
    }
    if (elStatus) {
      if (tfHintTimer) clearTimeout(tfHintTimer);
      const prevClass = elStatus.className;
      const prevText = elStatus.textContent;
      elStatus.className = "market-status";
      elStatus.textContent = text;
      tfHintTimer = setTimeout(function () {
        tfHintTimer = null;
        elStatus.className = prevClass;
        elStatus.textContent = prevText;
      }, 1200);
    }
  }

  function applyTimeframe(tf, fromKeyboard) {
    if (!tf || !TF_MS[tf]) return false;
    const cur = (elTf && elTf.value) || currentTf;
    if (cur === tf) return false;
    if (elTf) elTf.value = tf;
    if (elFsTf) elFsTf.value = tf;
    currentTf = tf;
    lastViewKey = "";
    tickLiveClock();
    updateHeaderLabels(
      elSymbol && elSymbol.value.trim().toUpperCase(),
      tf
    );
    syncFsToolbarFromMain();
    if (fromKeyboard) flashTfSwitchHint(tf);
    loadChart(false);
    return true;
  }

  function commitTfDigitBuffer() {
    const buf = tfDigitBuf;
    tfDigitBuf = "";
    if (tfDigitTimer) {
      clearTimeout(tfDigitTimer);
      tfDigitTimer = null;
    }
    const tf = resolveTfFromDigitBuffer(buf);
    if (tf) applyTimeframe(tf, true);
  }

  function handleTfDigitKey(digit) {
    if (!digit) return;
    if (tfDigitBuf && !canExtendTfDigitBuffer(tfDigitBuf)) {
      tfDigitBuf = "";
    }
    tfDigitBuf += digit;
    if (shouldCommitTfBufferNow(tfDigitBuf)) {
      commitTfDigitBuffer();
      return;
    }
    if (!canExtendTfDigitBuffer(tfDigitBuf)) {
      tfDigitBuf = digit;
      if (shouldCommitTfBufferNow(tfDigitBuf)) {
        commitTfDigitBuffer();
        return;
      }
    }
    if (tfDigitTimer) clearTimeout(tfDigitTimer);
    tfDigitTimer = setTimeout(commitTfDigitBuffer, TF_DIGIT_TIMEOUT_MS);
  }

  function isChartFullscreenKey(e) {
    if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return false;
    return e.code === "KeyF" || e.key === "f" || e.key === "F";
  }

  function onChartFullscreenKey(e) {
    if (!isMarketPageActive() || !isChartFullscreenKey(e)) return;
    if (isTypingInField(e.target)) return;
    e.preventDefault();
    e.stopImmediatePropagation();
    toggleChartFullscreen();
  }

  function focusMarketChartArea() {
    const wrap = elChartWrap;
    if (!wrap) return;
    if (!wrap.hasAttribute("tabindex")) wrap.setAttribute("tabindex", "-1");
    try {
      wrap.focus({ preventScroll: true });
    } catch (err) {
      /* ignore */
    }
  }

  function onMarketKeydown(e) {
    if (!isMarketPageActive()) return;

    if (e.key === "Escape" && chartFullscreen) {
      e.preventDefault();
      e.stopPropagation();
      setChartFullscreen(false);
      return;
    }

    if (!canUseTfKeyboard(e)) return;
    if (e.key >= "0" && e.key <= "9") {
      e.preventDefault();
      handleTfDigitKey(e.key);
      return;
    }
    if (e.key === "Enter" && tfDigitBuf) {
      e.preventDefault();
      commitTfDigitBuffer();
    }
  }

  function populateFsExchangeOptions() {
    if (!elFsExchange || !elExchange) return;
    elFsExchange.innerHTML = elExchange.innerHTML;
    elFsExchange.value = elExchange.value;
  }

  function unlockMarketFsOrientation() {
    try {
      if (screen.orientation && typeof screen.orientation.unlock === "function") {
        screen.orientation.unlock();
      }
    } catch (e) {
      /* ignore */
    }
    document.body.classList.remove("market-fs-want-landscape");
  }

  function lockMarketFsLandscape() {
    document.body.classList.add("market-fs-want-landscape");
    const orient = screen.orientation;
    if (!orient || typeof orient.lock !== "function") return;
    Promise.resolve(orient.lock("landscape"))
      .catch(function () {
        return orient.lock("landscape-primary");
      })
      .catch(function () {
        /* 浏览器可能拒绝;CSS 会提示转横屏 */
      });
  }

  function setChartFullscreen(on) {
    chartFullscreen = !!on;
    const wrap = elChartWrap || (chartHost && chartHost.closest(".market-chart-wrap"));
    if (wrap) wrap.classList.toggle("is-fullscreen", chartFullscreen);
    document.body.classList.toggle("market-chart-fs-open", chartFullscreen);
    if (elFsToolbar) elFsToolbar.classList.toggle("hidden", !chartFullscreen);
    if (elFsBtn) elFsBtn.textContent = chartFullscreen ? "退出全屏" : "全屏";
    if (elFsExit) {
      if (chartFullscreen) elFsExit.classList.remove("hidden");
      else elFsExit.classList.add("hidden");
    }
    mountVolRankSheet(chartFullscreen);
    if (chartFullscreen) {
      populateFsExchangeOptions();
      syncFsToolbarFromMain();
      // 手机全屏优先横屏看图;电脑/平板不强制
      if (window.matchMedia && window.matchMedia("(max-width: 720px)").matches) {
        lockMarketFsLandscape();
      }
    } else {
      unlockMarketFsOrientation();
    }
    scheduleChartResize();
  }

  function toggleChartFullscreen() {
    setChartFullscreen(!chartFullscreen);
  }

  function showHubToast(msg, isErr) {
    const t = document.getElementById("toast");
    if (!t) return;
    t.textContent = msg;
    t.classList.toggle("err", !!isErr);
    t.classList.add("show");
    clearTimeout(showHubToast._hideTimer);
    showHubToast._hideTimer = setTimeout(function () {
      t.classList.remove("show");
    }, 3500);
  }

  function estimateLinearSwapUpnl(side, entry, mark, contracts, contractSize) {
    const e = Number(entry);
    const m = Number(mark);
    const c = Math.abs(Number(contracts));
    let mult = Number(contractSize);
    if (!Number.isFinite(mult) || mult <= 0) mult = 1;
    if (!Number.isFinite(e) || !Number.isFinite(m) || !Number.isFinite(c) || c <= 0) {
      return null;
    }
    const diff =
      (side || "long").toLowerCase() === "long" ? m - e : e - m;
    return Math.round(diff * c * mult * 100) / 100;
  }

  function formatPosPnlText(ctx) {
    const upnl = ctx && ctx.unrealized_pnl;
    if (upnl == null || !Number.isFinite(Number(upnl))) return { text: "—", cls: "" };
    const n = Number(upnl);
    let text = (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
    const notional = ctx.notional_usdt;
    const entry = Number(ctx.entry);
    const contracts = Math.abs(Number(ctx.contracts));
    const cs =
      ctx.contract_size != null && Number(ctx.contract_size) > 0
        ? Number(ctx.contract_size)
        : 1;
    let pctBase = null;
    if (notional != null && Math.abs(Number(notional)) > 1e-8) {
      pctBase = Math.abs(Number(notional));
    } else if (
      Number.isFinite(entry) &&
      entry > 0 &&
      Number.isFinite(contracts) &&
      contracts > 0
    ) {
      pctBase = entry * contracts * cs;
    }
    if (pctBase != null && pctBase > 1e-8) {
      const pct = (n / pctBase) * 100;
      text += " (" + (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%)";
    } else if (ctx.plan_margin != null && Number(ctx.plan_margin) > 1e-8) {
      const pct = (n / Number(ctx.plan_margin)) * 100;
      text += " (" + (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%)";
    }
    return { text: text, cls: n > 0 ? "pnl-up" : n < 0 ? "pnl-down" : "" };
  }

  function findTrendFloatingPnl(row, sym, side) {
    const hm = row.hub_monitor;
    if (!hm || !Array.isArray(hm.trends)) return null;
    for (let i = 0; i < hm.trends.length; i++) {
      const t = hm.trends[i];
      const ts = normalizeMarketSymbol(t.exchange_symbol || t.symbol || "");
      if (ts !== sym) continue;
      if ((t.direction || "").toLowerCase() !== side) continue;
      const fp = t.floating_pnl;
      if (fp != null && Number.isFinite(Number(fp))) return Number(fp);
      if (t.plan_margin_capital != null && Number(t.plan_margin_capital) > 0) {
        /* 保留 plan_margin 供百分比 */
      }
    }
    return null;
  }

  function findTrendPlan(row, sym, side) {
    const hm = row.hub_monitor;
    if (!hm || !Array.isArray(hm.trends)) return null;
    for (let i = 0; i < hm.trends.length; i++) {
      const t = hm.trends[i];
      const ts = normalizeMarketSymbol(t.exchange_symbol || t.symbol || "");
      if (ts !== sym) continue;
      if ((t.direction || "").toLowerCase() !== side) continue;
      return t;
    }
    return null;
  }

  function applyTrendPlanFields(row, sym, side) {
    if (!posContext) return;
    const t = findTrendPlan(row, sym, side);
    if (!t) return;
    const m = t.plan_margin_capital;
    if (m != null && Number.isFinite(Number(m)) && Number(m) > 0) {
      posContext.plan_margin = Number(m);
    }
    const lev = t.leverage;
    if (lev != null && Number.isFinite(Number(lev)) && Number(lev) > 0) {
      posContext.leverage = Number(lev);
    }
  }

  /** U 本位线性永续:(标记价-开仓价)×张数×contractSize(空头取反) */
  function calcContractsUpnl(ctx, markPx) {
    if (!ctx || markPx == null || !Number.isFinite(Number(markPx))) return null;
    return estimateLinearSwapUpnl(
      ctx.side,
      ctx.entry,
      markPx,
      ctx.contracts,
      ctx.contract_size
    );
  }

  function latestChartMarkPrice() {
    if (!lastCandles || !lastCandles.length) return null;
    const bar = lastCandles[lastCandles.length - 1];
    const c = bar && bar.close != null ? Number(bar.close) : null;
    return c != null && Number.isFinite(c) && c > 0 ? c : null;
  }

  function updateLivePosPnl(markOverride) {
    if (!posContext) return false;
    const mark =
      markOverride != null && Number.isFinite(Number(markOverride))
        ? Number(markOverride)
        : latestChartMarkPrice() ||
          (posContext.mark_price != null && Number.isFinite(Number(posContext.mark_price))
            ? Number(posContext.mark_price)
            : null);
    if (mark == null) return false;
    const live = calcContractsUpnl(posContext, mark);
    if (live != null) {
      posContext.unrealized_pnl = live;
      posContext.mark_price = mark;
      renderPosPnlDisplay(posContext);
      return true;
    }
    if (
      posContext.unrealized_pnl != null &&
      Number.isFinite(Number(posContext.unrealized_pnl))
    ) {
      posContext.mark_price = mark;
      renderPosPnlDisplay(posContext);
      return true;
    }
    return false;
  }

  function syncPosTpslFromAgentPosition(p) {
    if (!posContext || !p) return;
    const et = p.exchange_tpsl;
    if (et && typeof et === "object") {
      if (et.sl && et.sl.trigger_price != null) {
        posContext.stop_loss = Number(et.sl.trigger_price);
      }
      if (et.tp && et.tp.trigger_price != null) {
        posContext.take_profit = Number(et.tp.trigger_price);
        posContext.tp_monitored = false;
      }
    }
    const cond = Array.isArray(p.conditional_orders) ? p.conditional_orders : [];
    for (let i = 0; i < cond.length; i++) {
      const o = cond[i];
      const lbl = String(o.label || "");
      const px =
        o.trigger_price != null && Number.isFinite(Number(o.trigger_price))
          ? Number(o.trigger_price)
          : null;
      if (px == null) continue;
      if (/^止损/.test(lbl)) posContext.stop_loss = px;
      else if (/^止盈/.test(lbl) && !/止盈止损/.test(lbl)) {
        posContext.take_profit = px;
        posContext.tp_monitored = false;
      }
    }
  }

  function renderPosPnlDisplay(ctx) {
    if (!elPosPnl) return;
    const p = formatPosPnlText(ctx);
    elPosPnl.textContent = p.text;
    elPosPnl.className = "market-pos-pnl " + p.cls;
  }

  function paintPosPnl(ctx) {
    if (ctx === posContext && updateLivePosPnl()) return;
    renderPosPnlDisplay(ctx);
  }

  function stopPosPnlPoll() {
    if (posPnlTimer) {
      clearInterval(posPnlTimer);
      posPnlTimer = null;
    }
  }

  function startPosPnlPoll() {
    stopPosPnlPoll();
    if (!posContext || !posContext.exchange_id) return;
    refreshPosPnlFromBoard();
    posPnlTimer = setInterval(function () {
      if (!updateLivePosPnl()) refreshPosPnlFromBoard();
    }, 2000);
  }

  async function refreshPosPnlFromBoard() {
    if (!posContext || !posContext.exchange_id) return;
    try {
      const r = await fetch("/api/monitor/board/snapshot", { credentials: "same-origin" });
      if (!r.ok) return;
      const data = await r.json();
      const rows = data.rows || [];
      const sym = normalizeMarketSymbol(posContext.symbol || "");
      const side = (posContext.side || "long").toLowerCase();
      for (let i = 0; i < rows.length; i++) {
        const row = rows[i];
        const ex = row.exchange || {};
        if (ex.id !== posContext.exchange_id) continue;
        applyTrendPlanFields(row, sym, side);
        const positions = (row.agent && row.agent.positions) || [];
        for (let j = 0; j < positions.length; j++) {
          const p = positions[j];
          if ((p.side || "").toLowerCase() !== side) continue;
          if (normalizeMarketSymbol(p.symbol || "") !== sym) continue;
          if (p.entry_price != null && Number.isFinite(Number(p.entry_price))) {
            posContext.entry = Number(p.entry_price);
          }
          if (p.contract_size != null && Number.isFinite(Number(p.contract_size))) {
            posContext.contract_size = Number(p.contract_size);
          }
          if (p.contracts != null && Number.isFinite(Number(p.contracts))) {
            posContext.contracts = Number(p.contracts);
          }
          if (p.mark_price != null && Number.isFinite(Number(p.mark_price))) {
            posContext.mark_price = Number(p.mark_price);
          }
          if (p.notional_usdt != null && Number.isFinite(Number(p.notional_usdt))) {
            posContext.notional_usdt = Number(p.notional_usdt);
          }
          syncPosTpslFromAgentPosition(p);
          if (elPosSl && posContext.stop_loss != null) {
            elPosSl.textContent = fmtPrice(posContext.stop_loss);
          }
          if (elPosTp && posContext.take_profit != null && !posContext.tp_monitored) {
            elPosTp.textContent = fmtPrice(posContext.take_profit);
          }
          const markForPnl =
            latestChartMarkPrice() ||
            (p.mark_price != null && Number.isFinite(Number(p.mark_price))
              ? Number(p.mark_price)
              : null);
          if (!updateLivePosPnl(markForPnl)) {
            let upnl =
              p.unrealized_pnl != null && Number.isFinite(Number(p.unrealized_pnl))
                ? Number(p.unrealized_pnl)
                : findTrendFloatingPnl(row, sym, side);
            if (upnl != null) {
              posContext.unrealized_pnl = upnl;
              renderPosPnlDisplay(posContext);
            }
          }
          updatePositionLines();
          try {
            sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext));
          } catch (_) {}
          return;
        }
        applyTrendPlanFields(row, sym, side);
        if (!updateLivePosPnl()) {
          const trendUpnl = findTrendFloatingPnl(row, sym, side);
          if (trendUpnl != null) {
            posContext.unrealized_pnl = trendUpnl;
            renderPosPnlDisplay(posContext);
          }
        }
        try {
          sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext));
        } catch (_) {}
        return;
      }
    } catch (_) {}
  }

  function resolveTpForPlace(ctx) {
    if (!ctx) return null;
    const tp = ctx.take_profit;
    if (tp != null && Number(tp) > 0) return Number(tp);
    const orders = ctx.orders || [];
    for (let i = 0; i < orders.length; i++) {
      const o = orders[i];
      const lbl = String(o.label || "");
      if (/止盈/.test(lbl) && o.price != null && Number(o.price) > 0) return Number(o.price);
    }
    return null;
  }

  async function placeTpslFromChart(newSl) {
    if (!posContext || !posContext.exchange_id) {
      showHubToast("缺少交易所信息,无法挂单", true);
      return;
    }
    const sl = roundToTick(newSl);
    if (sl == null || !Number.isFinite(sl) || sl <= 0) {
      showHubToast("止损价无效", true);
      return;
    }
    const tp = resolveTpForPlace(posContext);
    if (tp == null || tp <= 0) {
      showHubToast("未找到有效止盈价,请先在监控区用「委托」填写止盈", true);
      return;
    }
    const sym = normalizeMarketSymbol(posContext.symbol || "");
    const side = posContext.side || "long";
    const contracts = posContext.contracts;
    const oldSl = posContext.stop_loss;
    if (
      !confirm(
        "确认 " +
          sym +
          " " +
          side +
          "\n先撤销全部条件单,再挂止损 " +
          fmtPrice(sl) +
          ",止盈 " +
          fmtPrice(tp) +
          (oldSl != null ? "\n(原止损 " + fmtPrice(oldSl) + ")" : "")
      )
    ) {
      return;
    }
    try {
      const r = await fetch(
        "/api/orders/" + encodeURIComponent(posContext.exchange_id) + "/place-tpsl",
        {
          method: "POST",
          credentials: "same-origin",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            symbol: sym,
            side: side,
            stop_loss: sl,
            take_profit: tp,
            contracts: contracts > 0 ? contracts : null,
          }),
        }
      );
      const j = await r.json();
      const pl = j.payload || {};
      const ok = j.ok && pl.ok !== false;
      showHubToast(
        ok ? "止损已更新(已撤旧条件单并重新挂单)" : pl.error || JSON.stringify(j),
        !ok
      );
      if (ok) {
        posContext.stop_loss = sl;
        try {
          sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext));
        } catch (_) {}
        if (elPosSl) elPosSl.textContent = fmtPrice(sl);
        updatePositionLines();
        fetch("/api/monitor/board/refresh", { method: "POST", credentials: "same-origin" });
      }
    } catch (e) {
      showHubToast(String(e.message || e), true);
    }
  }

  function slLineCoordinate() {
    if (!candleSeries || !posContext) return null;
    const px =
      slDrag && slDrag.active && slDrag.previewSl != null
        ? slDrag.previewSl
        : posContext.stop_loss;
    if (px == null || !Number.isFinite(Number(px))) return null;
    return candleSeries.priceToCoordinate(roundToTick(px));
  }

  function clientYToChartPrice(clientY) {
    if (!candleSeries || !chartHost) return null;
    const rect = chartHost.getBoundingClientRect();
    const y = clientY - rect.top;
    const p = candleSeries.coordinateToPrice(y);
    if (p == null || !Number.isFinite(Number(p))) return null;
    return roundToTick(p);
  }

  function isPointerNearSlLine(clientY) {
    const coord = slLineCoordinate();
    if (coord == null || !chartHost) return false;
    const rect = chartHost.getBoundingClientRect();
    return Math.abs(clientY - rect.top - coord) <= SL_DRAG_HIT_PX;
  }

  function onSlLineHover(e) {
    if (!chartHost || (slDrag && slDrag.active)) return;
    if (!posContext || posContext.stop_loss == null) {
      chartHost.style.cursor = "";
      return;
    }
    chartHost.style.cursor = isPointerNearSlLine(e.clientY) ? "ns-resize" : "";
  }

  function onSlDragStart(e) {
    if (!posContext || posContext.stop_loss == null || !candleSeries) return;
    if (e.button !== 0) return;
    if (!isPointerNearSlLine(e.clientY)) return;
    e.preventDefault();
    slDrag = {
      active: true,
      moved: false,
      startSl: Number(posContext.stop_loss),
      previewSl: Number(posContext.stop_loss),
    };
    if (chartHost) chartHost.style.cursor = "ns-resize";
    updatePositionLines();
  }

  function onSlDragMove(e) {
    if (!slDrag || !slDrag.active) return;
    const p = clientYToChartPrice(e.clientY);
    if (p == null || p <= 0) return;
    slDrag.previewSl = p;
    if (Math.abs(p - slDrag.startSl) > 1e-12) slDrag.moved = true;
    if (elPosSl) elPosSl.textContent = fmtPrice(p);
    updatePositionLines();
  }

  function onSlDragEnd() {
    if (!slDrag || !slDrag.active) {
      slDrag = null;
      if (chartHost) chartHost.style.cursor = "";
      return;
    }
    const preview = slDrag.previewSl;
    const moved = slDrag.moved;
    slDrag = null;
    if (chartHost) chartHost.style.cursor = "";
    updatePositionLines();
    if (!moved || preview == null) return;
    placeTpslFromChart(preview);
  }

  function bindSlDrag() {
    if (!chartHost) return;
    chartHost.addEventListener("mousedown", onSlDragStart);
    chartHost.addEventListener("mousemove", onSlLineHover);
    document.addEventListener("mousemove", onSlDragMove);
    document.addEventListener("mouseup", onSlDragEnd);
  }

  function renderPosPanel(ctx) {
    if (!elPosPanel || !ctx) {
      clearPosPanel();
      return;
    }
    elPosPanel.classList.remove("hidden");
    if (elPosSide) {
      const isShort = (ctx.side || "").toLowerCase() === "short";
      elPosSide.textContent = isShort ? "空" : "多";
      elPosSide.className = "market-pos-side " + (isShort ? "side-short" : "side-long");
    }
    if (elPosEntry) elPosEntry.textContent = ctx.entry != null ? fmtPrice(ctx.entry) : "—";
    if (elPosSl) elPosSl.textContent = ctx.stop_loss != null ? fmtPrice(ctx.stop_loss) : "—";
    if (elPosTp) {
      if (ctx.tp_monitored) {
        elPosTp.textContent =
          ctx.take_profit != null
            ? "程序监控 · " + fmtPrice(ctx.take_profit)
            : "程序监控";
        elPosTp.classList.add("market-pos-tp-monitored");
      } else {
        elPosTp.textContent = ctx.take_profit != null ? fmtPrice(ctx.take_profit) : "—";
        elPosTp.classList.remove("market-pos-tp-monitored");
      }
    }
    if (elPosSize) elPosSize.textContent = ctx.contracts != null ? String(ctx.contracts) : "—";
    paintPosPnl(ctx);
    if (elPosOrders) {
      const orders = Array.isArray(ctx.orders) ? ctx.orders : [];
      if (!orders.length) {
        elPosOrders.innerHTML = '<span class="market-pos-orders-empty">暂无委托单</span>';
      } else {
        elPosOrders.innerHTML = orders
          .map(function (o) {
            const price = o.price != null ? fmtPrice(o.price) : "—";
            const amt = o.amount != null ? String(o.amount) : "";
            return (
              '<span class="market-pos-order">' +
              '<span class="market-pos-order-kind">' +
              escHtml(o.kind || "") +
              "</span>" +
              '<span class="market-pos-order-label">' +
              escHtml(o.label || "") +
              "</span>" +
              '<span class="market-pos-order-price">' +
              price +
              "</span>" +
              (amt ? '<span class="market-pos-order-amt">×' + escHtml(amt) + "</span>" : "") +
              "</span>"
            );
          })
          .join("");
      }
    }
    scheduleChartResize();
  }

  function clearPositionLines() {
    positionLines.forEach(function (m) {
      try {
        candleSeries.removePriceLine(m);
      } catch (e) {}
    });
    positionLines = [];
  }

  function updatePositionLines() {
    clearPositionLines();
    if (!candleSeries || !posContext) return;
    const slPrice =
      slDrag && slDrag.active && slDrag.previewSl != null
        ? slDrag.previewSl
        : posContext.stop_loss;
    const slTitle =
      slDrag && slDrag.active
        ? "止损 " + fmtPrice(slPrice)
        : slPrice != null
          ? "止损 ⟷"
          : "止损";
    const specs = [
      { price: posContext.entry, color: "#5b9cf5", title: "入场", lineWidth: 1 },
      {
        price: slPrice,
        color: "#ff4d6d",
        title: slTitle,
        lineWidth: slPrice != null ? 2 : 1,
      },
    ];
    if (posContext.take_profit != null) {
      specs.push({
        price: posContext.take_profit,
        color: "#00ff9d",
        title: posContext.tp_monitored ? "止盈(程序)" : "止盈",
      });
    }
    specs.forEach(function (s) {
      if (s.price == null || !Number.isFinite(Number(s.price))) return;
      const px = roundToTick(s.price);
      if (px == null || !Number.isFinite(Number(px))) return;
      positionLines.push(
        candleSeries.createPriceLine({
          price: Number(px),
          color: s.color,
          lineWidth: s.lineWidth != null ? s.lineWidth : 1,
          lineStyle: 2,
          axisLabelVisible: true,
          title: s.title,
        })
      );
    });
  }

  function clearPosContext() {
    posContext = null;
    slDrag = null;
    stopPosPnlPoll();
    try {
      sessionStorage.removeItem(HUB_MARKET_POS_CTX_KEY);
    } catch (e) {}
    clearPosPanel();
    clearPositionLines();
    if (chartHost) chartHost.style.cursor = "";
  }

  function applyPosContext(ctx) {
    posContext = ctx;
    renderPosPanel(ctx);
    updatePositionLines();
    startPosPnlPoll();
  }

  function syncPosContextForView(exKey, sym) {
    const stored = loadPosContextFromStorage();
    if (stored && posContextMatches(stored, exKey, sym)) {
      applyPosContext(stored);
      return;
    }
    clearPosContext();
  }

  function fmtVol(v) {
    if (v == null || Number.isNaN(Number(v))) return "-";
    const n = Number(v);
    if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
    if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
    if (n >= 1e3) return (n / 1e3).toFixed(2) + "K";
    return n.toFixed(2);
  }

  function decimalsFromTick(tick) {
    if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null;
    const minMove = Number(tick);
    if (minMove >= 1) return 0;
    const raw = String(minMove);
    const sci = raw.match(/e-(\d+)/i);
    if (sci) return Math.min(12, parseInt(sci[1], 10));
    const fixed = minMove.toFixed(12);
    const frac = fixed.split(".")[1] || "";
    const trimmed = frac.replace(/0+$/, "");
    if (trimmed.length) return Math.min(12, trimmed.length);
    return Math.max(0, Math.min(12, Math.round(-Math.log10(minMove))));
  }

  const SAFE_PRICE_FORMAT = { type: "price", precision: 4, minMove: 0.0001 };

  function tickToPriceFormat(tick) {
    try {
      if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) {
        return { type: "price", precision: 2, minMove: 0.01 };
      }
      const minMove = Number(tick);
      let prec = decimalsFromTick(minMove);
      if (prec == null || prec < 0) prec = 4;
      prec = Math.min(12, Math.max(0, Math.floor(prec)));
      return { type: "price", precision: prec, minMove: minMove };
    } catch (e) {
      return SAFE_PRICE_FORMAT;
    }
  }

  function roundToTick(v) {
    if (v == null || Number.isNaN(Number(v))) return v;
    const n = Number(v);
    const tick = priceTick;
    if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return n;
    const t = Number(tick);
    const rounded = Math.round(n / t) * t;
    const dec = decimalsFromTick(t);
    if (dec == null) return rounded;
    return parseFloat(rounded.toFixed(dec));
  }

  function alignCandlesToTick(candles) {
    if (!Array.isArray(candles) || !candles.length) return candles || [];
    if (priceTick == null || !Number.isFinite(Number(priceTick)) || Number(priceTick) <= 0) {
      return candles;
    }
    return candles.map(function (c) {
      return {
        time: c.time,
        open: roundToTick(c.open),
        high: roundToTick(c.high),
        low: roundToTick(c.low),
        close: roundToTick(c.close),
        volume: c.volume,
      };
    });
  }

  function applyPriceFormatToSeries(series, pf) {
    if (!series || !series.applyOptions) return;
    try {
      series.applyOptions({ priceFormat: pf });
    } catch (e) {
      series.applyOptions({ priceFormat: SAFE_PRICE_FORMAT });
    }
  }

  function applyChartPriceFormat() {
    let pf = SAFE_PRICE_FORMAT;
    try {
      pf = tickToPriceFormat(priceTick);
    } catch (e) {
      pf = SAFE_PRICE_FORMAT;
    }
    applyPriceFormatToSeries(candleSeries, pf);
    applyPriceFormatToSeries(indSeries.ema21, pf);
    applyPriceFormatToSeries(indSeries.ema55, pf);
    if (chart) {
      chart.applyOptions({
        localization: buildChartLocalization(),
      });
    }
  }

  function fmtPrice(v) {
    if (v == null || Number.isNaN(Number(v))) return "-";
    const aligned = roundToTick(v);
    const n = Number(aligned);
    if (n === 0) return "0";
    const dec = decimalsFromTick(priceTick);
    if (dec != null) return n.toFixed(dec);
    const av = Math.abs(n);
    let d = 8;
    if (av >= 10000) d = 2;
    else if (av >= 100) d = 3;
    else if (av >= 1) d = 4;
    else if (av >= 0.01) d = 6;
    let text = n.toFixed(d);
    if (text.indexOf(".") >= 0) text = text.replace(/\.?0+$/, "");
    return text;
  }

  function exchangeLabel() {
    if (!elExchange) return "";
    const opt = elExchange.options[elExchange.selectedIndex];
    if (opt && opt.textContent) return opt.textContent.trim();
    return (elExchange.value || "").trim().toUpperCase();
  }

  function updateExchangeDisplay() {
    const label = exchangeLabel();
    if (elExLabel) elExLabel.textContent = label;
    if (elExBadge) {
      elExBadge.textContent = label;
      elExBadge.setAttribute("aria-hidden", label ? "false" : "true");
    }
  }

  function updateHeaderLabels(sym, tf) {
    if (elSymLabel) elSymLabel.textContent = sym || "—";
    if (elTfLabel) elTfLabel.textContent = tf || "—";
    updateExchangeDisplay();
  }

  function fmtAmplitude(bar) {
    if (!bar) return "-";
    const o = Number(bar.open);
    const h = Number(bar.high);
    const l = Number(bar.low);
    if (!o || o <= 0 || !Number.isFinite(h) || !Number.isFinite(l)) return "-";
    return (((h - l) / o) * 100).toFixed(2) + "%";
  }

  function barRemainMs(tf) {
    const period = TF_MS[tf] || TF_MS["1d"];
    const now = Date.now();
    const barOpen = Math.floor(now / period) * period;
    return Math.max(0, barOpen + period - now);
  }

  function fmtBarCountdown(ms) {
    const total = Math.max(0, Math.floor(ms / 1000));
    const h = Math.floor(total / 3600);
    const m = Math.floor((total % 3600) / 60);
    const s = total % 60;
    const pad = function (n) {
      return n < 10 ? "0" + n : String(n);
    };
    if (h > 0) return h + ":" + pad(m) + ":" + pad(s);
    return pad(m) + ":" + pad(s);
  }

  function paintOhlcv(bar) {
    if (!bar) {
      ["o", "h", "l", "c", "v", "amp"].forEach(function (k) {
        const el = { o: elO, h: elH, l: elL, c: elC, v: elV, amp: elAmp }[k];
        if (el) el.textContent = "-";
      });
      return;
    }
    if (elO) elO.textContent = fmtPrice(bar.open);
    if (elH) elH.textContent = fmtPrice(bar.high);
    if (elL) elL.textContent = fmtPrice(bar.low);
    if (elC) elC.textContent = fmtPrice(bar.close);
    if (elV) elV.textContent = fmtVol(bar.volume);
    if (elAmp) elAmp.textContent = fmtAmplitude(bar);
  }

  function latestCandle() {
    return lastCandles.length ? lastCandles[lastCandles.length - 1] : null;
  }

  function showLatestOhlcv() {
    paintOhlcv(latestCandle());
    updateCurrentPriceLine();
    updatePriceTag();
  }

  function clearCurrentPriceLine() {
    if (currentPriceLine && candleSeries) {
      try {
        candleSeries.removePriceLine(currentPriceLine);
      } catch (e) {}
    }
    currentPriceLine = null;
  }

  function updateCurrentPriceLine() {
    clearCurrentPriceLine();
    if (!candleSeries) return;
    const bar = latestCandle();
    if (!bar || bar.close == null) return;
    const up = Number(bar.close) >= Number(bar.open);
    currentPriceLine = candleSeries.createPriceLine({
      price: Number(roundToTick(bar.close)),
      color: up ? "#00ff9d" : "#ff4d6d",
      lineWidth: 1,
      lineStyle: 2,
      axisLabelVisible: false,
      title: "",
    });
  }

  function tickLiveClock() {
    const cd = fmtBarCountdown(barRemainMs(currentTf));
    if (elPriceTagTime && elPriceTag && !elPriceTag.classList.contains("hidden")) {
      elPriceTagTime.textContent = cd;
    }
    if (elBarCountdown) elBarCountdown.textContent = "距收盘 " + cd;
  }

  function updatePriceTag() {
    if (!elPriceTag || !candleSeries || !chart) return;
    try {
      tickLiveClock();
      const bar = latestCandle();
      if (!bar || bar.close == null) {
        elPriceTag.classList.add("hidden");
        elPriceTag.setAttribute("aria-hidden", "true");
        return;
      }
      let y = null;
      try {
        y = candleSeries.priceToCoordinate(Number(bar.close));
      } catch (e) {
        y = null;
      }
      const hostH = chartHost.clientHeight || 0;
      if (y == null || y < 8 || y > hostH - 8) {
        elPriceTag.classList.add("hidden");
        elPriceTag.setAttribute("aria-hidden", "true");
        return;
      }
      const up = Number(bar.close) >= Number(bar.open);
      elPriceTag.classList.remove("hidden", "is-up", "is-down");
      elPriceTag.classList.add(up ? "is-up" : "is-down");
      elPriceTag.setAttribute("aria-hidden", "false");
      elPriceTag.style.left = "auto";
      elPriceTag.style.right = "0";
      elPriceTag.style.top = y + "px";
      if (elPriceTagValue) elPriceTagValue.textContent = fmtPrice(bar.close);
    } catch (e) {
      elPriceTag.classList.add("hidden");
      elPriceTag.setAttribute("aria-hidden", "true");
    }
  }

  function startPriceTagTimer() {
    stopPriceTagTimer();
    tickLiveClock();
    priceTagTimer = setInterval(tickLiveClock, 1000);
  }

  function stopPriceTagTimer() {
    if (priceTagTimer) clearInterval(priceTagTimer);
    priceTagTimer = null;
  }

  function applyPriceAutoScale() {
    if (!chart) return;
    chart.priceScale("right").applyOptions({ autoScale: priceAutoScale });
    if (elPriceAuto) elPriceAuto.classList.toggle("is-on", priceAutoScale);
  }

  function indexCandles(candles) {
    candleByTime = {};
    (candles || []).forEach(function (c) {
      if (c && c.time != null) candleByTime[c.time] = c;
    });
  }

  function candleAtTime(t) {
    if (t == null) return null;
    return candleByTime[t] || null;
  }

  function chartThemePalette() {
    const light = document.documentElement.getAttribute("data-theme") === "light";
    return light
      ? {
          bg: "#f0f4f9",
          text: "#4a6078",
          border: "#b8c8d8",
          up: "#0a8f5c",
          down: "#c93552",
          volUp: "rgba(10, 143, 92, 0.45)",
          volDown: "rgba(201, 53, 82, 0.45)",
        }
      : {
          bg: "#0a1018",
          text: "#b8d4e8",
          border: "#2a4058",
          up: "#00ff9d",
          down: "#ff4d6d",
          volUp: "rgba(0, 255, 157, 0.5)",
          volDown: "rgba(255, 77, 109, 0.5)",
        };
  }

  function applyChartTheme() {
    if (!chart) return;
    const p = chartThemePalette();
    chart.applyOptions({
      layout: { background: { color: p.bg }, textColor: p.text },
      rightPriceScale: { borderColor: p.border },
      timeScale: { borderColor: p.border },
    });
    if (candleSeries) {
      candleSeries.applyOptions({
        upColor: p.up,
        downColor: p.down,
        wickUpColor: p.up,
        wickDownColor: p.down,
      });
    }
    if (volumeSeries && lastCandles.length) {
      volumeSeries.setData(buildVolumeData(lastCandles));
    }
  }

  function buildVolumeData(candles) {
    const p = chartThemePalette();
    return (candles || []).map(function (c) {
      const up = Number(c.close) >= Number(c.open);
      return {
        time: c.time,
        value: Number(c.volume) || 0,
        color: up ? p.volUp : p.volDown,
      };
    });
  }

  function buildVolumeBar(candle) {
    const p = chartThemePalette();
    const up = Number(candle.close) >= Number(candle.open);
    return {
      time: candle.time,
      value: Number(candle.volume) || 0,
      color: up ? p.volUp : p.volDown,
    };
  }

  function ensureChart() {
    if (chart && candleSeries && volumeSeries) return true;
    if (!window.LightweightCharts) {
      if (elStatus) {
        elStatus.className = "market-status err";
        elStatus.textContent = "图表库加载失败";
      }
      return false;
    }
    const tp = chartThemePalette();
    chart = LightweightCharts.createChart(chartHost, {
      layout: { background: { color: tp.bg }, textColor: tp.text },
      grid: {
        vertLines: { visible: false },
        horzLines: { visible: false },
      },
      rightPriceScale: { borderColor: tp.border, autoScale: true },
      localization: buildChartLocalization(),
      timeScale: {
        borderColor: tp.border,
        timeVisible: true,
        secondsVisible: false,
        rightOffset: RIGHT_OFFSET_BARS,
      },
      crosshair: {
        mode: LightweightCharts.CrosshairMode
          ? LightweightCharts.CrosshairMode.Normal
          : 0,
      },
    });

    const candleOpts = {
      upColor: tp.up,
      downColor: tp.down,
      borderVisible: false,
      wickUpColor: tp.up,
      wickDownColor: tp.down,
      lastValueVisible: false,
      priceLineVisible: false,
      priceFormat: SAFE_PRICE_FORMAT,
    };

    if (typeof chart.addCandlestickSeries === "function") {
      candleSeries = chart.addCandlestickSeries(candleOpts);
    } else if (
      typeof chart.addSeries === "function" &&
      window.LightweightCharts &&
      window.LightweightCharts.CandlestickSeries
    ) {
      candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, candleOpts);
    }
    if (!candleSeries) return false;

    const volOpts = {
      priceFormat: { type: "volume" },
      priceScaleId: "",
      lastValueVisible: false,
    };
    if (typeof chart.addHistogramSeries === "function") {
      volumeSeries = chart.addHistogramSeries(volOpts);
    } else if (
      typeof chart.addSeries === "function" &&
      window.LightweightCharts &&
      window.LightweightCharts.HistogramSeries
    ) {
      volumeSeries = chart.addSeries(window.LightweightCharts.HistogramSeries, volOpts);
    }
    if (!volumeSeries) return false;

    applyScaleLayout();
    applyChartPriceFormat();
    applyPriceAutoScale();

    chart.subscribeCrosshairMove(function (param) {
      if (!param || param.time == null) {
        showLatestOhlcv();
        return;
      }
      const bar = candleAtTime(param.time);
      if (!bar) {
        showLatestOhlcv();
        return;
      }
      paintOhlcv(bar);
    });

    chart.timeScale().subscribeVisibleLogicalRangeChange(function (range) {
      if (!chartDataLoading && range && !suppressRangeUserLock) {
        markChartRangeUserAdjusted();
      }
      scheduleRangeUiUpdate();
      if (
        !range ||
        chartDataLoading ||
        loadingLeft ||
        exhaustedLeft ||
        !lastCandles.length ||
        !lastViewKey
      ) {
        return;
      }
      if (currentChartViewKey() !== lastViewKey) return;
      scheduleLoadOlderOnRange(range);
    });

    window.addEventListener("resize", function () {
      scheduleChartResize();
    });
    scheduleChartResize();
    ensureDrawLayer();
    return true;
  }

  function clearMarkers() {
    rangeMarkers.forEach(function (m) {
      try {
        candleSeries.removePriceLine(m);
      } catch (e) {}
    });
    rangeMarkers = [];
  }

  function clearYesterdayPriceLines() {
    if (candleSeries) {
      yesterdayPriceLines.forEach(function (m) {
        try {
          candleSeries.removePriceLine(m);
        } catch (e) {}
      });
    }
    yesterdayPriceLines = [];
  }

  function updateYesterdayPriceLines() {
    clearYesterdayPriceLines();
    if (!candleSeries || !lastCandles.length) return;
    const showClose = !!(elPrevCloseLine && elPrevCloseLine.checked);
    const showHl = !!(elPrevHlLines && elPrevHlLines.checked);
    if (!showClose && !showHl) return;
    const stats = computePrevTradingDayOhlc(lastCandles, chartResetHour());
    if (!stats) return;
    if (showClose && stats.close != null && Number.isFinite(Number(stats.close))) {
      const px = Number(roundToTick(stats.close));
      if (Number.isFinite(px)) {
        yesterdayPriceLines.push(
          candleSeries.createPriceLine({
            price: px,
            color: "#a78bfa",
            lineWidth: 1,
            lineStyle: 2,
            axisLabelVisible: true,
            title: "昨收",
          })
        );
      }
    }
    if (showHl) {
      if (stats.high != null && Number.isFinite(Number(stats.high))) {
        const hiPx = Number(roundToTick(stats.high));
        if (Number.isFinite(hiPx)) {
          yesterdayPriceLines.push(
            candleSeries.createPriceLine({
              price: hiPx,
              color: "#ffb84d",
              lineWidth: 1,
              lineStyle: 2,
              axisLabelVisible: true,
              title: "昨高",
            })
          );
        }
      }
      if (stats.low != null && Number.isFinite(Number(stats.low))) {
        const loPx = Number(roundToTick(stats.low));
        if (Number.isFinite(loPx)) {
          yesterdayPriceLines.push(
            candleSeries.createPriceLine({
              price: loPx,
              color: "#4cd97f",
              lineWidth: 1,
              lineStyle: 2,
              axisLabelVisible: true,
              title: "昨低",
            })
          );
        }
      }
    }
  }

  function viewKey(exKey, sym, tf) {
    const ex = String(exKey || "").trim().toLowerCase();
    const s = normalizeMarketSymbol(sym);
    const t = String(tf || "").trim();
    return ex + "|" + s + "|" + t;
  }

  function lookupSeriesMapEntry(map, vKey) {
    if (!map || !vKey) return null;
    if (map[vKey]) return map[vKey];
    const parts = String(vKey).split("|");
    if (parts.length === 3) {
      const norm = viewKey(parts[0], parts[1], parts[2]);
      if (norm !== vKey && map[norm]) return map[norm];
    }
    return null;
  }

  function chartInitialLimit(tf) {
    return CHART_INITIAL_LIMITS[tf] || 200;
  }

  function chartChunkLimit(tf) {
    return CHART_CHUNK_LIMITS[tf] || 200;
  }

  function chartMemoryCap(tf) {
    return CHART_MEMORY_CAPS[tf] || 1000;
  }

  function resetChartHistoryState() {
    exhaustedLeft = false;
    loadingLeft = false;
  }

  function currentChartViewKey() {
    const exKey = (elExchange && elExchange.value) || "";
    const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
    const tf = (elTf && elTf.value) || currentTf || "1d";
    if (!exKey || !sym) return "";
    return viewKey(exKey, sym, tf);
  }

  function isVisibleRangeValidForCandles(range, candleCount) {
    if (!range || candleCount <= 0) return false;
    const maxTo = candleCount - 1 + RIGHT_OFFSET_BARS;
    if (range.from < -2 || range.to < 0) return false;
    if (range.to > maxTo + 8) return false;
    if (range.from > candleCount - 1) return false;
    return true;
  }

  function markChartRangeUserAdjusted() {
    chartRangeUserLocked = true;
    if (chartRangeLockTimer) clearTimeout(chartRangeLockTimer);
    chartRangeLockTimer = setTimeout(function () {
      chartRangeLockTimer = null;
      chartRangeUserLocked = false;
    }, 30000);
  }

  function clampVisibleLogicalRange(range, candleCount) {
    if (!range || candleCount <= 0) return null;
    const maxTo = candleCount - 1 + RIGHT_OFFSET_BARS;
    const from = Math.max(-2, Math.min(range.from, candleCount - 1));
    const to = Math.max(0, Math.min(range.to, maxTo + 8));
    if (to <= from) return null;
    return { from: from, to: to };
  }

  function restoreVisibleLogicalRange(range, candleCount) {
    const clamped = clampVisibleLogicalRange(range, candleCount);
    if (!chart || !clamped || !isVisibleRangeValidForCandles(clamped, candleCount)) return false;
    suppressRangeUserLock = true;
    chart.timeScale().setVisibleLogicalRange(clamped);
    suppressRangeUserLock = false;
    return true;
  }

  function applyPreservedVisibleRange(range, candleCount) {
    if (!chart || !range || !candleCount) return;
    function applyOnce() {
      if (!chart || !lastCandles.length) return;
      applyChartRightGap();
      restoreVisibleLogicalRange(range, lastCandles.length);
      updateVisibleRangeMarkers();
      updateYesterdayPriceLines();
    }
    applyOnce();
    requestAnimationFrame(applyOnce);
    setTimeout(applyOnce, 0);
  }

  function shouldLoadOlderOnRange(range) {
    if (!range || !lastCandles.length) return false;
    const n = lastCandles.length;
    const maxTo = n - 1 + RIGHT_OFFSET_BARS;
    if (range.from >= CHART_LOAD_LEFT_THRESHOLD) return false;
    // 缩小图表时 from 会变小,但 to 仍靠近最新 — 不应触发左拖补历史
    if (range.to >= maxTo - 30) return false;
    return true;
  }

  function scheduleRangeUiUpdate() {
    if (rangeUiTimer) clearTimeout(rangeUiTimer);
    rangeUiTimer = setTimeout(function () {
      rangeUiTimer = null;
      updateVisibleRangeMarkers();
      updatePriceTag();
    }, 120);
  }

  function scheduleLoadOlderOnRange(range) {
    if (!shouldLoadOlderOnRange(range)) return;
    if (loadOlderTimer) clearTimeout(loadOlderTimer);
    loadOlderTimer = setTimeout(function () {
      loadOlderTimer = null;
      if (!chart) return;
      const cur = chart.timeScale().getVisibleLogicalRange();
      if (!shouldLoadOlderOnRange(cur)) return;
      void loadOlderCandles();
    }, 280);
  }

  function tailVisibleLogicalRange(candleCount) {
    const n = Math.max(0, Number(candleCount) || 0);
    if (n <= 0) return null;
    const visible = Math.min(DEFAULT_VISIBLE_BARS, n);
    return {
      from: Math.max(0, n - visible),
      to: n - 1 + RIGHT_OFFSET_BARS,
    };
  }

  function clearChartSeriesData() {
    lastCandles = [];
    candleByTime = {};
    clearYesterdayPriceLines();
    if (candleSeries) candleSeries.setData([]);
    if (volumeSeries) volumeSeries.setData([]);
  }

  function mergeCandles(existing, incoming, opts) {
    opts = opts || {};
    const prepend = !!opts.prepend;
    const byTime = {};
    (existing || []).forEach(function (c) {
      if (c && c.time != null) byTime[c.time] = c;
    });
    (incoming || []).forEach(function (c) {
      if (c && c.time != null) byTime[c.time] = c;
    });
    let merged = Object.keys(byTime)
      .map(function (t) {
        return Number(t);
      })
      .sort(function (a, b) {
        return a - b;
      })
      .map(function (t) {
        return byTime[t];
      });
    const cap = chartMemoryCap(currentTf);
    if (merged.length > cap) {
      merged = prepend ? merged.slice(0, cap) : merged.slice(-cap);
    }
    return merged;
  }

  /** 尾部静默刷新:仅 update 变更 K 线,不 setData,避免视口跳动 */
  function applyTailCandlePatch(incoming) {
    if (!candleSeries || !volumeSeries || !incoming || !incoming.length) return false;
    const aligned = alignCandlesToTick(incoming);
    const prevLen = lastCandles.length;
    const oldestTime = prevLen ? lastCandles[0].time : null;
    const prevLastTime = prevLen ? lastCandles[prevLen - 1].time : null;
    const merged = mergeCandles(lastCandles, aligned, { prepend: false });
    if (
      prevLen > 0 &&
      merged.length > 0 &&
      merged[0].time !== oldestTime &&
      merged.length <= prevLen
    ) {
      return false;
    }
    let patchStart = 0;
    if (prevLastTime != null) {
      patchStart = merged.findIndex(function (b) {
        return b.time >= prevLastTime;
      });
      if (patchStart < 0) return false;
    }
    try {
      for (let i = patchStart; i < merged.length; i++) {
        const bar = merged[i];
        candleSeries.update(bar);
        volumeSeries.update(buildVolumeBar(bar));
      }
    } catch (_) {
      return false;
    }
    lastCandles = merged;
    indexCandles(lastCandles);
    readIndicatorState();
    if (indicatorState.ema || indicatorState.ema144 || indicatorState.macd || indicatorState.rsi) {
      try {
        updateIndicators();
      } catch (indErr) {}
    }
    updateVisibleRangeMarkers();
    updateYesterdayPriceLines();
    showLatestOhlcv();
    return true;
  }

  function applyCandlesToChart(candles, rangeShift, opts) {
    opts = opts || {};
    let savedRange = null;
    if (opts.preserveRange && chart) {
      savedRange = chart.timeScale().getVisibleLogicalRange();
    }
    lastCandles = alignCandlesToTick(candles);
    indexCandles(lastCandles);
    candleSeries.setData(lastCandles);
    volumeSeries.setData(buildVolumeData(lastCandles));
    if (!opts.skipRightGap) {
      applyChartRightGap();
    }
    if (rangeShift && chart) {
      const range = chart.timeScale().getVisibleLogicalRange();
      if (range) {
        suppressRangeUserLock = true;
        chart.timeScale().setVisibleLogicalRange({
          from: range.from + rangeShift,
          to: range.to + rangeShift,
        });
        suppressRangeUserLock = false;
      }
    } else if (savedRange) {
      restoreVisibleLogicalRange(savedRange, lastCandles.length);
    }
    if (!opts.skipAutoScale) {
      applyPriceAutoScale();
    }
    updateVisibleRangeMarkers();
    updateYesterdayPriceLines();
    try {
      updateIndicators();
    } catch (indErr) {}
    showLatestOhlcv();
  }

  async function fetchChartChunk(params) {
    const qs = new URLSearchParams({
      exchange_key: params.exchange_key,
      symbol: params.symbol,
      timeframe: params.timeframe,
      limit: String(params.limit),
    });
    if (params.before_ms) qs.set("before_ms", String(params.before_ms));
    if (params.refresh) qs.set("refresh", "1");
    if (params.tail) qs.set("tail", "1");
    const r = await fetch("/api/chart/ohlcv?" + qs.toString(), { credentials: "same-origin" });
    const data = await r.json();
    if (!r.ok) {
      throw new Error(data.detail || data.msg || "请求失败");
    }
    return data;
  }

  async function loadOlderCandles() {
    if (chartDataLoading || loadingLeft || exhaustedLeft || !lastCandles.length) return;
    const exKey = (elExchange && elExchange.value) || "";
    const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
    const tf = (elTf && elTf.value) || "1d";
    if (!exKey || !sym) return;
    const vKey = viewKey(exKey, sym, tf);
    if (!lastViewKey || vKey !== lastViewKey) return;
    loadingLeft = true;
    const beforeMs = Number(lastCandles[0].time) * 1000;
    try {
      const data = await fetchChartChunk({
        exchange_key: exKey,
        symbol: sym,
        timeframe: tf,
        limit: chartChunkLimit(tf),
        before_ms: beforeMs,
      });
      if (data.exhausted) exhaustedLeft = true;
      const incoming = alignCandlesToTick(data.candles || []);
      if (!incoming.length) return;
      const prevLen = lastCandles.length;
      const merged = mergeCandles(lastCandles, incoming, { prepend: true });
      const shift = merged.length - prevLen;
      applyCandlesToChart(merged, shift);
      if (elStatus && !elStatus.classList.contains("err")) {
        elStatus.textContent =
          "已加载 " +
          lastCandles.length +
          " 根(向左 +" +
          incoming.length +
          (exhaustedLeft ? " · 已到最早" : "") +
          ")";
      }
    } catch (e) {
      if (elStatus) {
        elStatus.className = "market-status warn";
        elStatus.textContent = "加载更早 K 线失败:" + String(e.message || e);
      }
    } finally {
      loadingLeft = false;
    }
  }

  function applyIncomingTailCandles(incoming, meta) {
    meta = meta || {};
    const vKey = currentViewSeriesKey();
    if (!vKey || !lastCandles.length || chartDataLoading) return false;
    if (!lastViewKey || vKey !== lastViewKey) return false;
    const epochAtStart = chartViewEpoch;
    const autoFollow = priceAutoScale;
    let savedRange = null;
    if (chart) savedRange = chart.timeScale().getVisibleLogicalRange();
    if (!incoming || !incoming.length) return false;
    if (meta.price_tick != null) {
      priceTick = meta.price_tick;
      try {
        applyChartPriceFormat();
      } catch (fmtErr) {
        priceTick = null;
        applyChartPriceFormat();
      }
    }
    const aligned = alignCandlesToTick(incoming);
    let tailPatched = false;
    if (!autoFollow) {
      try {
        tailPatched = applyTailCandlePatch(aligned);
      } catch (_) {
        tailPatched = false;
      }
    }
    if (!autoFollow && tailPatched) {
      /* 手动模式:增量 update,不触碰时间轴 */
    } else {
      const merged = mergeCandles(lastCandles, aligned, { prepend: false });
      applyCandlesToChart(merged, 0, {
        preserveRange: false,
        skipAutoScale: !autoFollow,
        skipRightGap: !autoFollow,
      });
      if (epochAtStart !== chartViewEpoch) return false;
      const n = lastCandles.length;
      if (autoFollow) {
        applyDefaultVisibleRange();
      } else if (savedRange) {
        applyPreservedVisibleRange(savedRange, n);
      }
    }
    if (epochAtStart !== chartViewEpoch) return false;
    scheduleRangeUiUpdate();
    if (posContext) {
      updateLivePosPnl();
      refreshPosPnlFromBoard();
    }
    if (meta.series_version != null) {
      localSeriesVersion = Number(meta.series_version) || localSeriesVersion;
    }
    if (meta.chart_version != null) {
      localChartVersion = Number(meta.chart_version) || localChartVersion;
    }
    if (elUpdated) elUpdated.textContent = "数据 " + (meta.updated_at || "--");
    tickLiveClock();
    if (window.HubChartDraw && drawAttached) window.HubChartDraw.redraw();
    return true;
  }

  async function refreshChartTail() {
    const exKey = (elExchange && elExchange.value) || "";
    const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
    const tf = (elTf && elTf.value) || "1d";
    const vKey = viewKey(exKey, sym, tf);
    if (!exKey || !sym || !lastCandles.length || chartDataLoading) return;
    if (!lastViewKey || vKey !== lastViewKey) return;
    const myToken = loadToken;
    const epochAtStart = chartViewEpoch;
    try {
      const data = await fetchChartChunk({
        exchange_key: exKey,
        symbol: sym,
        timeframe: tf,
        limit: CHART_TAIL_REFRESH_LIMIT,
        tail: true,
      });
      if (myToken !== loadToken) return;
      if (vKey !== lastViewKey) return;
      if (epochAtStart !== chartViewEpoch) return;
      if (!data.ok || !data.candles || !data.candles.length) return;
      applyIncomingTailCandles(data.candles, {
        price_tick: data.price_tick,
        series_version: data.series_version,
        chart_version: data.chart_version,
        updated_at: data.updated_at,
      });
    } catch (_) {}
  }

  function applyChartRightGap() {
    if (!chart) return;
    chart.timeScale().applyOptions({
      rightOffset: RIGHT_OFFSET_BARS,
      fixRightEdge: false,
    });
  }

  function applyDefaultVisibleRange() {
    if (!chart || !lastCandles.length) return;
    function applyOnce() {
      if (!chart || !lastCandles.length) return;
      const r = tailVisibleLogicalRange(lastCandles.length);
      if (!r) return;
      applyChartRightGap();
      restoreVisibleLogicalRange(r, lastCandles.length);
      updateVisibleRangeMarkers();
    }
    applyOnce();
    requestAnimationFrame(applyOnce);
    setTimeout(applyOnce, 0);
  }

  function updateVisibleRangeMarkers() {
    clearMarkers();
    if (!candleSeries || !chart || !lastCandles.length) return;

    const range = chart.timeScale().getVisibleLogicalRange();
    if (!range) return;

    const from = Math.max(0, Math.floor(range.from));
    const to = Math.min(lastCandles.length - 1, Math.ceil(range.to));
    if (to < from) return;

    let hi = null;
    let lo = null;
    for (let i = from; i <= to; i++) {
      const c = lastCandles[i];
      if (!c) continue;
      if (!hi || c.high > hi.high) hi = c;
      if (!lo || c.low < lo.low) lo = c;
    }
    if (!hi || !lo) return;

    rangeMarkers.push(
      candleSeries.createPriceLine({
        price: Number(roundToTick(hi.high)),
        color: "#ffb84d",
        lineWidth: 1,
        lineStyle: 2,
        axisLabelVisible: true,
        title: "高点",
      })
    );
    rangeMarkers.push(
      candleSeries.createPriceLine({
        price: Number(roundToTick(lo.low)),
        color: "#4cd97f",
        lineWidth: 1,
        lineStyle: 2,
        axisLabelVisible: true,
        title: "低点",
      })
    );
  }

  function readQuery() {
    const qs = new URLSearchParams(window.location.search);
    const ex = qs.get("exchange_key") || qs.get("exchange") || "";
    const sym = qs.get("symbol") || "";
    const tf = qs.get("timeframe") || "";
    if (ex && elExchange) elExchange.value = ex;
    if (sym && elSymbol) elSymbol.value = sym;
    if (tf && elTf) elTf.value = tf;
  }

  function applyDefaults() {
    if (elSymbol && !elSymbol.value.trim()) elSymbol.value = "BTC/USDT";
    if (elTf && !elTf.value) elTf.value = "1d";
  }

  function currentViewSeriesKey() {
    const exKey = (elExchange && elExchange.value) || "";
    const sym = (elSymbol && elSymbol.value.trim()) || "";
    const tf = (elTf && elTf.value) || "1d";
    if (!exKey || !sym) return "";
    return viewKey(exKey, sym, tf);
  }

  function postChartWatch() {
    const exKey = (elExchange && elExchange.value) || "";
    const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
    const tf = (elTf && elTf.value) || "1d";
    if (!exKey || !sym) return Promise.resolve();
    return fetch("/api/chart/watch", {
      method: "POST",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ exchange_key: exKey, symbol: sym, timeframe: tf }),
    }).catch(function () {});
  }

  function postChartUnwatch() {
    const exKey = (elExchange && elExchange.value) || "";
    const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
    const tf = (elTf && elTf.value) || "1d";
    if (!exKey || !sym) return Promise.resolve();
    return fetch("/api/chart/unwatch", {
      method: "POST",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ exchange_key: exKey, symbol: sym, timeframe: tf }),
    }).catch(function () {});
  }

  function closeChartStream() {
    if (chartEventSource) {
      chartEventSource.close();
      chartEventSource = null;
    }
  }

  function handleChartStreamEvent(st) {
    if (!st || st.polling) return;
    const vKey = currentViewSeriesKey();
    if (!vKey) return;
    const tails = st.tails || {};
    const series = st.series || {};
    const tailPack = lookupSeriesMapEntry(tails, vKey);
    if (tailPack && tailPack.candles && tailPack.candles.length) {
      if (
        applyIncomingTailCandles(tailPack.candles, {
          price_tick: tailPack.price_tick,
          series_version: tailPack.series_version,
          chart_version: st.chart_version,
          updated_at: tailPack.updated_at || st.updated_at,
        })
      ) {
        return;
      }
    }
    const seriesEntry = lookupSeriesMapEntry(series, vKey);
    const sVer = seriesEntry ? Number(seriesEntry.series_version) || 0 : 0;
    const seriesChanged = sVer > 0 && sVer !== localSeriesVersion;
    if (seriesChanged) {
      if (lastCandles.length && vKey === lastViewKey) {
        void refreshChartTail();
      } else if (!lastCandles.length && !chartDataLoading) {
        void loadChart(false);
      }
      return;
    }
    if (tailPack && lastCandles.length && vKey === lastViewKey && !chartDataLoading) {
      void refreshChartTail();
      return;
    }
    if (posContext) updateLivePosPnl();
    const ver = Number(st.chart_version) || 0;
    if (ver && ver !== localChartVersion) {
      localChartVersion = ver;
      if (lastCandles.length && vKey === lastViewKey && !chartDataLoading) {
        void refreshChartTail();
      }
    }
  }

  function connectChartStream() {
    closeChartStream();
    const page = document.getElementById("page-market");
    if (!page || page.classList.contains("hidden")) return;
    chartEventSource = new EventSource("/api/chart/stream");
    chartEventSource.addEventListener("chart", function (ev) {
      try {
        handleChartStreamEvent(JSON.parse(ev.data || "{}"));
      } catch (_) {}
    });
    chartEventSource.onerror = function () {
      closeChartStream();
      if (chartSseReconnectTimer) clearTimeout(chartSseReconnectTimer);
      chartSseReconnectTimer = setTimeout(function () {
        const p = document.getElementById("page-market");
        if (p && !p.classList.contains("hidden")) connectChartStream();
      }, 8000);
    };
  }

  function startChartWatchHeartbeat() {
    stopChartWatchHeartbeat();
    void postChartWatch();
    chartWatchTimer = setInterval(function () {
      const page = document.getElementById("page-market");
      if (!page || page.classList.contains("hidden")) return;
      void postChartWatch();
    }, CHART_WATCH_HEARTBEAT_MS);
  }

  function stopChartWatchHeartbeat() {
    if (chartWatchTimer) clearInterval(chartWatchTimer);
    chartWatchTimer = null;
  }

  function startAutoRefresh() {
    stopAutoRefresh();
    const tick = function () {
      const page = document.getElementById("page-market");
      if (!page || page.classList.contains("hidden")) return;
      if (lastCandles.length) {
        void refreshChartTail();
      } else if (!chartDataLoading) {
        void loadChart(false);
      }
    };
    refreshTimer = setInterval(tick, CHART_SSE_FALLBACK_MS);
    tick();
  }

  function stopAutoRefresh() {
    if (refreshTimer) clearInterval(refreshTimer);
    refreshTimer = null;
    if (chartSseReconnectTimer) {
      clearTimeout(chartSseReconnectTimer);
      chartSseReconnectTimer = null;
    }
  }

  function stopChartLive() {
    stopAutoRefresh();
    stopChartWatchHeartbeat();
    closeChartStream();
    void postChartUnwatch();
  }

  function allScanTabButtons() {
    return Array.prototype.slice.call(document.querySelectorAll(".market-scan-tab"));
  }

  function setActiveScanTab(tab) {
    activeScanTab = tab || "top20";
    allScanTabButtons().forEach(function (btn) {
      const on = btn.getAttribute("data-scan-tab") === activeScanTab;
      btn.classList.toggle("is-active", on);
      btn.setAttribute("aria-selected", on ? "true" : "false");
    });
  }

  function mountVolRankSheet(forFullscreen) {
    if (!elVolRankSheet) return;
    const anchor = forFullscreen ? elVolRankAnchorFs : elVolRankAnchor;
    if (!anchor || elVolRankSheet.parentElement === anchor) return;
    anchor.appendChild(elVolRankSheet);
  }

  function setScanSheetOpen(open, tab) {
    const on = !!open;
    scanSheetOpen = on;
    if (tab) setActiveScanTab(tab);
    if (elVolRankSheet) {
      elVolRankSheet.classList.toggle("hidden", !on);
      elVolRankSheet.setAttribute("aria-hidden", on ? "false" : "true");
    }
    if (on) void loadScanPanel(false);
  }

  function loadScanPanel(forceRefresh) {
    if (activeScanTab === "top20") {
      void loadVolumeRank(forceRefresh);
      return;
    }
    void loadDivergenceScan(activeScanTab, forceRefresh);
  }

  function bindVolRankPanel() {
    allScanTabButtons().forEach(function (btn) {
      btn.addEventListener("click", function () {
        const tab = btn.getAttribute("data-scan-tab") || "top20";
        if (scanSheetOpen && activeScanTab === tab) {
          setScanSheetOpen(false);
          return;
        }
        setScanSheetOpen(true, tab);
      });
    });
    document.addEventListener("pointerdown", function (ev) {
      if (!elVolRankSheet || elVolRankSheet.classList.contains("hidden")) return;
      const t = ev.target;
      if (elVolRankSheet.contains(t)) return;
      if (t && t.closest && t.closest(".market-scan-tabs")) return;
      setScanSheetOpen(false);
    });
  }

  function applyScanSymbolSelection(symbol, tabTf) {
    if (!symbol) return;
    if (elSymbol) elSymbol.value = symbol;
    if (elFsSymbol) elFsSymbol.value = symbol;
    if (tabTf && tabTf !== "top20") {
      if (elTf) elTf.value = tabTf;
      if (elFsTf) elFsTf.value = tabTf;
      if (elIndMacd) elIndMacd.checked = true;
      indicatorState.macd = true;
    }
    setScanSheetOpen(false);
    loadChart(false);
  }

  function renderDivergenceScan(data) {
    if (!elVolRankMeta || !elVolRankList) return;
    elVolRankList.innerHTML = "";
    elVolRankList.classList.add("is-div-scan-list");
    const tabLabel = (data && data.tab_label) || "背离";
    if (!data || !data.ok || !data.items || !data.items.length) {
      elVolRankMeta.textContent =
        (data && data.msg) ||
        tabLabel + ":Top20 内暂无 MACD 背离(可点「清库重拉」后重试扫描)";
      return;
    }
    const rankDate = data.rank_date || "—";
    const updated = data.scanned_at || data.updated_at || "—";
    elVolRankMeta.innerHTML =
      "<div class=\"div-scan-meta-title\">" +
      tabLabel +
      " · Top20 · MACD 档A</div>" +
      "<div class=\"div-scan-meta-sub\">交易日 " +
      rankDate +
      " · 扫描 " +
      updated +
      " · 共 " +
      data.items.length +
      " 条</div>";
    const curSym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
    const tabTf = data.tab || activeScanTab;
    const tfShort = { "4h": "4h", "1d": "日线", "1w": "周线" };
    data.items.forEach(function (row) {
      const li = document.createElement("li");
      li.className = "market-vol-rank-li";
      const btn = document.createElement("button");
      btn.type = "button";
      const css = row.confluence_css || "none";
      btn.className = "market-vol-rank-item is-div-scan confluence-" + css;
      if (row.symbol && row.symbol.toUpperCase() === curSym) {
        btn.classList.add("is-active");
      }
      btn.dataset.symbol = row.symbol || "";
      const dirLabel = row.is_split
        ? "分歧"
        : row.tab_direction_label || row.direction_label || "";
      const confLabel = row.is_split ? "分歧" : row.confluence_kind || "—";
      const fresh = row.tab_freshness || "";
      const dirClass =
        row.tab_direction === "bull"
          ? "is-bull"
          : row.tab_direction === "bear"
            ? "is-bear"
            : row.is_split
              ? "is-split"
              : "";
      const tfPills = [];
      const tfs = row.timeframes || {};
      ["4h", "1d", "1w"].forEach(function (tf) {
        if (!tfs[tf]) return;
        const pillDir = tfs[tf] === "bull" ? "底" : "顶";
        tfPills.push(
          '<span class="div-scan-tf-pill">' + (tfShort[tf] || tf) + pillDir + "</span>"
        );
      });
      const subExtra = row.is_split && row.split_detail
        ? '<span class="div-scan-split-detail">' + row.split_detail + "</span>"
        : tfPills.length
          ? '<span class="div-scan-tf-pills">' + tfPills.join("") + "</span>"
          : "";
      btn.innerHTML =
        '<div class="div-scan-row div-scan-head">' +
        '<span class="market-vol-rank-badge confluence-' +
        css +
        '">' +
        confLabel +
        '</span><span class="market-vol-rank-no">#' +
        (row.rank || "—") +
        '</span><span class="market-vol-rank-sym">' +
        (row.symbol || "") +
        "</span></div>" +
        '<div class="div-scan-row div-scan-sub">' +
        '<span class="div-scan-dir ' +
        dirClass +
        '">' +
        dirLabel +
        "</span>" +
        (fresh ? '<span class="div-scan-fresh">' + fresh + "</span>" : "") +
        subExtra +
        "</div>";
      btn.addEventListener("click", function () {
        applyScanSymbolSelection(row.symbol, tabTf);
      });
      li.appendChild(btn);
      elVolRankList.appendChild(li);
    });
  }

  async function loadDivergenceScan(tab, forceRefresh) {
    const exKey = (elExchange && elExchange.value) || "";
    if (!exKey || !elVolRankMeta) return;
    elVolRankMeta.textContent = "扫描背离…";
    if (elVolRankList) elVolRankList.innerHTML = "";
    try {
      let url =
        "/api/chart/divergence-scan?exchange_key=" +
        encodeURIComponent(exKey) +
        "&tab=" +
        encodeURIComponent(tab || "4h");
      if (forceRefresh) url += "&refresh=1";
      const r = await fetch(url, { credentials: "same-origin" });
      const data = await r.json();
      if (!r.ok) {
        throw new Error((data && data.detail) || (data && data.msg) || "加载失败");
      }
      renderDivergenceScan(data);
    } catch (e) {
      renderDivergenceScan({ ok: false, msg: String(e.message || e), tab_label: tab });
    }
  }

  function renderVolumeRank(data) {
    if (!elVolRankMeta || !elVolRankList) return;
    elVolRankList.innerHTML = "";
    elVolRankList.classList.remove("is-div-scan-list");
    if (!data || !data.ok || !data.items || !data.items.length) {
      elVolRankMeta.textContent =
        (data && data.msg) ||
        "暂无排名数据(请 pm2 restart 三实例与 manual-trading-hub 后重试)";
      return;
    }
    const resetHour = data.reset_hour != null ? data.reset_hour : 8;
    const rankDate = data.rank_date || "—";
    const updated = data.updated_at || "—";
    const total = data.total_symbols != null ? data.total_symbols : "";
    const count = data.items.length;
    const expect = data.expected_count != null ? data.expected_count : 20;
    let meta =
      "昨日成交 Top" +
      expect +
      " · 交易日 " +
      rankDate +
      " · 每早 " +
      resetHour +
      ":00 更新 · 显示 " +
      count +
      "/" +
      expect +
      " 条";
    if (total) meta += " · 全市场 " + total + " 个";
    if (data.stale) meta += " · 数据不完整,正在重拉…";
    meta += " · " + updated;
    elVolRankMeta.textContent = meta;
    const curSym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
    data.items.forEach(function (row) {
      const li = document.createElement("li");
      const btn = document.createElement("button");
      btn.type = "button";
      btn.className = "market-vol-rank-item";
      if (row.symbol && row.symbol.toUpperCase() === curSym) {
        btn.classList.add("is-active");
      }
      btn.dataset.symbol = row.symbol || "";
      btn.innerHTML =
        '<span class="market-vol-rank-no">' +
        (row.rank || "") +
        '</span><span class="market-vol-rank-sym">' +
        (row.symbol || "") +
        '</span><span class="market-vol-rank-vol">' +
        (row.volume_label || "") +
        "</span>";
      btn.addEventListener("click", function () {
        applyScanSymbolSelection(row.symbol, "top20");
      });
      li.appendChild(btn);
      elVolRankList.appendChild(li);
    });
  }

  async function loadVolumeRank(forceRefresh) {
    const exKey = (elExchange && elExchange.value) || "";
    if (!exKey || !elVolRankMeta) return;
    elVolRankMeta.textContent = "加载排名…";
    if (elVolRankList) elVolRankList.innerHTML = "";
    try {
      let url = "/api/chart/volume-rank?exchange_key=" + encodeURIComponent(exKey);
      if (forceRefresh) url += "&refresh=1";
      const r = await fetch(url, { credentials: "same-origin" });
      const data = await r.json();
      if (!r.ok) {
        throw new Error((data && data.detail) || (data && data.msg) || "加载失败");
      }
      renderVolumeRank(data);
      const expect = data.expected_count != null ? data.expected_count : 20;
      if (!forceRefresh && data.ok && data.items && data.items.length < expect) {
        void loadVolumeRank(true);
      }
    } catch (e) {
      renderVolumeRank({ ok: false, msg: String(e.message || e) });
    }
  }

  async function loadMeta() {
    const r = await fetch("/api/chart/meta", { credentials: "same-origin" });
    chartMeta = await r.json();
    if (!elExchange || !chartMeta.exchanges) return;
    elExchange.innerHTML = "";
    chartMeta.exchanges.forEach(function (ex) {
      const opt = document.createElement("option");
      opt.value = ex.key || ex.id;
      opt.textContent = ex.name || ex.key;
      elExchange.appendChild(opt);
    });
    populateFsExchangeOptions();
    readQuery();
    applyDefaults();
    updateExchangeDisplay();
  }

  async function loadChart(force, options) {
    options = options || {};
    const autoTick = !!options.autoTick;
    if (autoTick) {
      return refreshChartTail();
    }
    localSeriesVersion = 0;
    void postChartWatch();
    if (!ensureChart()) return;
    const exKey = (elExchange && elExchange.value) || "";
    const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
    const tf = (elTf && elTf.value) || "1d";
    currentTf = tf;
    if (!exKey || !sym) {
      if (elStatus) {
        elStatus.className = "market-status err";
        elStatus.textContent = "请选择交易所并输入币种";
      }
      return;
    }
    const myToken = ++loadToken;
    const vKey = viewKey(exKey, sym, tf);
    const resetView = !!force || vKey !== lastViewKey;
    chartDataLoading = true;
    if (resetView) {
      chartViewEpoch += 1;
      chartRangeUserLocked = false;
      if (chartRangeLockTimer) {
        clearTimeout(chartRangeLockTimer);
        chartRangeLockTimer = null;
      }
      resetChartHistoryState();
      lastViewKey = "";
      clearChartSeriesData();
    }
    if (elStatus) {
      elStatus.className = "market-status";
      elStatus.textContent = "加载中…";
    }
    updateHeaderLabels(sym, tf);

    try {
      const data = await fetchChartChunk({
        exchange_key: exKey,
        symbol: sym,
        timeframe: tf,
        limit: chartInitialLimit(tf),
        refresh: !!force,
      });
      if (myToken !== loadToken) return;
      if (!data.ok || !data.candles || !data.candles.length) {
        throw new Error(data.msg || "无 K 线");
      }

      priceTick = data.price_tick;
      try {
        applyChartPriceFormat();
      } catch (fmtErr) {
        priceTick = null;
        applyChartPriceFormat();
      }
      applyCandlesToChart(alignCandlesToTick(data.candles), 0);
      lastViewKey = vKey;
      ensureDrawLayer();
      syncDrawViewKey();
      if (resetView) {
        applyDefaultVisibleRange();
      }
      syncPosContextForView(exKey, sym);
      if (posContext) {
        updateLivePosPnl();
        refreshPosPnlFromBoard();
      }
      scheduleChartResize();

      const limit = data.limit || lastCandles.length;
      let hint =
        "已加载 " +
        lastCandles.length +
        " 根(首屏 " +
        limit +
        ")· 库 " +
        (data.from_cache || 0) +
        " / 新拉 " +
        (data.fetched || 0) +
        (data.cleared ? " · 清库 " + data.cleared : "") +
        " · 左拖加载更多 · 后台 " +
        (data.chart_poll_interval_sec || 5) +
        "s";
      if (data.stale && data.stale_message) {
        hint += " · 缓存:" + data.stale_message;
      }
      if (elStatus) {
        elStatus.className = data.stale ? "market-status warn" : "market-status";
        elStatus.textContent = hint;
      }
      if (elUpdated) elUpdated.textContent = "数据 " + (data.updated_at || "--");
      if (data.series_version != null) localSeriesVersion = Number(data.series_version) || localSeriesVersion;
      if (data.chart_version != null) localChartVersion = Number(data.chart_version) || localChartVersion;
      tickLiveClock();
    } catch (e) {
      if (myToken !== loadToken) return;
      if (elStatus) {
        elStatus.className = "market-status err";
        elStatus.textContent = String(e.message || e);
      }
    } finally {
      if (myToken === loadToken) chartDataLoading = false;
    }
  }

  function bind() {
    bindSlDrag();
    bindVolRankPanel();
    if (elRefresh) {
      elRefresh.addEventListener("click", function () {
        loadChart(true);
      });
    }
    if (elTf) {
      elTf.addEventListener("change", function () {
        tfDigitBuf = "";
        if (tfDigitTimer) {
          clearTimeout(tfDigitTimer);
          tfDigitTimer = null;
        }
        currentTf = (elTf && elTf.value) || "1d";
        lastViewKey = "";
        tickLiveClock();
        syncFsToolbarFromMain();
        loadChart(false);
      });
    }
    if (elExchange) {
      elExchange.addEventListener("change", function () {
        updateExchangeDisplay();
        syncFsToolbarFromMain();
        lastViewKey = "";
        if (elVolRankSheet && !elVolRankSheet.classList.contains("hidden")) {
          void loadVolumeRank();
        }
        loadChart(false);
      });
    }
    if (elSymbol) {
      elSymbol.addEventListener("keydown", function (e) {
        if (e.key === "Enter") loadChart(false);
      });
      elSymbol.addEventListener("change", function () {
        loadChart(false);
      });
    }
    const btnLoad = document.getElementById("market-load");
    if (btnLoad) {
      btnLoad.addEventListener("click", function () {
        loadChart(false);
      });
    }
    if (elPriceAuto) {
      elPriceAuto.addEventListener("click", function () {
        priceAutoScale = !priceAutoScale;
        applyPriceAutoScale();
        if (priceAutoScale) applyDefaultVisibleRange();
      });
    }
    if (elPosClear) {
      elPosClear.addEventListener("click", function () {
        clearPosContext();
      });
    }
    if (elFsBtn) {
      elFsBtn.addEventListener("click", function () {
        toggleChartFullscreen();
      });
    }
    if (elFsExit) {
      elFsExit.addEventListener("click", function () {
        setChartFullscreen(false);
      });
    }
    [elIndEma, elIndEma144, elIndMacd, elIndRsi].forEach(function (el) {
      if (!el) return;
      el.addEventListener("change", function () {
        updateIndicators();
      });
    });
    if (elPrevCloseLine) {
      elPrevCloseLine.checked = loadPrevCloseLinePref();
      elPrevCloseLine.addEventListener("change", syncPrevDayLineUi);
    }
    if (elPrevHlLines) {
      elPrevHlLines.checked = loadPrevHlLinesPref();
      elPrevHlLines.addEventListener("change", syncPrevDayLineUi);
    }
    if (elDaySplit) {
      elDaySplit.checked = loadDaySplitPref();
      elDaySplit.addEventListener("change", syncTradingDaySplitUi);
      applyTradingDaySplit(elDaySplit.checked);
    }
    const pageMarket = document.getElementById("page-market");
    const fsKeyTargets = [window, pageMarket, elChartWrap, chartHost].filter(Boolean);
    fsKeyTargets.forEach(function (el) {
      el.addEventListener("keydown", onChartFullscreenKey, true);
    });
    window.addEventListener("keydown", onMarketKeydown, true);
    if (elChartWrap) {
      if (!elChartWrap.hasAttribute("tabindex")) elChartWrap.setAttribute("tabindex", "-1");
      elChartWrap.addEventListener("mousedown", focusMarketChartArea);
    }
    if (elFsExchange) {
      elFsExchange.addEventListener("change", function () {
        syncMainFromFsToolbar();
        loadChart(false);
      });
    }
    if (elFsTf) {
      elFsTf.addEventListener("change", function () {
        currentTf = elFsTf.value || "1d";
        lastViewKey = "";
        syncMainFromFsToolbar();
        tickLiveClock();
        loadChart(false);
      });
    }
    if (elFsSymbol) {
      elFsSymbol.addEventListener("keydown", function (e) {
        if (e.key === "Enter") {
          syncMainFromFsToolbar();
          loadChart(false);
        }
      });
    }
    if (elFsLoad) {
      elFsLoad.addEventListener("click", function () {
        syncMainFromFsToolbar();
        loadChart(false);
      });
    }
  }

  window.hubMarketChart = {
    init: async function () {
      if (!marketInited) {
        marketInited = true;
        await loadMeta();
        bind();
      } else {
        readQuery();
      }
      focusMarketChartArea();
      connectChartStream();
      startChartWatchHeartbeat();
      startAutoRefresh();
      await loadChart(false);
      startPriceTagTimer();
    },
    openWith: async function (exKey, sym, tf) {
      if (!marketInited) {
        await this.init();
      }
      if (elExchange && exKey) elExchange.value = exKey;
      if (elSymbol && sym) elSymbol.value = String(sym).trim().toUpperCase();
      if (tf && elTf) elTf.value = tf;
      lastViewKey = "";
      localSeriesVersion = 0;
      updateExchangeDisplay();
      connectChartStream();
      startChartWatchHeartbeat();
      startAutoRefresh();
      await loadChart(false);
      startPriceTagTimer();
    },
    reload: function (force) {
      loadChart(!!force);
    },
    startAutoRefresh: startAutoRefresh,
    stopAutoRefresh: stopAutoRefresh,
    stopChartLive: stopChartLive,
    stopPriceTagTimer: stopPriceTagTimer,
  };

  document.addEventListener("hub-theme-change", function () {
    applyChartTheme();
  });

  if (
    document.getElementById("page-market") &&
    !document.getElementById("page-market").classList.contains("hidden")
  ) {
    window.hubMarketChart.init();
  }
})();
