/** * 中控行情区:K 线 + 成交量;默认最新 OHLCV;5s 自动刷新;价格轴「自动」。 */ (function () { const AUTO_REFRESH_MS = 5000; const DEFAULT_VISIBLE_BARS = 200; const RIGHT_OFFSET_BARS = 10; const CANDLE_SCALE_BOTTOM = 0.26; const VOLUME_SCALE_TOP = 0.73; const VOLUME_SCALE_BOTTOM = 0.06; const TF_MS = { "1m": 60_000, "5m": 5 * 60_000, "15m": 15 * 60_000, "1h": 60 * 60_000, "4h": 4 * 60 * 60_000, "1d": 24 * 60 * 60_000, "1w": 7 * 24 * 60 * 60_000, }; 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 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 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, }; let chart = null; let candleSeries = null; let volumeSeries = null; let priceTick = null; let priceAutoScale = true; let rangeMarkers = []; let positionLines = []; let posContext = null; let currentPriceLine = null; let lastCandles = []; let candleByTime = {}; let chartMeta = null; let loadToken = 0; let marketInited = false; let refreshTimer = null; let lastViewKey = ""; let currentTf = "1d"; let priceTagTimer = 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 (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; Object.keys(indSeries).forEach(function (k) { if (indSeries[k]) { try { chart.removeSeries(indSeries[k]); } catch (e) {} indSeries[k] = null; } }); } function applyScaleLayout() { if (!chart) return; const rsiOn = indicatorState.rsi; const macdOn = indicatorState.macd; let candleBottom = CANDLE_SCALE_BOTTOM; let volTop = VOLUME_SCALE_TOP; const volBottom = VOLUME_SCALE_BOTTOM; if (rsiOn && macdOn) { candleBottom = 0.52; volTop = 0.84; chart.priceScale("rsi").applyOptions({ scaleMargins: { top: 0.66, bottom: 0.18 }, borderColor: "#2a4058", autoScale: true, }); chart.priceScale("macd").applyOptions({ scaleMargins: { top: 0.48, bottom: 0.34 }, borderColor: "#2a4058", autoScale: true, }); } else if (rsiOn) { candleBottom = 0.4; volTop = 0.78; chart.priceScale("rsi").applyOptions({ scaleMargins: { top: 0.62, bottom: 0.22 }, borderColor: "#2a4058", autoScale: true, }); } else if (macdOn) { candleBottom = 0.42; volTop = 0.78; chart.priceScale("macd").applyOptions({ scaleMargins: { top: 0.44, bottom: 0.3 }, borderColor: "#2a4058", autoScale: true, }); } chart.priceScale("right").applyOptions({ scaleMargins: { top: 0.06, bottom: candleBottom }, }); chart.priceScale("volume").applyOptions({ scaleMargins: { top: volTop, bottom: volBottom }, }); } function updateIndicators() { if (!chart || !lastCandles.length) return; readIndicatorState(); clearIndicatorSeries(); applyScaleLayout(); 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.macd) { const macd = buildMacdData(lastCandles); indSeries.macdLine = createLineSeries({ color: "#5b9cf5", title: "MACD", priceScaleId: "macd", }); indSeries.macdSignal = createLineSeries({ color: "#ffb84d", title: "Signal", priceScaleId: "macd", }); indSeries.macdHist = createHistSeries({ priceScaleId: "macd" }); if (indSeries.macdLine) indSeries.macdLine.setData(macd.macdLine); if (indSeries.macdSignal) indSeries.macdSignal.setData(macd.signalLine); if (indSeries.macdHist) indSeries.macdHist.setData(macd.histData); } if (indicatorState.rsi) { indSeries.rsi = createLineSeries({ color: "#8fc8ff", title: "RSI", priceScaleId: "rsi", }); if (indSeries.rsi) indSeries.rsi.setData(buildRsiSeries(lastCandles, 14)); } scheduleChartResize(); } 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 (elFsBtn) elFsBtn.textContent = chartFullscreen ? "退出全屏" : "全屏"; if (elFsExit) { if (chartFullscreen) elFsExit.classList.remove("hidden"); else elFsExit.classList.add("hidden"); } scheduleChartResize(); } function toggleChartFullscreen() { setChartFullscreen(!chartFullscreen); } 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 = "程序监控"; 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) : "—"; 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 specs = [ { price: posContext.entry, color: "#5b9cf5", title: "入场" }, { price: posContext.stop_loss, color: "#ff4d6d", title: "止损" }, ]; if (!posContext.tp_monitored && posContext.take_profit != null) { specs.push({ price: posContext.take_profit, color: "#00ff9d", title: "止盈" }); } specs.forEach(function (s) { if (s.price == null || !Number.isFinite(Number(s.price))) return; positionLines.push( candleSeries.createPriceLine({ price: Number(s.price), color: s.color, lineWidth: 1, lineStyle: 2, axisLabelVisible: true, title: s.title, }) ); }); } function clearPosContext() { posContext = null; try { sessionStorage.removeItem(HUB_MARKET_POS_CTX_KEY); } catch (e) {} clearPosPanel(); clearPositionLines(); } function applyPosContext(ctx) { posContext = ctx; renderPosPanel(ctx); updatePositionLines(); } 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)))); } function tickToPriceFormat(tick) { const minMove = tick != null && Number.isFinite(Number(tick)) && Number(tick) > 0 ? Number(tick) : 0.01; const precision = decimalsFromTick(minMove) ?? 2; return { type: "price", precision: precision, minMove: minMove }; } function applyChartPriceFormat() { const pf = tickToPriceFormat(priceTick); if (candleSeries && candleSeries.applyOptions) { candleSeries.applyOptions({ priceFormat: pf }); } if (indSeries.ema21 && indSeries.ema21.applyOptions) { indSeries.ema21.applyOptions({ priceFormat: pf }); } if (indSeries.ema55 && indSeries.ema55.applyOptions) { indSeries.ema55.applyOptions({ priceFormat: pf }); } if (chart) { chart.applyOptions({ localization: { priceFormatter: function (p) { return fmtPrice(p); }, }, }); } } function fmtPrice(v) { if (v == null || Number.isNaN(Number(v))) return "-"; const n = Number(v); 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(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 buildVolumeData(candles) { return (candles || []).map(function (c) { const up = Number(c.close) >= Number(c.open); return { time: c.time, value: Number(c.volume) || 0, color: up ? "rgba(0, 255, 157, 0.5)" : "rgba(255, 77, 109, 0.5)", }; }); } function ensureChart() { if (chart && candleSeries && volumeSeries) return true; if (!window.LightweightCharts) { if (elStatus) { elStatus.className = "market-status err"; elStatus.textContent = "图表库加载失败"; } return false; } chart = LightweightCharts.createChart(chartHost, { layout: { background: { color: "#0a1018" }, textColor: "#b8d4e8" }, grid: { vertLines: { visible: false }, horzLines: { visible: false }, }, rightPriceScale: { borderColor: "#2a4058", autoScale: true }, timeScale: { borderColor: "#2a4058", timeVisible: true, secondsVisible: false, rightOffset: RIGHT_OFFSET_BARS, }, crosshair: { mode: LightweightCharts.CrosshairMode ? LightweightCharts.CrosshairMode.Normal : 0, }, }); const candleOpts = { upColor: "#00ff9d", downColor: "#ff4d6d", borderVisible: false, wickUpColor: "#00ff9d", wickDownColor: "#ff4d6d", lastValueVisible: false, priceLineVisible: false, priceFormat: tickToPriceFormat(priceTick), }; 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: "volume", 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; chart.priceScale("macd"); chart.priceScale("rsi"); 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 () { updateVisibleRangeMarkers(); updatePriceTag(); }); 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 applyChartRightGap() { if (!chart) return; chart.timeScale().applyOptions({ rightOffset: RIGHT_OFFSET_BARS, fixRightEdge: false, }); } function applyDefaultVisibleRange() { if (!chart || !lastCandles.length) return; const n = lastCandles.length; const visible = Math.min(DEFAULT_VISIBLE_BARS, n); const from = Math.max(0, n - visible); // to 延伸到最后一根 K 线之后,留出 RIGHT_OFFSET_BARS 根空白(K 线与价格轴间距) const to = n - 1 + RIGHT_OFFSET_BARS; applyChartRightGap(); chart.timeScale().setVisibleLogicalRange({ from: from, to: to }); } 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(hi.high), color: "#ffb84d", lineWidth: 1, lineStyle: 2, axisLabelVisible: true, title: "高点", }) ); rangeMarkers.push( candleSeries.createPriceLine({ price: Number(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 startAutoRefresh() { stopAutoRefresh(); refreshTimer = setInterval(function () { const page = document.getElementById("page-market"); if (!page || page.classList.contains("hidden")) return; loadChart(false, { autoTick: true }); }, AUTO_REFRESH_MS); } function stopAutoRefresh() { if (refreshTimer) clearInterval(refreshTimer); refreshTimer = null; } 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); }); readQuery(); applyDefaults(); updateExchangeDisplay(); } async function loadChart(force, options) { options = options || {}; const autoTick = !!options.autoTick; 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 || !autoTick || vKey !== lastViewKey; let savedRange = null; if (!resetView && chart) { savedRange = chart.timeScale().getVisibleLogicalRange(); } if (!autoTick && elStatus) { elStatus.className = "market-status"; elStatus.textContent = "加载中…"; } updateHeaderLabels(sym, tf); const qs = new URLSearchParams({ exchange_key: exKey, symbol: sym, timeframe: tf, }); if (force) qs.set("refresh", "1"); try { const r = await fetch("/api/chart/ohlcv?" + qs.toString(), { credentials: "same-origin" }); const data = await r.json(); if (myToken !== loadToken) return; if (!r.ok) { throw new Error(data.detail || data.msg || "请求失败"); } if (!data.ok || !data.candles || !data.candles.length) { throw new Error(data.msg || "无 K 线"); } priceTick = data.price_tick; applyChartPriceFormat(); lastCandles = data.candles; indexCandles(lastCandles); candleSeries.setData(lastCandles); volumeSeries.setData(buildVolumeData(lastCandles)); applyChartRightGap(); if (resetView) { lastViewKey = vKey; applyDefaultVisibleRange(); } else if (savedRange) { chart.timeScale().setVisibleLogicalRange(savedRange); } applyPriceAutoScale(); updateVisibleRangeMarkers(); syncPosContextForView(exKey, sym); showLatestOhlcv(); updateIndicators(); scheduleChartResize(); const limit = data.limit || lastCandles.length; let hint = "已加载 " + data.candles.length + " 根(目标 " + limit + ")· 库 " + (data.from_cache || 0) + " / 新拉 " + (data.fetched || 0) + ")· 每 5s 刷新"; 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 || "--"); tickLiveClock(); } catch (e) { if (myToken !== loadToken) return; if (elStatus) { elStatus.className = "market-status err"; elStatus.textContent = String(e.message || e); } } } function bind() { if (elRefresh) { elRefresh.addEventListener("click", function () { loadChart(true); }); } if (elTf) { elTf.addEventListener("change", function () { currentTf = (elTf && elTf.value) || "1d"; tickLiveClock(); loadChart(false); }); } if (elExchange) { elExchange.addEventListener("change", function () { updateExchangeDisplay(); 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 (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(); }); }); document.addEventListener("keydown", function (e) { if (e.key === "Escape" && chartFullscreen) setChartFullscreen(false); }); } window.hubMarketChart = { init: async function () { if (!marketInited) { marketInited = true; await loadMeta(); bind(); } else { readQuery(); } 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 = ""; updateExchangeDisplay(); startAutoRefresh(); await loadChart(false); startPriceTagTimer(); }, reload: function (force) { loadChart(!!force); }, startAutoRefresh: startAutoRefresh, stopAutoRefresh: stopAutoRefresh, stopPriceTagTimer: stopPriceTagTimer, }; if ( document.getElementById("page-market") && !document.getElementById("page-market").classList.contains("hidden") ) { window.hubMarketChart.init(); } })();