(function () { var chartEl = document.getElementById('market-chart'); var emptyEl = document.getElementById('market-chart-empty'); var wrapEl = document.getElementById('market-chart-wrap'); var chart = null; var currentPeriod = '15m'; var klineSource = null; var streamActive = false; var reconnectTimer = null; var lastData = null; var lastPrevClose = null; var chartOpts = { prevClose: false, ma: false, gapDay: false }; var dataZoomBound = false; function getSymbol() { var hidden = document.getElementById('market-symbol-hidden'); var input = document.getElementById('market-symbol-input'); if (hidden && hidden.value) return hidden.value.trim(); if (input && input.value) return input.value.trim(); return ''; } function getMarketCodes() { return { symbol: getSymbol(), market_code: (document.getElementById('market-market-code') || {}).value || '', sina_code: (document.getElementById('market-sina-code') || {}).value || '', }; } function themeColors() { var dark = document.documentElement.getAttribute('data-theme') !== 'light'; return { bg: dark ? '#0a0c14' : '#f4f7fc', text: dark ? '#a8b0c8' : '#5c6578', title: dark ? '#e8eaf6' : '#1a2233', grid: dark ? '#1a2038' : '#e2e8f0', up: dark ? '#4cd97f' : '#15803d', down: dark ? '#ff6b7a' : '#dc2626', line: dark ? '#4cc2ff' : '#2563eb', area: dark ? 'rgba(76,194,255,0.12)' : 'rgba(37,99,235,0.1)', slider: dark ? '#1e2640' : '#cbd5e1', sliderFill: dark ? '#4cc2ff' : '#2563eb', ma21: dark ? '#ffb347' : '#d97706', ma55: dark ? '#c084fc' : '#7c3aed', prevClose: dark ? '#fbbf24' : '#b45309', }; } function isTradingSession() { var d = new Date(); var wd = d.getDay(); if (wd === 0) return false; if (wd === 6 && d.getHours() < 21) return false; var t = d.getHours() * 60 + d.getMinutes(); function inRange(sh, sm, eh, em) { return t >= sh * 60 + sm && t < eh * 60 + em; } if (inRange(9, 0, 11, 30)) return true; if (inRange(13, 30, 15, 0)) return true; if (inRange(21, 0, 24, 0)) return true; if (inRange(0, 0, 2, 30)) return true; return false; } function calcMA(period, closes) { var result = []; for (var i = 0; i < closes.length; i++) { if (i < period - 1) { result.push(null); continue; } var sum = 0; for (var j = 0; j < period; j++) sum += closes[i - j]; result.push(+(sum / period).toFixed(4)); } return result; } function findMaCrosses(ma21, ma55) { var points = []; for (var i = 1; i < ma21.length; i++) { if (ma21[i] == null || ma55[i] == null || ma21[i - 1] == null || ma55[i - 1] == null) continue; var prev = ma21[i - 1] - ma55[i - 1]; var curr = ma21[i] - ma55[i]; if (prev <= 0 && curr > 0) points.push({ i: i, type: 'golden' }); if (prev >= 0 && curr < 0) points.push({ i: i, type: 'death' }); } return points; } function getDefaultZoomStart() { return currentPeriod === 'timeshare' ? 60 : 75; } function getZoomRange() { if (!chart) return { start: getDefaultZoomStart(), end: 100 }; var opt = chart.getOption(); if (opt && opt.dataZoom && opt.dataZoom.length) { var z = opt.dataZoom[0]; if (z.start != null && z.end != null) return { start: z.start, end: z.end }; } return { start: getDefaultZoomStart(), end: 100 }; } function visibleIndices(bars, zoom) { var n = bars.length; if (!n) return { start: 0, end: 0 }; var start = Math.floor(n * zoom.start / 100); var end = Math.min(n - 1, Math.ceil(n * zoom.end / 100) - 1); if (end < start) end = start; return { start: start, end: end }; } function computeVisibleHL(bars, startIdx, endIdx) { var maxH = -Infinity; var minL = Infinity; var maxI = startIdx; var minI = startIdx; for (var i = startIdx; i <= endIdx; i++) { var b = bars[i]; if (!b) continue; if (b.high > maxH) { maxH = b.high; maxI = i; } if (b.low < minL) { minL = b.low; minI = i; } } if (maxH === -Infinity) return null; return { maxH: maxH, minL: minL, maxI: maxI, minI: minI }; } function buildHLMarkPoint(hl, c) { if (!hl) return { data: [] }; return { symbol: 'circle', symbolSize: 6, data: [ { name: '高', xAxis: hl.maxI, yAxis: hl.maxH, value: hl.maxH.toFixed(2), itemStyle: { color: c.up }, label: { show: true, formatter: '高 ' + hl.maxH.toFixed(2), color: c.up, fontSize: 11, position: 'top', }, }, { name: '低', xAxis: hl.minI, yAxis: hl.minL, value: hl.minL.toFixed(2), itemStyle: { color: c.down }, label: { show: true, formatter: '低 ' + hl.minL.toFixed(2), color: c.down, fontSize: 11, position: 'bottom', }, }, ], }; } function buildMaCrossMarkPoint(crosses, bars, c) { if (!crosses.length) return { data: [] }; return { symbol: 'pin', symbolSize: 28, data: crosses.map(function (p) { var b = bars[p.i]; return { name: p.type === 'golden' ? '金叉' : '死叉', xAxis: p.i, yAxis: b ? b.close : 0, itemStyle: { color: p.type === 'golden' ? c.up : c.down }, label: { show: true, formatter: p.type === 'golden' ? '金叉' : '死叉', fontSize: 10, color: p.type === 'golden' ? c.up : c.down, }, }; }), }; } function buildPrevCloseMarkLine(prevClose, c) { if (prevClose == null) return { data: [] }; var v = Number(prevClose); return { silent: true, symbol: 'none', lineStyle: { color: c.prevClose, type: 'dashed', width: 1 }, label: { show: true, formatter: '昨收 ' + v.toFixed(2), color: c.prevClose, fontSize: 10, }, data: [{ yAxis: v }], }; } function initChart() { if (!chartEl || !window.echarts) return; chart = echarts.init(chartEl); window.addEventListener('resize', function () { if (chart) chart.resize(); }); document.addEventListener('click', function (e) { if (e.target.closest('[data-theme-pick]')) { setTimeout(function () { if (chart && lastData) renderChart(lastData, true); }, 100); } }); } function bindDataZoomHL() { if (!chart || dataZoomBound) return; dataZoomBound = true; chart.on('dataZoom', function () { updateVisibleHLMark(); }); } function updateVisibleHLMark() { if (!chart || !lastData || !lastData.bars) return; var bars = lastData.bars; var zoom = getZoomRange(); var idx = visibleIndices(bars, zoom); var hl = computeVisibleHL(bars, idx.start, idx.end); var c = themeColors(); chart.setOption({ series: [{ id: 'main', markPoint: buildHLMarkPoint(hl, c) }], }); } function getDataZoom(c, preserve) { var defStart = getDefaultZoomStart(); var zoom = [ { type: 'inside', xAxisIndex: [0, 1], start: defStart, end: 100, zoomOnMouseWheel: true, moveOnMouseMove: true, moveOnMouseWheel: false, }, { type: 'slider', xAxisIndex: [0, 1], start: defStart, end: 100, height: 22, bottom: 4, borderColor: c.grid, backgroundColor: c.bg, fillerColor: c.area, handleStyle: { color: c.sliderFill }, dataBackground: { lineStyle: { color: c.grid }, areaStyle: { color: c.area }, }, textStyle: { color: c.text, fontSize: 10 }, }, ]; if (preserve && chart) { var opt = chart.getOption(); if (opt && opt.dataZoom) { opt.dataZoom.forEach(function (z, i) { if (zoom[i] && z.start != null && z.end != null) { zoom[i].start = z.start; zoom[i].end = z.end; } }); } } return zoom; } function mapSeriesData(bars, values, gapDay) { if (!gapDay) return values; return bars.map(function (b, i) { var v = values[i]; if (v == null || b.timestamp == null) return v; return [b.timestamp, v]; }); } function renderChart(data, preserveZoom) { if (!chart) return; lastData = data; if (data.prev_close != null) lastPrevClose = data.prev_close; var c = themeColors(); var bars = data.bars || []; var times = bars.map(function (b) { return b.time; }); var isLine = data.chart_type === 'line' || data.period === 'timeshare'; var gapDay = chartOpts.gapDay; var dataZoom = getDataZoom(c, preserveZoom); var zoom = preserveZoom ? getZoomRange() : { start: dataZoom[0].start, end: dataZoom[0].end }; var vIdx = visibleIndices(bars, zoom); var hl = computeVisibleHL(bars, vIdx.start, vIdx.end); var closes = bars.map(function (b) { return b.close; }); var ma21 = chartOpts.ma ? calcMA(21, closes) : null; var ma55 = chartOpts.ma ? calcMA(55, closes) : null; var crosses = chartOpts.ma ? findMaCrosses(ma21, ma55) : []; var prevCloseVal = lastPrevClose != null ? lastPrevClose : data.prev_close; var showPrev = chartOpts.prevClose && prevCloseVal != null; var grids = [ { left: 56, right: 24, top: 44, height: '50%' }, { left: 56, right: 24, top: '66%', height: '14%' }, ]; var xAxisType = gapDay ? 'time' : 'category'; var xAxis0 = { type: xAxisType, gridIndex: 0, boundaryGap: gapDay ? false : true, axisLabel: { color: c.text, fontSize: 10 }, axisLine: { lineStyle: { color: c.grid } }, }; var xAxis1 = { type: xAxisType, gridIndex: 1, boundaryGap: gapDay ? false : true, axisLabel: { show: false }, axisLine: { lineStyle: { color: c.grid } }, }; if (!gapDay) { xAxis0.data = times; xAxis1.data = times; } var base = { backgroundColor: c.bg, animation: false, tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, axisPointer: { link: [{ xAxisIndex: 'all' }] }, dataZoom: dataZoom, grid: grids, xAxis: [xAxis0, xAxis1], yAxis: [ { scale: true, gridIndex: 0, splitLine: { lineStyle: { color: c.grid } }, axisLabel: { color: c.text } }, { scale: true, gridIndex: 1, splitLine: { show: false }, axisLabel: { color: c.text, fontSize: 10 }, splitNumber: 2 }, ], }; var series = []; var mainMark = { markPoint: buildHLMarkPoint(hl, c), }; if (showPrev) mainMark.markLine = buildPrevCloseMarkLine(prevCloseVal, c); if (chartOpts.ma && crosses.length) { var crossMp = buildMaCrossMarkPoint(crosses, bars, c); mainMark.markPoint.data = mainMark.markPoint.data.concat(crossMp.data); } if (isLine) { var lineData = mapSeriesData(bars, closes, gapDay); series.push({ id: 'main', name: '价格', type: 'line', data: lineData, smooth: true, showSymbol: false, lineStyle: { width: 1.5, color: c.line }, areaStyle: { color: c.area }, xAxisIndex: 0, yAxisIndex: 0, markPoint: mainMark.markPoint, markLine: mainMark.markLine, }); } else { var candle = bars.map(function (b) { if (gapDay && b.timestamp) { return [b.timestamp, b.open, b.close, b.low, b.high]; } return [b.open, b.close, b.low, b.high]; }); series.push({ id: 'main', name: 'K线', type: 'candlestick', data: candle, itemStyle: { color: c.up, color0: c.down, borderColor: c.up, borderColor0: c.down, }, xAxisIndex: 0, yAxisIndex: 0, markPoint: mainMark.markPoint, markLine: mainMark.markLine, }); } if (chartOpts.ma && ma21 && ma55) { series.push({ id: 'ma21', name: 'MA21', type: 'line', data: mapSeriesData(bars, ma21, gapDay), smooth: true, showSymbol: false, lineStyle: { width: 1, color: c.ma21 }, xAxisIndex: 0, yAxisIndex: 0, }); series.push({ id: 'ma55', name: 'MA55', type: 'line', data: mapSeriesData(bars, ma55, gapDay), smooth: true, showSymbol: false, lineStyle: { width: 1, color: c.ma55 }, xAxisIndex: 0, yAxisIndex: 0, }); } var vols = bars.map(function (b) { var up = b.close >= b.open; var val = { value: b.volume, itemStyle: { color: up ? c.up : c.down, opacity: 0.65 }, }; if (gapDay && b.timestamp) { return { value: [b.timestamp, b.volume], itemStyle: val.itemStyle }; } return val; }); series.push({ id: 'volume', name: '成交量', type: 'bar', data: vols, xAxisIndex: 1, yAxisIndex: 1, }); if (!isLine) { base.tooltip = { trigger: 'axis', axisPointer: { type: 'cross' }, formatter: function (params) { var idx = params[0] && params[0].dataIndex; if (idx == null || !bars[idx]) return ''; var b = bars[idx]; var lines = [b.time, '开 ' + b.open, '高 ' + b.high, '低 ' + b.low, '收 ' + b.close, '量 ' + b.volume]; if (ma21 && ma21[idx] != null) lines.push('MA21 ' + ma21[idx]); if (ma55 && ma55[idx] != null) lines.push('MA55 ' + ma55[idx]); return lines.join('
'); }, }; } chart.setOption(Object.assign(base, { series: series }), true); var title = (data.chart_symbol || data.symbol || '') + ' · ' + periodLabel(data.period); chart.setOption({ title: { text: title, left: 12, top: 8, textStyle: { color: c.title, fontSize: 13, fontWeight: 600 } }, legend: chartOpts.ma ? { data: ['MA21', 'MA55'], top: 8, right: 12, textStyle: { color: c.text, fontSize: 10 }, } : { show: false }, }); bindDataZoomHL(); } function periodLabel(key) { var tabs = document.querySelectorAll('.period-tab'); for (var i = 0; i < tabs.length; i++) { if (tabs[i].getAttribute('data-period') === key) return tabs[i].textContent; } return key; } function hideEmptyOverlay() { if (wrapEl) wrapEl.classList.add('has-data'); } function showEmptyOverlay(text) { if (emptyEl) emptyEl.textContent = text; if (wrapEl) wrapEl.classList.remove('has-data'); } function setLoading(on) { var btn = document.getElementById('market-load-btn'); if (btn) { btn.disabled = on; btn.textContent = on ? '连接中…' : '查看'; } if (!wrapEl) return; if (on && !lastData) { wrapEl.classList.add('loading'); showEmptyOverlay('请选择合约并点击「查看」'); } else { wrapEl.classList.remove('loading'); if (lastData) hideEmptyOverlay(); } } function updateRefreshHint(disconnected) { var el = document.getElementById('market-refresh-hint'); if (!el) return; if (!getSymbol()) { el.textContent = ''; return; } if (disconnected) { el.textContent = 'SSE 连接中断,正在重连…'; return; } if (!streamActive) { el.textContent = ''; return; } var src = lastData && lastData.source === 'local' ? ' · 本地缓存' : ''; if (isTradingSession()) { el.textContent = '交易中 · 后台刷新 · SSE 推送(约1秒)' + src; } else { el.textContent = 'SSE 推送 · 非交易时段低频刷新' + src; } } function updatePrevCloseDisplay(val) { var prevEl = document.getElementById('market-quote-prev'); if (!prevEl) return; if (val != null && !isNaN(Number(val))) { prevEl.textContent = '昨收 ' + Number(val).toFixed(2); } else { prevEl.textContent = ''; } } function applyQuote(data) { var priceEl = document.getElementById('market-quote-price'); var nameEl = document.getElementById('market-quote-name'); if (nameEl && data.name) nameEl.textContent = data.name + ' ' + (data.symbol || ''); if (priceEl) { priceEl.textContent = data.price != null ? Number(data.price).toFixed(2) : '—'; } if (data.prev_close != null) { lastPrevClose = data.prev_close; updatePrevCloseDisplay(data.prev_close); if (chartOpts.prevClose && lastData) renderChart(lastData, true); } } function stopKlineStream() { streamActive = false; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (klineSource) { klineSource.close(); klineSource = null; } } function scheduleReconnect() { if (reconnectTimer) return; updateRefreshHint(true); reconnectTimer = setTimeout(function () { reconnectTimer = null; if (getSymbol()) startKlineStream(false); }, 3000); } function startKlineStream(showLoading) { stopKlineStream(); var symbol = getSymbol(); if (!symbol) { alert('请先选择或输入合约代码'); return; } if (showLoading) setLoading(true); var codes = getMarketCodes(); var q = 'symbol=' + encodeURIComponent(symbol) + '&period=' + encodeURIComponent(currentPeriod); if (codes.market_code) q += '&market_code=' + encodeURIComponent(codes.market_code); if (codes.sina_code) q += '&sina_code=' + encodeURIComponent(codes.sina_code); klineSource = new EventSource('/api/kline/stream?' + q); streamActive = true; updateRefreshHint(false); klineSource.addEventListener('kline', function (e) { try { var data = JSON.parse(e.data); if (!data.bars || !data.bars.length) return; hideEmptyOverlay(); renderChart(data, lastData !== null); updateQuoteMeta(data); if (data.prev_close != null) updatePrevCloseDisplay(data.prev_close); updateRefreshHint(false); setLoading(false); } catch (err) { /* ignore */ } }); klineSource.addEventListener('quote', function (e) { try { applyQuote(JSON.parse(e.data)); } catch (err) { /* ignore */ } }); klineSource.onerror = function () { stopKlineStream(); scheduleReconnect(); }; } function updateQuoteMeta(data) { var meta = document.getElementById('market-quote-meta'); if (meta) { meta.textContent = data.count ? ('共 ' + data.count + ' 根 · ' + periodLabel(data.period)) : ''; } var nameEl = document.getElementById('market-quote-name'); var hiddenName = document.getElementById('market-symbol-name'); if (nameEl && !(nameEl.textContent && nameEl.textContent.trim())) { nameEl.textContent = (hiddenName && hiddenName.value) || data.symbol || '—'; } } function loadKline(showLoading) { startKlineStream(showLoading); } function shiftDataZoom(delta) { if (!chart) return; var opt = chart.getOption(); if (!opt || !opt.dataZoom || !opt.dataZoom.length) return; var z = opt.dataZoom[0]; var span = (z.end - z.start) || 20; var newSpan = Math.max(5, Math.min(100, span + delta)); var center = (z.start + z.end) / 2; var start = Math.max(0, center - newSpan / 2); var end = Math.min(100, center + newSpan / 2); if (end - start < newSpan) { if (start === 0) end = newSpan; else start = end - newSpan; } chart.dispatchAction({ type: 'dataZoom', start: start, end: end }); } function resetDataZoom() { if (!chart) return; chart.dispatchAction({ type: 'dataZoom', start: getDefaultZoomStart(), end: 100 }); } function bindPeriodTabs() { var tabs = document.getElementById('market-period-tabs'); if (!tabs) return; tabs.addEventListener('click', function (e) { var btn = e.target.closest('.period-tab'); if (!btn) return; tabs.querySelectorAll('.period-tab').forEach(function (el) { el.classList.remove('active'); }); btn.classList.add('active'); currentPeriod = btn.getAttribute('data-period') || '15m'; if (getSymbol()) loadKline(true); }); } function bindZoomButtons() { var zoomIn = document.getElementById('chart-zoom-in'); var zoomOut = document.getElementById('chart-zoom-out'); var zoomReset = document.getElementById('chart-zoom-reset'); if (zoomIn) zoomIn.addEventListener('click', function () { shiftDataZoom(-12); }); if (zoomOut) zoomOut.addEventListener('click', function () { shiftDataZoom(12); }); if (zoomReset) zoomReset.addEventListener('click', resetDataZoom); } function bindChartOptions() { var prevCb = document.getElementById('chart-opt-prev-close'); var maCb = document.getElementById('chart-opt-ma'); var gapCb = document.getElementById('chart-opt-gap-day'); if (prevCb) { prevCb.addEventListener('change', function () { chartOpts.prevClose = prevCb.checked; if (lastData) renderChart(lastData, true); }); } if (maCb) { maCb.addEventListener('change', function () { chartOpts.ma = maCb.checked; if (lastData) renderChart(lastData, true); }); } if (gapCb) { gapCb.addEventListener('change', function () { chartOpts.gapDay = gapCb.checked; if (lastData) renderChart(lastData, false); }); } } document.addEventListener('DOMContentLoaded', function () { initChart(); bindPeriodTabs(); bindZoomButtons(); bindChartOptions(); var active = document.querySelector('.period-tab.active'); if (active) currentPeriod = active.getAttribute('data-period') || '15m'; var loadBtn = document.getElementById('market-load-btn'); if (loadBtn) loadBtn.addEventListener('click', function () { loadKline(true); }); var hidden = document.getElementById('market-symbol-hidden'); var input = document.getElementById('market-symbol-input'); if (input) { input.addEventListener('symbol-selected', function () { lastPrevClose = null; updatePrevCloseDisplay(null); loadKline(true); }); } if (hidden && hidden.value) { if (input && !input.value) input.value = hidden.value; loadKline(true); } else { updateRefreshHint(false); } window.addEventListener('beforeunload', stopKlineStream); }); })();