Files
crypto_monitor/manual_trading_hub/static/chart.js
T
dekun bfffc7d984 修复 OKX K 线无 since 时只拉 300 根的问题,并加入行情快捷键
- fetch_ohlcv_for_hub:无 since 时按目标根数分页拉取(OKX/Gate 单次约 300)

- hub_kline_store 全量补拉传 fetch_start_ms

- 行情区:数字键切换周期、Ctrl+空格全屏、Esc 退出全屏

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:51:37 +08:00

1672 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 中控行情区: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,
"4h": 4 * 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",
"240": "4h",
"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小时",
"4h": "4小时",
"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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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.indexOf(buf) === 0;
});
}
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;
const immediate = resolveTfFromDigitBuffer(tfDigitBuf);
if (immediate) {
commitTfDigitBuffer();
return;
}
if (!canExtendTfDigitBuffer(tfDigitBuf)) {
tfDigitBuf = digit;
const again = resolveTfFromDigitBuffer(tfDigitBuf);
if (again) {
commitTfDigitBuffer();
return;
}
}
if (tfDigitTimer) clearTimeout(tfDigitTimer);
tfDigitTimer = setTimeout(commitTfDigitBuffer, TF_DIGIT_TIMEOUT_MS);
}
function isFullscreenShortcut(e) {
return (
e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
!e.shiftKey &&
(e.code === "Space" || e.key === " " || e.key === "Spacebar")
);
}
function onMarketKeydown(e) {
if (!isMarketPageActive()) return;
if (e.key === "Escape" && chartFullscreen) {
e.preventDefault();
setChartFullscreen(false);
return;
}
if (isFullscreenShortcut(e)) {
e.preventDefault();
toggleChartFullscreen();
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 = '<span class="market-pos-orders-empty">暂无委托单</span>';
} else {
elPosOrders.innerHTML = orders
.map(function (o) {
const price = o.price != null ? fmtPrice(o.price) : "—";
const amt = o.amount != null ? String(o.amount) : "";
return (
'<span class="market-pos-order">' +
'<span class="market-pos-order-kind">' +
escHtml(o.kind || "") +
"</span>" +
'<span class="market-pos-order-label">' +
escHtml(o.label || "") +
"</span>" +
'<span class="market-pos-order-price">' +
price +
"</span>" +
(amt ? '<span class="market-pos-order-amt">×' + escHtml(amt) + "</span>" : "") +
"</span>"
);
})
.join("");
}
}
scheduleChartResize();
}
function clearPositionLines() {
positionLines.forEach(function (m) {
try {
candleSeries.removePriceLine(m);
} catch (e) {}
});
positionLines = [];
}
function updatePositionLines() {
clearPositionLines();
if (!candleSeries || !posContext) return;
const specs = [
{ price: posContext.entry, color: "#5b9cf5", title: "入场" },
{ price: posContext.stop_loss, color: "#ff4d6d", title: "止损" },
];
if (!posContext.tp_monitored && posContext.take_profit != null) {
specs.push({ price: posContext.take_profit, color: "#00ff9d", title: "止盈" });
}
specs.forEach(function (s) {
if (s.price == null || !Number.isFinite(Number(s.price))) return;
positionLines.push(
candleSeries.createPriceLine({
price: Number(s.price),
color: s.color,
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: s.title,
})
);
});
}
function clearPosContext() {
posContext = null;
try {
sessionStorage.removeItem(HUB_MARKET_POS_CTX_KEY);
} catch (e) {}
clearPosPanel();
clearPositionLines();
}
function applyPosContext(ctx) {
posContext = ctx;
renderPosPanel(ctx);
updatePositionLines();
}
function syncPosContextForView(exKey, sym) {
const stored = loadPosContextFromStorage();
if (stored && posContextMatches(stored, exKey, sym)) {
applyPosContext(stored);
return;
}
clearPosContext();
}
function fmtVol(v) {
if (v == null || Number.isNaN(Number(v))) return "-";
const n = Number(v);
if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(2) + "K";
return n.toFixed(2);
}
function decimalsFromTick(tick) {
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null;
const minMove = Number(tick);
if (minMove >= 1) return 0;
const raw = String(minMove);
const sci = raw.match(/e-(\d+)/i);
if (sci) return Math.min(12, parseInt(sci[1], 10));
const fixed = minMove.toFixed(12);
const frac = fixed.split(".")[1] || "";
const trimmed = frac.replace(/0+$/, "");
if (trimmed.length) return Math.min(12, trimmed.length);
return Math.max(0, Math.min(12, Math.round(-Math.log10(minMove))));
}
function tickToPriceFormat(tick) {
const minMove =
tick != null && Number.isFinite(Number(tick)) && Number(tick) > 0 ? Number(tick) : 0.01;
const precision = decimalsFromTick(minMove) ?? 2;
return { type: "price", precision: precision, minMove: minMove };
}
function applyChartPriceFormat() {
const pf = tickToPriceFormat(priceTick);
if (candleSeries && candleSeries.applyOptions) {
candleSeries.applyOptions({ priceFormat: pf });
}
if (indSeries.ema21 && indSeries.ema21.applyOptions) {
indSeries.ema21.applyOptions({ priceFormat: pf });
}
if (indSeries.ema55 && indSeries.ema55.applyOptions) {
indSeries.ema55.applyOptions({ priceFormat: pf });
}
if (chart) {
chart.applyOptions({
localization: {
priceFormatter: function (p) {
return fmtPrice(p);
},
},
});
}
}
function fmtPrice(v) {
if (v == null || Number.isNaN(Number(v))) return "-";
const n = Number(v);
if (n === 0) return "0";
const dec = decimalsFromTick(priceTick);
if (dec != null) return n.toFixed(dec);
const av = Math.abs(n);
let d = 8;
if (av >= 10000) d = 2;
else if (av >= 100) d = 3;
else if (av >= 1) d = 4;
else if (av >= 0.01) d = 6;
let text = n.toFixed(d);
if (text.indexOf(".") >= 0) text = text.replace(/\.?0+$/, "");
return text;
}
function exchangeLabel() {
if (!elExchange) return "";
const opt = elExchange.options[elExchange.selectedIndex];
if (opt && opt.textContent) return opt.textContent.trim();
return (elExchange.value || "").trim().toUpperCase();
}
function updateExchangeDisplay() {
const label = exchangeLabel();
if (elExLabel) elExLabel.textContent = label;
if (elExBadge) {
elExBadge.textContent = label;
elExBadge.setAttribute("aria-hidden", label ? "false" : "true");
}
}
function updateHeaderLabels(sym, tf) {
if (elSymLabel) elSymLabel.textContent = sym || "—";
if (elTfLabel) elTfLabel.textContent = tf || "—";
updateExchangeDisplay();
}
function fmtAmplitude(bar) {
if (!bar) return "-";
const o = Number(bar.open);
const h = Number(bar.high);
const l = Number(bar.low);
if (!o || o <= 0 || !Number.isFinite(h) || !Number.isFinite(l)) return "-";
return (((h - l) / o) * 100).toFixed(2) + "%";
}
function barRemainMs(tf) {
const period = TF_MS[tf] || TF_MS["1d"];
const now = Date.now();
const barOpen = Math.floor(now / period) * period;
return Math.max(0, barOpen + period - now);
}
function fmtBarCountdown(ms) {
const total = Math.max(0, Math.floor(ms / 1000));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const pad = function (n) {
return n < 10 ? "0" + n : String(n);
};
if (h > 0) return h + ":" + pad(m) + ":" + pad(s);
return pad(m) + ":" + pad(s);
}
function paintOhlcv(bar) {
if (!bar) {
["o", "h", "l", "c", "v", "amp"].forEach(function (k) {
const el = { o: elO, h: elH, l: elL, c: elC, v: elV, amp: elAmp }[k];
if (el) el.textContent = "-";
});
return;
}
if (elO) elO.textContent = fmtPrice(bar.open);
if (elH) elH.textContent = fmtPrice(bar.high);
if (elL) elL.textContent = fmtPrice(bar.low);
if (elC) elC.textContent = fmtPrice(bar.close);
if (elV) elV.textContent = fmtVol(bar.volume);
if (elAmp) elAmp.textContent = fmtAmplitude(bar);
}
function latestCandle() {
return lastCandles.length ? lastCandles[lastCandles.length - 1] : null;
}
function showLatestOhlcv() {
paintOhlcv(latestCandle());
updateCurrentPriceLine();
updatePriceTag();
}
function clearCurrentPriceLine() {
if (currentPriceLine && candleSeries) {
try {
candleSeries.removePriceLine(currentPriceLine);
} catch (e) {}
}
currentPriceLine = null;
}
function updateCurrentPriceLine() {
clearCurrentPriceLine();
if (!candleSeries) return;
const bar = latestCandle();
if (!bar || bar.close == null) return;
const up = Number(bar.close) >= Number(bar.open);
currentPriceLine = candleSeries.createPriceLine({
price: Number(bar.close),
color: up ? "#00ff9d" : "#ff4d6d",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: false,
title: "",
});
}
function tickLiveClock() {
const cd = fmtBarCountdown(barRemainMs(currentTf));
if (elPriceTagTime && elPriceTag && !elPriceTag.classList.contains("hidden")) {
elPriceTagTime.textContent = cd;
}
if (elBarCountdown) elBarCountdown.textContent = "距收盘 " + cd;
}
function updatePriceTag() {
if (!elPriceTag || !candleSeries || !chart) return;
try {
tickLiveClock();
const bar = latestCandle();
if (!bar || bar.close == null) {
elPriceTag.classList.add("hidden");
elPriceTag.setAttribute("aria-hidden", "true");
return;
}
let y = null;
try {
y = candleSeries.priceToCoordinate(Number(bar.close));
} catch (e) {
y = null;
}
const hostH = chartHost.clientHeight || 0;
if (y == null || y < 8 || y > hostH - 8) {
elPriceTag.classList.add("hidden");
elPriceTag.setAttribute("aria-hidden", "true");
return;
}
const up = Number(bar.close) >= Number(bar.open);
elPriceTag.classList.remove("hidden", "is-up", "is-down");
elPriceTag.classList.add(up ? "is-up" : "is-down");
elPriceTag.setAttribute("aria-hidden", "false");
elPriceTag.style.left = "auto";
elPriceTag.style.right = "0";
elPriceTag.style.top = y + "px";
if (elPriceTagValue) elPriceTagValue.textContent = fmtPrice(bar.close);
} catch (e) {
elPriceTag.classList.add("hidden");
elPriceTag.setAttribute("aria-hidden", "true");
}
}
function startPriceTagTimer() {
stopPriceTagTimer();
tickLiveClock();
priceTagTimer = setInterval(tickLiveClock, 1000);
}
function stopPriceTagTimer() {
if (priceTagTimer) clearInterval(priceTagTimer);
priceTagTimer = null;
}
function applyPriceAutoScale() {
if (!chart) return;
chart.priceScale("right").applyOptions({ autoScale: priceAutoScale });
if (elPriceAuto) elPriceAuto.classList.toggle("is-on", priceAutoScale);
}
function indexCandles(candles) {
candleByTime = {};
(candles || []).forEach(function (c) {
if (c && c.time != null) candleByTime[c.time] = c;
});
}
function candleAtTime(t) {
if (t == null) return null;
return candleByTime[t] || null;
}
function buildVolumeData(candles) {
return (candles || []).map(function (c) {
const up = Number(c.close) >= Number(c.open);
return {
time: c.time,
value: Number(c.volume) || 0,
color: up ? "rgba(0, 255, 157, 0.5)" : "rgba(255, 77, 109, 0.5)",
};
});
}
function ensureChart() {
if (chart && candleSeries && volumeSeries) return true;
if (!window.LightweightCharts) {
if (elStatus) {
elStatus.className = "market-status err";
elStatus.textContent = "图表库加载失败";
}
return false;
}
chart = LightweightCharts.createChart(chartHost, {
layout: { background: { color: "#0a1018" }, textColor: "#b8d4e8" },
grid: {
vertLines: { visible: false },
horzLines: { visible: false },
},
rightPriceScale: { borderColor: "#2a4058", autoScale: true },
timeScale: {
borderColor: "#2a4058",
timeVisible: true,
secondsVisible: false,
rightOffset: RIGHT_OFFSET_BARS,
},
crosshair: {
mode: LightweightCharts.CrosshairMode
? LightweightCharts.CrosshairMode.Normal
: 0,
},
});
const candleOpts = {
upColor: "#00ff9d",
downColor: "#ff4d6d",
borderVisible: false,
wickUpColor: "#00ff9d",
wickDownColor: "#ff4d6d",
lastValueVisible: false,
priceLineVisible: false,
priceFormat: tickToPriceFormat(priceTick),
};
if (typeof chart.addCandlestickSeries === "function") {
candleSeries = chart.addCandlestickSeries(candleOpts);
} else if (
typeof chart.addSeries === "function" &&
window.LightweightCharts &&
window.LightweightCharts.CandlestickSeries
) {
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, candleOpts);
}
if (!candleSeries) return false;
const volOpts = {
priceFormat: { type: "volume" },
priceScaleId: "",
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(hi.high),
color: "#ffb84d",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "高点",
})
);
rangeMarkers.push(
candleSeries.createPriceLine({
price: Number(lo.low),
color: "#4cd97f",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "低点",
})
);
}
function readQuery() {
const qs = new URLSearchParams(window.location.search);
const ex = qs.get("exchange_key") || qs.get("exchange") || "";
const sym = qs.get("symbol") || "";
const tf = qs.get("timeframe") || "";
if (ex && elExchange) elExchange.value = ex;
if (sym && elSymbol) elSymbol.value = sym;
if (tf && elTf) elTf.value = tf;
}
function applyDefaults() {
if (elSymbol && !elSymbol.value.trim()) elSymbol.value = "BTC/USDT";
if (elTf && !elTf.value) elTf.value = "1d";
}
function startAutoRefresh() {
stopAutoRefresh();
refreshTimer = setInterval(function () {
const page = document.getElementById("page-market");
if (!page || page.classList.contains("hidden")) return;
loadChart(false, { autoTick: true });
}, AUTO_REFRESH_MS);
}
function stopAutoRefresh() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = null;
}
async function loadMeta() {
const r = await fetch("/api/chart/meta", { credentials: "same-origin" });
chartMeta = await r.json();
if (!elExchange || !chartMeta.exchanges) return;
elExchange.innerHTML = "";
chartMeta.exchanges.forEach(function (ex) {
const opt = document.createElement("option");
opt.value = ex.key || ex.id;
opt.textContent = ex.name || ex.key;
elExchange.appendChild(opt);
});
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;
applyChartPriceFormat();
lastCandles = data.candles;
indexCandles(lastCandles);
candleSeries.setData(lastCandles);
volumeSeries.setData(buildVolumeData(lastCandles));
applyChartRightGap();
if (resetView) {
lastViewKey = vKey;
applyDefaultVisibleRange();
} else if (savedRange) {
chart.timeScale().setVisibleLogicalRange(savedRange);
}
applyPriceAutoScale();
updateVisibleRangeMarkers();
syncPosContextForView(exKey, sym);
showLatestOhlcv();
updateIndicators();
scheduleChartResize();
const limit = data.limit || lastCandles.length;
let hint =
"已加载 " +
data.candles.length +
" 根(目标 " +
limit +
")· 库 " +
(data.from_cache || 0) +
" / 新拉 " +
(data.fetched || 0) +
")· 每 5s 刷新";
if (data.stale && data.stale_message) {
hint += " · 缓存:" + data.stale_message;
}
if (elStatus) {
elStatus.className = data.stale ? "market-status warn" : "market-status";
elStatus.textContent = hint;
}
if (elUpdated) elUpdated.textContent = "数据 " + (data.updated_at || "--");
tickLiveClock();
} catch (e) {
if (myToken !== loadToken) return;
if (elStatus) {
elStatus.className = "market-status err";
elStatus.textContent = String(e.message || e);
}
}
}
function bind() {
if (elRefresh) {
elRefresh.addEventListener("click", function () {
loadChart(true);
});
}
if (elTf) {
elTf.addEventListener("change", function () {
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();
});
});
document.addEventListener("keydown", onMarketKeydown);
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();
}
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();
}
})();