/** * 中控行情区:单图 + 周期切换,数据来自 /api/chart/ohlcv(本地库优先)。 */ (function () { const TF_ORDER = ["1m", "5m", "15m", "1h", "4h", "1d", "1w"]; 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 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 elSymLabel = document.getElementById("mkt-symbol-label"); const elTfLabel = document.getElementById("mkt-tf-label"); let chart = null; let candleSeries = null; let priceTick = null; let rangeMarkers = []; let lastCandles = []; let chartMeta = null; let loadToken = 0; let marketInited = false; 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 paintOhlcv(bar) { if (!bar) { ["o", "h", "l", "c", "v"].forEach(function (k) { const el = { o: elO, h: elH, l: elL, c: elC, v: elV }[k]; if (el) el.textContent = "-"; }); return; } if (elO) elO.textContent = bar.open != null ? String(bar.open) : "-"; if (elH) elH.textContent = bar.high != null ? String(bar.high) : "-"; if (elL) elL.textContent = bar.low != null ? String(bar.low) : "-"; if (elC) elC.textContent = bar.close != null ? String(bar.close) : "-"; if (elV) elV.textContent = fmtVol(bar.volume); } function ensureChart() { if (chart && candleSeries) 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: { color: "#1a2838" }, horzLines: { color: "#1a2838" } }, rightPriceScale: { borderColor: "#2a4058" }, timeScale: { borderColor: "#2a4058", timeVisible: true, secondsVisible: false }, crosshair: { mode: LightweightCharts.CrosshairMode ? LightweightCharts.CrosshairMode.Normal : 0 }, }); const opts = { upColor: "#00ff9d", downColor: "#ff4d6d", borderVisible: false, wickUpColor: "#00ff9d", wickDownColor: "#ff4d6d", }; if (typeof chart.addCandlestickSeries === "function") { candleSeries = chart.addCandlestickSeries(opts); } else if ( typeof chart.addSeries === "function" && window.LightweightCharts && window.LightweightCharts.CandlestickSeries ) { candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, opts); } if (!candleSeries) return false; chart.subscribeCrosshairMove(function (param) { if (!param || !param.time || !param.seriesData) return; const d = param.seriesData.get(candleSeries); if (!d) return; paintOhlcv({ open: d.open, high: d.high, low: d.low, close: d.close, volume: d.volume, }); }); window.addEventListener("resize", function () { if (!chart) return; chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight }); }); chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight }); return true; } function clearMarkers() { rangeMarkers.forEach(function (m) { try { candleSeries.removePriceLine(m); } catch (e) {} }); rangeMarkers = []; } function addRangeMarkers(data) { clearMarkers(); if (!candleSeries || !data) return; const hi = data.range_high; const lo = data.range_low; if (hi && hi.price != null) { rangeMarkers.push( candleSeries.createPriceLine({ price: Number(hi.price), color: "#ffb84d", lineWidth: 1, lineStyle: 2, axisLabelVisible: true, title: "区间高", }) ); } if (lo && lo.price != null) { rangeMarkers.push( candleSeries.createPriceLine({ price: Number(lo.price), 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; } 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(); } async function loadChart(force) { if (!ensureChart()) return; const exKey = (elExchange && elExchange.value) || ""; const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; const tf = (elTf && elTf.value) || "5m"; if (!exKey || !sym) { if (elStatus) { elStatus.className = "market-status err"; elStatus.textContent = "请选择交易所并输入币种"; } return; } const myToken = ++loadToken; if (elStatus) { elStatus.className = "market-status"; elStatus.textContent = "加载中…"; } if (elSymLabel) elSymLabel.textContent = sym; if (elTfLabel) elTfLabel.textContent = 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; lastCandles = data.candles; candleSeries.setData(data.candles); chart.timeScale().fitContent(); addRangeMarkers(data); const ohlcv = data.ohlcv || {}; paintOhlcv({ open: ohlcv.open, high: ohlcv.high, low: ohlcv.low, close: ohlcv.close, volume: ohlcv.volume, }); let hint = "已加载 " + data.candles.length + " 根(库 " + (data.from_cache || 0) + " / 新拉 " + (data.fetched || 0) + ")· 保留 " + (data.retention_days || 15) + " 天"; 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 || "--"; } 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 () { loadChart(false); }); } if (elExchange) { elExchange.addEventListener("change", function () { 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); }); } window.hubMarketChart = { init: async function () { if (!marketInited) { marketInited = true; await loadMeta(); bind(); } await loadChart(false); }, reload: function (force) { loadChart(!!force); }, }; if (document.getElementById("page-market") && !document.getElementById("page-market").classList.contains("hidden")) { window.hubMarketChart.init(); } })();