/**
* 中控行情区: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 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,
"12h": 12 * 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",
"720": "12h",
"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小时",
"12h": "12小时",
"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 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 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;
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 (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;
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 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;
const px = roundToTick(s.price);
if (px == null || !Number.isFinite(Number(px))) return;
positionLines.push(
candleSeries.createPriceLine({
price: Number(px),
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))));
}
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 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: 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 () {
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(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 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);
});
populateFsExchangeOptions();
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;
try {
applyChartPriceFormat();
} catch (fmtErr) {
priceTick = null;
applyChartPriceFormat();
}
lastCandles = alignCandlesToTick(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();
try {
updateIndicators();
} catch (indErr) {
/* 指标序列 priceFormat 异常时不阻断主图 */
}
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 () {
tfDigitBuf = "";
if (tfDigitTimer) {
clearTimeout(tfDigitTimer);
tfDigitTimer = null;
}
currentTf = (elTf && elTf.value) || "1d";
tickLiveClock();
syncFsToolbarFromMain();
loadChart(false);
});
}
if (elExchange) {
elExchange.addEventListener("change", function () {
updateExchangeDisplay();
syncFsToolbarFromMain();
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();
});
});
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";
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();
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();
}
})();