行情区:MACD/RSI置于成交量下方、背离标注与全屏切换品种周期
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,6 +8,11 @@
|
||||
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,
|
||||
@@ -55,6 +60,12 @@
|
||||
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;
|
||||
@@ -69,7 +80,10 @@
|
||||
macdSignal: null,
|
||||
macdHist: null,
|
||||
rsi: null,
|
||||
rsi30: null,
|
||||
rsi70: null,
|
||||
};
|
||||
let divergenceMarkers = [];
|
||||
|
||||
let chart = null;
|
||||
let candleSeries = null;
|
||||
@@ -306,7 +320,17 @@
|
||||
|
||||
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]);
|
||||
@@ -316,57 +340,216 @@
|
||||
});
|
||||
}
|
||||
|
||||
function applyScaleLayout() {
|
||||
if (!chart) return;
|
||||
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;
|
||||
let candleBottom = CANDLE_SCALE_BOTTOM;
|
||||
let volTop = VOLUME_SCALE_TOP;
|
||||
const volBottom = VOLUME_SCALE_BOTTOM;
|
||||
if (!rsiOn && !macdOn) {
|
||||
return {
|
||||
candleBottom: CANDLE_SCALE_BOTTOM,
|
||||
volTop: VOLUME_SCALE_TOP,
|
||||
volBottom: VOLUME_SCALE_BOTTOM,
|
||||
macdTop: null,
|
||||
macdBottom: null,
|
||||
rsiTop: null,
|
||||
rsiBottom: null,
|
||||
rsiOn: false,
|
||||
macdOn: false,
|
||||
};
|
||||
}
|
||||
let stackH = 0;
|
||||
if (rsiOn) stackH += PANEL_RSI_H;
|
||||
if (macdOn) stackH += PANEL_MACD_H;
|
||||
const volBottom = VOLUME_SCALE_BOTTOM + stackH;
|
||||
const volTop = volBottom + PANEL_VOL_H;
|
||||
let rsiTop = null;
|
||||
let rsiBottom = null;
|
||||
let macdTop = null;
|
||||
let macdBottom = null;
|
||||
let cursor = VOLUME_SCALE_BOTTOM;
|
||||
if (rsiOn) {
|
||||
rsiBottom = cursor;
|
||||
rsiTop = cursor + PANEL_RSI_H;
|
||||
cursor = rsiTop;
|
||||
}
|
||||
if (macdOn) {
|
||||
macdBottom = cursor;
|
||||
macdTop = cursor + PANEL_MACD_H;
|
||||
cursor = macdTop;
|
||||
}
|
||||
const candleBottom = Math.max(CANDLE_SCALE_BOTTOM, volTop + 0.02);
|
||||
return {
|
||||
candleBottom,
|
||||
volTop,
|
||||
volBottom,
|
||||
macdTop,
|
||||
macdBottom,
|
||||
rsiTop,
|
||||
rsiBottom,
|
||||
rsiOn,
|
||||
macdOn,
|
||||
};
|
||||
}
|
||||
|
||||
if (rsiOn && macdOn) {
|
||||
candleBottom = 0.52;
|
||||
volTop = 0.84;
|
||||
chart.priceScale("rsi").applyOptions({
|
||||
scaleMargins: { top: 0.66, bottom: 0.18 },
|
||||
borderColor: "#2a4058",
|
||||
autoScale: true,
|
||||
});
|
||||
function applyScaleLayout() {
|
||||
if (!chart) return;
|
||||
const L = panelLayout();
|
||||
chart.priceScale("right").applyOptions({
|
||||
scaleMargins: { top: 0.06, bottom: L.candleBottom },
|
||||
});
|
||||
chart.priceScale("volume").applyOptions({
|
||||
scaleMargins: { top: L.volTop, bottom: L.volBottom },
|
||||
});
|
||||
if (L.macdOn && L.macdTop != null) {
|
||||
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 },
|
||||
scaleMargins: { top: L.macdTop, bottom: L.macdBottom },
|
||||
borderColor: "#2a4058",
|
||||
autoScale: true,
|
||||
});
|
||||
}
|
||||
if (L.rsiOn && L.rsiTop != null) {
|
||||
chart.priceScale("rsi").applyOptions({
|
||||
scaleMargins: { top: L.rsiTop, bottom: L.rsiBottom },
|
||||
borderColor: "#2a4058",
|
||||
autoScale: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
chart.priceScale("right").applyOptions({
|
||||
scaleMargins: { top: 0.06, bottom: candleBottom },
|
||||
});
|
||||
chart.priceScale("volume").applyOptions({
|
||||
scaleMargins: { top: volTop, bottom: volBottom },
|
||||
});
|
||||
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 = [];
|
||||
applyScaleLayout();
|
||||
|
||||
if (indicatorState.ema) {
|
||||
@@ -387,8 +570,12 @@
|
||||
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",
|
||||
@@ -403,30 +590,84 @@
|
||||
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",
|
||||
priceScaleId: "rsi",
|
||||
priceFormat: { type: "price", precision: 1, minMove: 0.1 },
|
||||
});
|
||||
if (indSeries.rsi) indSeries.rsi.setData(buildRsiSeries(lastCandles, 14));
|
||||
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();
|
||||
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 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();
|
||||
}
|
||||
|
||||
@@ -1013,6 +1254,7 @@
|
||||
opt.textContent = ex.name || ex.key;
|
||||
elExchange.appendChild(opt);
|
||||
});
|
||||
populateFsExchangeOptions();
|
||||
readQuery();
|
||||
applyDefaults();
|
||||
updateExchangeDisplay();
|
||||
@@ -1123,12 +1365,14 @@
|
||||
elTf.addEventListener("change", function () {
|
||||
currentTf = (elTf && elTf.value) || "1d";
|
||||
tickLiveClock();
|
||||
syncFsToolbarFromMain();
|
||||
loadChart(false);
|
||||
});
|
||||
}
|
||||
if (elExchange) {
|
||||
elExchange.addEventListener("change", function () {
|
||||
updateExchangeDisplay();
|
||||
syncFsToolbarFromMain();
|
||||
loadChart(false);
|
||||
});
|
||||
}
|
||||
@@ -1173,6 +1417,34 @@
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape" && chartFullscreen) setChartFullscreen(false);
|
||||
});
|
||||
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 = {
|
||||
|
||||
Reference in New Issue
Block a user