From 84ac9134db549fc4db664fd72dd3d70e713676cb Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 2 Jun 2026 14:24:36 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=8C=E6=83=85=E5=8C=BA=EF=BC=9AK=E7=BA=BF?= =?UTF-8?q?=E5=85=A8=E5=B1=8F=E3=80=81=E5=8F=AF=E9=80=89=E6=8A=80=E6=9C=AF?= =?UTF-8?q?=E6=8C=87=E6=A0=87=E4=B8=8E=E4=BA=A4=E6=98=93=E6=89=80=E4=BB=B7?= =?UTF-8?q?=E6=A0=BC=E7=B2=BE=E5=BA=A6=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- hub_kline_store.py | 13 + hub_ohlcv_lib.py | 67 +++-- manual_trading_hub/static/app.css | 102 +++++++ manual_trading_hub/static/chart.js | 383 +++++++++++++++++++++++++-- manual_trading_hub/static/index.html | 18 +- tests/test_hub_ohlcv_lib.py | 42 +++ 6 files changed, 592 insertions(+), 33 deletions(-) diff --git a/hub_kline_store.py b/hub_kline_store.py index dcfab01..ba02981 100644 --- a/hub_kline_store.py +++ b/hub_kline_store.py @@ -276,6 +276,19 @@ def resolve_chart_bars( if len(db_rows) > need: db_rows = db_rows[-need:] + if price_tick is None: + try: + tick_probe = remote_fetch( + symbol=sym, + timeframe=tf, + since_ms=None, + limit=3, + ) + if tick_probe.get("ok"): + price_tick = tick_probe.get("price_tick") + except Exception: + pass + candles = _to_chart_candles(db_rows) if not candles: return {"ok": False, "msg": remote_err or "无 K 线数据", "purged": purged} diff --git a/hub_ohlcv_lib.py b/hub_ohlcv_lib.py index b8eccc7..fc37857 100644 --- a/hub_ohlcv_lib.py +++ b/hub_ohlcv_lib.py @@ -56,17 +56,17 @@ def chart_fetch_start_ms(timeframe: str, need: int, now_ms: int | None = None) - def price_tick_from_market(exchange, exchange_symbol: str) -> Optional[float]: + """最小价格变动单位(与交易所 tick / price_to_precision 一致)。""" try: - markets = getattr(exchange, "markets", None) or {} - m = markets.get(exchange_symbol) or {} - prec = m.get("precision") or {} - p = prec.get("price") - if p is not None: - p = float(p) - if p > 0: - return p - info = m.get("info") or {} - for key in ("tickSize", "price_increment", "order_price_round"): + if not getattr(exchange, "markets", None): + exchange.load_markets() + market = exchange.market(exchange_symbol) + except Exception: + return None + + info = market.get("info") or {} + if isinstance(info, dict): + for key in ("tickSize", "tickSz", "price_increment", "order_price_round", "quote_increment"): if info.get(key) not in (None, ""): try: v = float(info[key]) @@ -74,11 +74,52 @@ def price_tick_from_market(exchange, exchange_symbol: str) -> Optional[float]: return v except (TypeError, ValueError): pass + + limits = market.get("limits") or {} + price_limits = limits.get("price") or {} + if price_limits.get("min") not in (None, ""): + try: + v = float(price_limits["min"]) + if v > 0: + return v + except (TypeError, ValueError): + pass + + try: + sample = exchange.price_to_precision(exchange_symbol, 12345.678901234) + s = str(sample).strip() + if "." in s: + frac = s.split(".", 1)[1] + if frac: + return 10 ** (-len(frac)) + return 1.0 except Exception: pass + + prec = (market.get("precision") or {}).get("price") + if prec is not None: + try: + p = float(prec) + if p >= 1 and abs(p - round(p)) < 1e-9 and p <= 12: + return 10 ** (-int(p)) + if 0 < p < 1: + return p + except (TypeError, ValueError): + pass return None +def _decimals_from_tick(tick: float) -> int: + if tick >= 1: + return 0 + s = f"{tick:.12f}".rstrip("0") + if "." in s: + frac = s.split(".", 1)[1] + if frac: + return min(12, len(frac)) + return max(0, min(12, int(round(-math.log10(tick))))) + + def format_price_by_tick(value: Any, tick: Optional[float]) -> str: if value in (None, ""): return "-" @@ -89,11 +130,7 @@ def format_price_by_tick(value: Any, tick: Optional[float]) -> str: if v == 0: return "0" if tick and tick > 0: - decimals = max(0, min(12, int(round(-math.log10(tick))) if tick < 1 else 0)) - if tick >= 1: - decimals = 0 - text = f"{v:.{decimals}f}" - return text.rstrip("0").rstrip(".") if "." in text else text + return f"{v:.{_decimals_from_tick(float(tick))}f}" av = abs(v) if av >= 10000: d = 2 diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index a3dd809..ad56819 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -509,6 +509,10 @@ body.hub-instance-frame-open { overflow: hidden; } +body.market-chart-fs-open { + overflow: hidden; +} + .instance-frame-shell { position: fixed; inset: 0; @@ -1982,6 +1986,104 @@ body.login-page { min-height: 440px; } +.market-chart-wrap.is-fullscreen { + position: fixed; + inset: 0; + z-index: 8500; + width: 100vw; + height: 100vh !important; + max-height: none; + min-height: 0; + border-radius: 0; + border: none; +} + +.market-chart-wrap.is-fullscreen.has-pos-panel { + height: 100vh !important; +} + +.market-chart-actions { + margin-left: auto; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; +} + +.market-ind-menu { + position: relative; + font-size: 0.72rem; +} + +.market-ind-menu summary { + cursor: pointer; + list-style: none; + padding: 2px 10px; + border-radius: 4px; + border: 1px solid var(--border-soft); + color: var(--muted); + user-select: none; +} + +.market-ind-menu summary::-webkit-details-marker { + display: none; +} + +.market-ind-menu[open] summary { + color: var(--accent); + border-color: rgba(0, 255, 157, 0.35); +} + +.market-ind-options { + position: absolute; + right: 0; + top: calc(100% + 4px); + z-index: 20; + min-width: 168px; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--border-soft); + background: rgba(10, 16, 26, 0.98); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + gap: 6px; +} + +.market-ind-opt { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + color: var(--text); + white-space: nowrap; +} + +.market-ind-opt input { + accent-color: var(--accent); +} + +.market-fs-btn, +.market-fs-exit { + font-size: 0.72rem; + padding: 2px 10px; +} + +.market-fs-exit { + position: absolute; + top: 8px; + left: 8px; + z-index: 12; +} + +.market-chart-wrap.is-fullscreen .market-fs-exit:not(.hidden) { + display: inline-flex !important; +} + +.market-chart-wrap.is-fullscreen .market-fs-btn { + 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 ec0779f..37a272a 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -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 = { diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 560e8a0..4d38afb 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -8,7 +8,7 @@ - + @@ -97,12 +97,23 @@

-
+
1d +
+
+ 技术指标 +
+ + + +
+
+ +
@@ -125,6 +136,7 @@
+