/* Copyright (c) 2025-2026 马建军. All rights reserved. * 专有软件 — 未经授权禁止复制、传播、转售。 * 详见 LICENSE.zh-CN.txt */ (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 candleSeries = null; var volumeSeries = null; var areaSeries = null; var ma21Series = null; var ma55Series = null; var prevCloseLine = null; var resizeObs = null; var currentPeriod = '15m'; var currentChartMode = ''; var klineSource = null; var streamActive = false; var reconnectTimer = null; var lastData = null; var lastRenderedPrepared = null; var lastPrevClose = null; var chartOpts = { prevClose: false, ma: false, gapDay: false }; var followingLatest = true; var autoFollow = true; var DEFAULT_VISIBLE_BARS = 80; var EMA_STORAGE_KEY = 'qihuo-market-ema-periods'; var DEFAULT_EMA = { fast: 21, slow: 55 }; var PERIOD_SECONDS = { timeshare: 60, '1m': 60, '2m': 120, '5m': 300, '15m': 900, '1h': 3600, '2h': 7200, '4h': 14400, d: 86400, w: 604800, }; 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' : '#ffffff', text: dark ? '#a8b0c8' : '#5c6578', grid: dark ? '#1e2640' : '#e8edf5', up: dark ? '#26a69a' : '#089981', down: dark ? '#ef5350' : '#f23645', line: dark ? '#4cc2ff' : '#2962ff', areaTop: dark ? 'rgba(76,194,255,0.28)' : 'rgba(41,98,255,0.22)', ma21: dark ? '#ffb347' : '#f7931a', 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 barUnixTime(bar) { if (bar.timestamp) return Math.floor(bar.timestamp / 1000); if (bar.time) { var d = new Date(String(bar.time).replace(' ', 'T')); if (!isNaN(d.getTime())) return Math.floor(d.getTime() / 1000); } return null; } function prepareBars(bars, periodKey) { var out = []; var gapDay = chartOpts.gapDay; var seen = {}; var gapBase = null; var step = PERIOD_SECONDS[periodKey] || 60; for (var i = 0; i < bars.length; i++) { var b = bars[i]; var o = Number(b.open); var h = Number(b.high); var l = Number(b.low); var c = Number(b.close); if (!isFinite(o) || !isFinite(c)) continue; if (!isFinite(h)) h = Math.max(o, c); if (!isFinite(l)) l = Math.min(o, c); h = Math.max(h, o, c); l = Math.min(l, o, c); var t; if (gapDay) { if (gapBase == null) { gapBase = b.timestamp ? Math.floor(b.timestamp / 1000) : 946684800; } t = gapBase + out.length * step; } else { t = barUnixTime(b); } if (t == null || seen[t]) continue; seen[t] = true; out.push({ time: t, open: o, high: h, low: l, close: c, volume: Number(b.volume) || 0, rawTime: b.time, }); } return out; } function clampEmaPeriod(v, fallback) { var n = parseInt(v, 10); if (isNaN(n)) return fallback; return Math.max(2, Math.min(500, n)); } function loadEmaPeriodsFromStorage() { try { var raw = localStorage.getItem(EMA_STORAGE_KEY); if (raw) { var o = JSON.parse(raw); return { fast: clampEmaPeriod(o.fast, DEFAULT_EMA.fast), slow: clampEmaPeriod(o.slow, DEFAULT_EMA.slow), }; } } catch (e) { /* ignore */ } return { fast: DEFAULT_EMA.fast, slow: DEFAULT_EMA.slow }; } function saveEmaPeriods(fast, slow) { try { localStorage.setItem(EMA_STORAGE_KEY, JSON.stringify({ fast: fast, slow: slow })); } catch (e) { /* ignore */ } } function getEmaPeriods() { var fastEl = document.getElementById('chart-ema-fast'); var slowEl = document.getElementById('chart-ema-slow'); var fast = clampEmaPeriod(fastEl && fastEl.value, DEFAULT_EMA.fast); var slow = clampEmaPeriod(slowEl && slowEl.value, DEFAULT_EMA.slow); if (fastEl) fastEl.value = String(fast); if (slowEl) slowEl.value = String(slow); return { fast: fast, slow: slow }; } function syncEmaInputsEnabled() { var fastEl = document.getElementById('chart-ema-fast'); var slowEl = document.getElementById('chart-ema-slow'); var on = !!chartOpts.ma; if (fastEl) fastEl.disabled = !on; if (slowEl) slowEl.disabled = !on; } function initEmaPeriodInputs() { var stored = loadEmaPeriodsFromStorage(); var fastEl = document.getElementById('chart-ema-fast'); var slowEl = document.getElementById('chart-ema-slow'); if (fastEl) fastEl.value = String(stored.fast); if (slowEl) slowEl.value = String(stored.slow); syncEmaInputsEnabled(); } function calcEMA(period, bars) { var result = []; if (!bars.length || period < 1) return result; var k = 2 / (period + 1); var ema = null; for (var i = 0; i < bars.length; i++) { var close = bars[i].close; if (ema == null) { if (i < period - 1) continue; var sum = 0; for (var j = i - period + 1; j <= i; j++) sum += bars[j].close; ema = sum / period; } else { ema = close * k + ema * (1 - k); } result.push({ time: bars[i].time, value: +ema.toFixed(4) }); } return result; } function destroyChart() { if (resizeObs) { resizeObs.disconnect(); resizeObs = null; } if (chart) { chart.remove(); chart = null; } candleSeries = null; volumeSeries = null; areaSeries = null; ma21Series = null; ma55Series = null; prevCloseLine = null; currentChartMode = ''; lastRenderedPrepared = null; } function buildChart(mode) { destroyChart(); if (!chartEl || !window.LightweightCharts) return; var c = themeColors(); var w = chartEl.clientWidth || 600; var h = chartEl.clientHeight || 400; chart = LightweightCharts.createChart(chartEl, { width: w, height: h, layout: { background: { type: 'solid', color: c.bg }, textColor: c.text, fontSize: 11, }, grid: { vertLines: { color: c.grid, style: 1 }, horzLines: { color: c.grid, style: 1 }, }, crosshair: { mode: LightweightCharts.CrosshairMode.Normal, vertLine: { width: 1, color: c.text, style: 2, labelBackgroundColor: c.grid }, horzLine: { width: 1, color: c.text, style: 2, labelBackgroundColor: c.grid }, }, rightPriceScale: { borderColor: c.grid, scaleMargins: mode === 'line' ? { top: 0.08, bottom: 0.08 } : { top: 0.05, bottom: 0.22 }, }, timeScale: { borderColor: c.grid, timeVisible: true, secondsVisible: false, rightOffset: 8, barSpacing: 10, minBarSpacing: 4, fixLeftEdge: false, fixRightEdge: false, }, handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: true }, handleScale: { axisPressedMouseMove: true, mouseWheel: true, pinch: true }, localization: { locale: 'zh-CN' }, }); if (mode === 'line') { areaSeries = chart.addAreaSeries({ lineColor: c.line, topColor: c.areaTop, bottomColor: 'rgba(0,0,0,0)', lineWidth: 2, priceLineVisible: false, lastValueVisible: true, }); } else { candleSeries = chart.addCandlestickSeries({ upColor: c.up, downColor: c.down, borderVisible: true, borderUpColor: c.up, borderDownColor: c.down, wickUpColor: c.up, wickDownColor: c.down, priceLineVisible: false, lastValueVisible: true, }); volumeSeries = chart.addHistogramSeries({ priceFormat: { type: 'volume' }, priceScaleId: 'volume', lastValueVisible: false, priceLineVisible: false, }); chart.priceScale('volume').applyOptions({ scaleMargins: { top: 0.82, bottom: 0 }, borderVisible: false, }); if (chartOpts.ma) { ma21Series = chart.addLineSeries({ color: c.ma21, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false, }); ma55Series = chart.addLineSeries({ color: c.ma55, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false, }); } } chart.timeScale().subscribeVisibleLogicalRangeChange(function () { if (!chart) return; var range = chart.timeScale().getVisibleLogicalRange(); if (!range || !lastData || !lastData.preparedBars) return; var total = lastData.preparedBars.length; followingLatest = range.to >= total - 2; }); resizeObs = new ResizeObserver(function () { if (!chart || !chartEl) return; chart.applyOptions({ width: chartEl.clientWidth, height: chartEl.clientHeight }); }); resizeObs.observe(chartEl); currentChartMode = mode; } function applyPrevCloseLine(price) { if (!candleSeries || currentChartMode !== 'candle') return; if (prevCloseLine) { candleSeries.removePriceLine(prevCloseLine); prevCloseLine = null; } if (!chartOpts.prevClose || price == null || !isFinite(Number(price))) return; var c = themeColors(); prevCloseLine = candleSeries.createPriceLine({ price: Number(price), color: c.prevClose, lineWidth: 1, lineStyle: LightweightCharts.LineStyle.Dashed, axisLabelVisible: true, title: '昨收', }); } function setVisibleRange(prepared, preserve) { if (!chart || !prepared.length) return; var ts = chart.timeScale(); if (preserve && followingLatest) { var span = DEFAULT_VISIBLE_BARS; try { var cur = ts.getVisibleLogicalRange(); if (cur) span = Math.max(20, cur.to - cur.from); } catch (e) { /* ignore */ } ts.setVisibleLogicalRange({ from: Math.max(0, prepared.length - span), to: prepared.length + 4, }); return; } if (preserve) return; var show = Math.min(DEFAULT_VISIBLE_BARS, prepared.length); ts.setVisibleLogicalRange({ from: Math.max(0, prepared.length - show), to: prepared.length + 4, }); } function shouldPreserveView() { return !autoFollow || !followingLatest; } function applyBarUpdate(bar, mode, prepared) { if (mode === 'line') { areaSeries.update({ time: bar.time, value: bar.close }); return; } candleSeries.update({ time: bar.time, open: bar.open, high: bar.high, low: bar.low, close: bar.close, }); var up = bar.close >= bar.open; var c = themeColors(); volumeSeries.update({ time: bar.time, value: bar.volume, color: up ? c.up : c.down, }); if (chartOpts.ma && ma21Series && ma55Series && prepared && prepared.length) { var emaP = getEmaPeriods(); var emaFast = calcEMA(emaP.fast, prepared); var emaSlow = calcEMA(emaP.slow, prepared); if (emaFast.length) ma21Series.update(emaFast[emaFast.length - 1]); if (emaSlow.length) ma55Series.update(emaSlow[emaSlow.length - 1]); } } function tryIncrementalUpdate(prepared, mode) { if (!lastRenderedPrepared || !prepared.length) return false; var prev = lastRenderedPrepared; var prevLast = prev[prev.length - 1]; var newLast = prepared[prepared.length - 1]; if (prepared.length === prev.length && newLast.time === prevLast.time) { applyBarUpdate(newLast, mode, prepared); return true; } if (prepared.length === prev.length + 1 && prepared[prepared.length - 2].time === prevLast.time) { applyBarUpdate(newLast, mode, prepared); if (autoFollow && followingLatest) { setVisibleRange(prepared, true); } return true; } return false; } function renderChartFull(prepared, data, mode, preserveRange) { if (mode === 'line') { areaSeries.setData(prepared.map(function (b) { return { time: b.time, value: b.close }; })); } else { candleSeries.setData(prepared.map(function (b) { return { time: b.time, open: b.open, high: b.high, low: b.low, close: b.close }; })); volumeSeries.setData(prepared.map(function (b) { var up = b.close >= b.open; var c = themeColors(); return { time: b.time, value: b.volume, color: up ? c.up : c.down, }; })); if (chartOpts.ma && ma21Series && ma55Series) { var emaP = getEmaPeriods(); ma21Series.setData(calcEMA(emaP.fast, prepared)); ma55Series.setData(calcEMA(emaP.slow, prepared)); } applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : data.prev_close); } if (!shouldPreserveView()) { setVisibleRange(prepared, !!preserveRange); } } function renderChart(data, options) { options = options || {}; if (!chartEl || !window.LightweightCharts) return; lastData = data; if (data.prev_close != null) lastPrevClose = data.prev_close; var isLine = data.chart_type === 'line' || data.period === 'timeshare'; var mode = isLine ? 'line' : 'candle'; if (!chart || currentChartMode !== mode) buildChart(mode); if (!chart) return; var prepared = prepareBars(data.bars || [], data.period || currentPeriod); data.preparedBars = prepared; if (!prepared.length) return; if (!options.forceFull && shouldPreserveView() && tryIncrementalUpdate(prepared, mode)) { lastRenderedPrepared = prepared; return; } renderChartFull(prepared, data, mode, options.preserveRange); lastRenderedPrepared = prepared; } 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 klineSourceLabel(src) { if (src === 'local') return '本地缓存'; return '新浪'; } 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 = ''; if (lastData && lastData.source) { src = ' · ' + klineSourceLabel(lastData.source); } if (isTradingSession()) { el.textContent = '新浪数据 · 交易中 SSE 推送' + src; } else { el.textContent = '新浪数据 · 非交易时段低频刷新' + 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.quote_source && lastData) { updateQuoteMeta(Object.assign({}, lastData, { quote_source: data.quote_source })); } if (data.prev_close != null) { lastPrevClose = data.prev_close; updatePrevCloseDisplay(data.prev_close); if (chartOpts.prevClose && lastData) { applyPrevCloseLine(data.prev_close); } } } 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; followingLatest = 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, { preserveRange: 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) { var parts = []; if (data.count) parts.push('共 ' + data.count + ' 根 · ' + periodLabel(data.period)); if (data.source) parts.push('K线 ' + klineSourceLabel(data.source)); if (data.quote_source) { parts.push('报价 新浪'); } meta.textContent = parts.join(' · '); } 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 ts = chart.timeScale(); var range = ts.getVisibleLogicalRange(); if (!range) return; var span = range.to - range.from; var newSpan = Math.max(15, span + delta); var center = (range.from + range.to) / 2; ts.setVisibleLogicalRange({ from: center - newSpan / 2, to: center + newSpan / 2, }); } function resetDataZoom() { if (!chart || !lastData || !lastData.preparedBars) return; followingLatest = true; setVisibleRange(lastData.preparedBars, false); } 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'; followingLatest = true; 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(-20); }); if (zoomOut) zoomOut.addEventListener('click', function () { shiftDataZoom(20); }); if (zoomReset) zoomReset.addEventListener('click', resetDataZoom); } function bindAutoButton() { var btn = document.getElementById('market-auto-btn'); if (!btn) return; btn.addEventListener('click', function () { autoFollow = !autoFollow; btn.classList.toggle('is-active', autoFollow); if (autoFollow) { followingLatest = true; if (lastData && lastData.preparedBars) { setVisibleRange(lastData.preparedBars, false); } } }); } 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'); var emaFastEl = document.getElementById('chart-ema-fast'); var emaSlowEl = document.getElementById('chart-ema-slow'); function onEmaPeriodChange() { var emaP = getEmaPeriods(); saveEmaPeriods(emaP.fast, emaP.slow); if (chartOpts.ma && lastData) { renderChart(lastData, { forceFull: true }); } } if (prevCb) { prevCb.addEventListener('change', function () { chartOpts.prevClose = prevCb.checked; if (lastData) { applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : lastData.prev_close); } }); } if (maCb) { maCb.addEventListener('change', function () { chartOpts.ma = maCb.checked; syncEmaInputsEnabled(); if (lastData) { destroyChart(); renderChart(lastData, { forceFull: true }); } }); } if (emaFastEl) { emaFastEl.addEventListener('change', onEmaPeriodChange); emaFastEl.addEventListener('keydown', function (ev) { if (ev.key === 'Enter') { ev.preventDefault(); emaFastEl.blur(); onEmaPeriodChange(); } }); } if (emaSlowEl) { emaSlowEl.addEventListener('change', onEmaPeriodChange); emaSlowEl.addEventListener('keydown', function (ev) { if (ev.key === 'Enter') { ev.preventDefault(); emaSlowEl.blur(); onEmaPeriodChange(); } }); } if (gapCb) { gapCb.addEventListener('change', function () { chartOpts.gapDay = gapCb.checked; followingLatest = true; if (lastData) renderChart(lastData, { forceFull: true }); }); } } function cleanupMarketPage() { stopKlineStream(); destroyChart(); } function bootMarketPage() { if (!window.LightweightCharts) { if (emptyEl) emptyEl.textContent = '图表库加载失败,请刷新页面'; return; } bindPeriodTabs(); bindZoomButtons(); bindAutoButton(); initEmaPeriodInputs(); 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; lastData = null; lastRenderedPrepared = null; destroyChart(); updatePrevCloseDisplay(null); loadKline(true); }); } if (hidden && hidden.value) { if (input && !input.value) input.value = hidden.value; loadKline(true); } else { updateRefreshHint(false); } } if (!window.__QIHUO_MARKET_THEME__) { window.__QIHUO_MARKET_THEME__ = true; document.addEventListener('click', function (e) { if (e.target.closest('[data-theme-pick]') && lastData) { setTimeout(function () { destroyChart(); renderChart(lastData, { forceFull: true }); }, 80); } }); } if (window.qihuoPageBoot) window.qihuoPageBoot(bootMarketPage, '#market-chart-wrap'); else if (window.qihuoOnPageLoad) window.qihuoOnPageLoad(bootMarketPage); else document.addEventListener('DOMContentLoaded', bootMarketPage); if (window.qihuoOnPageLeave) window.qihuoOnPageLeave(cleanupMarketPage); window.addEventListener('pagehide', cleanupMarketPage); })();