行情区:K线全屏、可选技术指标与交易所价格精度对齐
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user