/** * 中控行情区:K 线 + 成交量;Hub 后台轮询 + SSE 推送;「自动」控制价格轴与视口跟随。 */ (function () { const AUTO_REFRESH_MS = 5000; const CHART_WATCH_HEARTBEAT_MS = 25000; const CHART_SSE_FALLBACK_MS = 30000; 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 chartHost = document.getElementById("market-chart"); if (!chartHost) return; const elExchange = document.getElementById("market-exchange"); const elSymbol = document.getElementById("market-symbol"); 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 elIndMacd = document.getElementById("market-ind-macd"); const elIndRsi = document.getElementById("market-ind-rsi"); 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; let chartFullscreen = false; const indicatorState = { ema: false, macd: false, rsi: false }; const indSeries = { ema21: null, ema55: 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 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, "&") .replace(//g, ">") .replace(/"/g, """); } 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 resizeChart() { if (!chart || !chartHost) return; chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight }); updatePriceTag(); } 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.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)); } 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 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"); } if (chartFullscreen) { populateFsExchangeOptions(); syncFsToolbarFromMain(); } 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 = '暂无委托单'; } else { elPosOrders.innerHTML = orders .map(function (o) { const price = o.price != null ? fmtPrice(o.price) : "—"; const amt = o.amount != null ? String(o.amount) : ""; return ( '' + '' + escHtml(o.kind || "") + "" + '' + escHtml(o.label || "") + "" + '' + price + "" + (amt ? '×' + escHtml(amt) + "" : "") + "" ); }) .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: { priceFormatter: function (p) { return fmtPrice(p); }, }, }); } } 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 }, 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(); return true; } function clearMarkers() { rangeMarkers.forEach(function (m) { try { candleSeries.removePriceLine(m); } catch (e) {} }); rangeMarkers = []; } function viewKey(exKey, sym, tf) { return (exKey || "") + "|" + (sym || "") + "|" + (tf || ""); } 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(); } 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 = {}; 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 merged = mergeCandles(lastCandles, aligned, { prepend: false }); if ( prevLen > 0 && merged.length > 0 && merged[0].time !== oldestTime && merged.length <= prevLen ) { return false; } aligned.forEach(function (bar) { candleSeries.update(bar); volumeSeries.update(buildVolumeBar(bar)); }); if (merged.length > prevLen) { for (let i = prevLen; i < merged.length; i++) { const bar = merged[i]; candleSeries.update(bar); volumeSeries.update(buildVolumeBar(bar)); } } lastCandles = merged; indexCandles(lastCandles); readIndicatorState(); if (indicatorState.ema || indicatorState.macd || indicatorState.rsi) { try { updateIndicators(); } catch (indErr) {} } updateVisibleRangeMarkers(); 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(); 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; } } 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; const autoFollow = priceAutoScale; let savedRange = null; if (chart) savedRange = chart.timeScale().getVisibleLogicalRange(); 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; if (data.price_tick != null) { priceTick = data.price_tick; try { applyChartPriceFormat(); } catch (fmtErr) { priceTick = null; applyChartPriceFormat(); } } const incoming = alignCandlesToTick(data.candles); if (!autoFollow && applyTailCandlePatch(incoming)) { /* 手动模式:增量更新,不触碰时间轴 */ } else { const merged = mergeCandles(lastCandles, incoming, { prepend: false }); applyCandlesToChart(merged, 0, { preserveRange: false, skipAutoScale: !autoFollow, skipRightGap: !autoFollow, }); if (epochAtStart !== chartViewEpoch) return; const n = lastCandles.length; if (autoFollow) { applyDefaultVisibleRange(); } else if (savedRange) { applyPreservedVisibleRange(savedRange, n); } } if (epochAtStart !== chartViewEpoch) return; scheduleRangeUiUpdate(); if (posContext) { updateLivePosPnl(); refreshPosPnlFromBoard(); } if (data.series_version != null) localSeriesVersion = Number(data.series_version) || localSeriesVersion; if (data.chart_version != null) localChartVersion = Number(data.chart_version) || localChartVersion; if (elUpdated) elUpdated.textContent = "数据 " + (data.updated_at || "--"); tickLiveClock(); } 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().toUpperCase()) || ""; const tf = (elTf && elTf.value) || "1d"; if (!exKey || !sym) return ""; return 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 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 { const st = JSON.parse(ev.data || "{}"); const ver = Number(st.chart_version) || 0; const series = st.series || {}; const vKey = currentViewSeriesKey(); const sVer = vKey && series[vKey] ? Number(series[vKey].series_version) || 0 : 0; const seriesChanged = vKey && sVer > 0 && sVer !== localSeriesVersion; if (seriesChanged) { localSeriesVersion = sVer; localChartVersion = ver; refreshChartTail(); } else if (posContext) { updateLivePosPnl(); } else if (ver !== localChartVersion) { localChartVersion = ver; } } 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(); refreshTimer = setInterval(function () { const page = document.getElementById("page-market"); if (!page || page.classList.contains("hidden")) return; refreshChartTail(); }, CHART_SSE_FALLBACK_MS); } function stopAutoRefresh() { if (refreshTimer) clearInterval(refreshTimer); refreshTimer = null; if (chartSseReconnectTimer) { clearTimeout(chartSseReconnectTimer); chartSseReconnectTimer = null; } } function stopChartLive() { stopAutoRefresh(); stopChartWatchHeartbeat(); closeChartStream(); void postChartUnwatch(); } 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; 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.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(); 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 = ""; loadChart(false); }); } if (elSymbol) { elSymbol.addEventListener("keydown", function (e) { if (e.key === "Enter") 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, elIndMacd, elIndRsi].forEach(function (el) { if (!el) return; el.addEventListener("change", function () { updateIndicators(); }); }); 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(); } })();