diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index ad56819..ac155b7 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -2084,6 +2084,42 @@ body.login-page { display: none; } +.market-fs-toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 8px 12px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-soft); +} + +.market-fs-toolbar.hidden { + display: none; +} + +.market-fs-field span { + font-size: 0.68rem; + color: var(--muted); +} + +.market-fs-field select, +.market-fs-field input { + font-size: 0.78rem; + min-width: 100px; +} + +.market-div-legend { + margin-top: 4px; + font-size: 0.72rem; + color: #ffb84d; + line-height: 1.4; +} + +.market-div-legend.hidden { + display: none; +} + .market-ohlcv-bar { flex: 0 0 auto; padding: 8px 12px; diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js index 37a272a..42b8a1a 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -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 = { diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 4d38afb..f790a4b 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -8,7 +8,7 @@ - + @@ -123,6 +123,30 @@ 振幅 + +