行情区:K线全屏、可选技术指标与交易所价格精度对齐

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-02 14:24:36 +08:00
parent 01d26e9833
commit 84ac9134db
6 changed files with 592 additions and 33 deletions
+368 -15
View File
@@ -49,8 +49,27 @@
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 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,
};
let chart = null;
let candleSeries = null;
@@ -133,13 +152,288 @@
}
function syncChartWrapLayout() {
const wrap = chartHost && chartHost.closest(".market-chart-wrap");
if (wrap && elPosPanel) {
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;
Object.keys(indSeries).forEach(function (k) {
if (indSeries[k]) {
try {
chart.removeSeries(indSeries[k]);
} catch (e) {}
indSeries[k] = null;
}
});
}
function applyScaleLayout() {
if (!chart) return;
const rsiOn = indicatorState.rsi;
const macdOn = indicatorState.macd;
let candleBottom = CANDLE_SCALE_BOTTOM;
let volTop = VOLUME_SCALE_TOP;
const volBottom = VOLUME_SCALE_BOTTOM;
if (rsiOn && macdOn) {
candleBottom = 0.52;
volTop = 0.84;
chart.priceScale("rsi").applyOptions({
scaleMargins: { top: 0.66, bottom: 0.18 },
borderColor: "#2a4058",
autoScale: true,
});
chart.priceScale("macd").applyOptions({
scaleMargins: { top: 0.48, bottom: 0.34 },
borderColor: "#2a4058",
autoScale: true,
});
} else if (rsiOn) {
candleBottom = 0.4;
volTop = 0.78;
chart.priceScale("rsi").applyOptions({
scaleMargins: { top: 0.62, bottom: 0.22 },
borderColor: "#2a4058",
autoScale: true,
});
} else if (macdOn) {
candleBottom = 0.42;
volTop = 0.78;
chart.priceScale("macd").applyOptions({
scaleMargins: { top: 0.44, bottom: 0.3 },
borderColor: "#2a4058",
autoScale: true,
});
}
chart.priceScale("right").applyOptions({
scaleMargins: { top: 0.06, bottom: candleBottom },
});
chart.priceScale("volume").applyOptions({
scaleMargins: { top: volTop, bottom: volBottom },
});
}
function updateIndicators() {
if (!chart || !lastCandles.length) return;
readIndicatorState();
clearIndicatorSeries();
applyScaleLayout();
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));
}
if (indicatorState.macd) {
const macd = buildMacdData(lastCandles);
indSeries.macdLine = createLineSeries({
color: "#5b9cf5",
title: "MACD",
priceScaleId: "macd",
});
indSeries.macdSignal = createLineSeries({
color: "#ffb84d",
title: "Signal",
priceScaleId: "macd",
});
indSeries.macdHist = createHistSeries({ priceScaleId: "macd" });
if (indSeries.macdLine) indSeries.macdLine.setData(macd.macdLine);
if (indSeries.macdSignal) indSeries.macdSignal.setData(macd.signalLine);
if (indSeries.macdHist) indSeries.macdHist.setData(macd.histData);
}
if (indicatorState.rsi) {
indSeries.rsi = createLineSeries({
color: "#8fc8ff",
title: "RSI",
priceScaleId: "rsi",
});
if (indSeries.rsi) indSeries.rsi.setData(buildRsiSeries(lastCandles, 14));
}
scheduleChartResize();
}
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 (elFsBtn) elFsBtn.textContent = chartFullscreen ? "退出全屏" : "全屏";
if (elFsExit) {
if (chartFullscreen) elFsExit.classList.remove("hidden");
else elFsExit.classList.add("hidden");
}
scheduleChartResize();
}
function toggleChartFullscreen() {
setChartFullscreen(!chartFullscreen);
}
function renderPosPanel(ctx) {
if (!elPosPanel || !ctx) {
clearPosPanel();
@@ -260,17 +554,55 @@
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 tick = priceTick;
if (tick && tick > 0) {
let decimals = tick >= 1 ? 0 : Math.max(0, Math.min(12, Math.round(-Math.log10(tick))));
let text = n.toFixed(decimals);
if (text.indexOf(".") >= 0) text = text.replace(/\.?0+$/, "");
return text;
}
const dec = decimalsFromTick(priceTick);
if (dec != null) return n.toFixed(dec);
const av = Math.abs(n);
let d = 8;
if (av >= 10000) d = 2;
@@ -504,6 +836,7 @@
wickDownColor: "#ff4d6d",
lastValueVisible: false,
priceLineVisible: false,
priceFormat: tickToPriceFormat(priceTick),
};
if (typeof chart.addCandlestickSeries === "function") {
@@ -533,12 +866,11 @@
}
if (!volumeSeries) return false;
chart.priceScale("right").applyOptions({
scaleMargins: { top: 0.06, bottom: CANDLE_SCALE_BOTTOM },
});
chart.priceScale("volume").applyOptions({
scaleMargins: { top: VOLUME_SCALE_TOP, bottom: VOLUME_SCALE_BOTTOM },
});
chart.priceScale("macd");
chart.priceScale("rsi");
applyScaleLayout();
applyChartPriceFormat();
applyPriceAutoScale();
chart.subscribeCrosshairMove(function (param) {
@@ -733,6 +1065,7 @@
}
priceTick = data.price_tick;
applyChartPriceFormat();
lastCandles = data.candles;
indexCandles(lastCandles);
candleSeries.setData(lastCandles);
@@ -748,6 +1081,7 @@
updateVisibleRangeMarkers();
syncPosContextForView(exKey, sym);
showLatestOhlcv();
updateIndicators();
scheduleChartResize();
const limit = data.limit || lastCandles.length;
@@ -820,6 +1154,25 @@
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", function (e) {
if (e.key === "Escape" && chartFullscreen) setChartFullscreen(false);
});
}
window.hubMarketChart = {