diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 4db75c5..f9c7adb 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -2051,10 +2051,53 @@ body.login-page { margin-right: 4px; } +.market-price-tag { + position: absolute; + right: 52px; + z-index: 6; + pointer-events: none; + padding: 4px 8px; + border-radius: 4px; + font-family: var(--font); + font-size: 0.75rem; + font-weight: 600; + line-height: 1.2; + text-align: center; + transform: translateY(-50%); + min-width: 76px; + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.35); +} + +.market-price-tag.hidden { + display: none; +} + +.market-price-tag.is-up { + background: #00ff9d; + color: #0a1018; +} + +.market-price-tag.is-down { + background: #ff4d6d; + color: #fff; +} + +.market-price-tag-value { + font-variant-numeric: tabular-nums; +} + +.market-price-tag-time { + margin-top: 2px; + font-size: 0.68rem; + font-weight: 500; + font-variant-numeric: tabular-nums; + opacity: 0.95; +} + .market-price-auto { position: absolute; - right: 6px; - top: 42%; + right: 8px; + bottom: 10px; z-index: 5; padding: 4px 8px; font-size: 0.68rem; diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index c52d112..488a79f 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -262,8 +262,9 @@ if (page === "settings") loadSettingsUI(); if (page === "market" && window.hubMarketChart) { window.hubMarketChart.init(); - } else if (window.hubMarketChart && window.hubMarketChart.stopAutoRefresh) { - window.hubMarketChart.stopAutoRefresh(); + } else if (window.hubMarketChart) { + if (window.hubMarketChart.stopAutoRefresh) window.hubMarketChart.stopAutoRefresh(); + if (window.hubMarketChart.stopPriceTagTimer) window.hubMarketChart.stopPriceTagTimer(); } } diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js index b079506..36a8125 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -3,6 +3,17 @@ */ (function () { const AUTO_REFRESH_MS = 60000; + const DEFAULT_VISIBLE_BARS = 200; + const RIGHT_OFFSET_BARS = 12; + const TF_MS = { + "1m": 60_000, + "5m": 5 * 60_000, + "15m": 15 * 60_000, + "1h": 60 * 60_000, + "4h": 4 * 60 * 60_000, + "1d": 24 * 60 * 60_000, + "1w": 7 * 24 * 60 * 60_000, + }; const chartHost = document.getElementById("market-chart"); if (!chartHost) return; @@ -17,6 +28,10 @@ const elL = document.getElementById("mkt-l"); const elC = document.getElementById("mkt-c"); const elV = document.getElementById("mkt-v"); + const elAmp = document.getElementById("mkt-amp"); + const elPriceTag = document.getElementById("market-price-tag"); + const elPriceTagValue = document.getElementById("market-price-tag-value"); + const elPriceTagTime = document.getElementById("market-price-tag-time"); const elExLabel = document.getElementById("mkt-exchange-label"); const elExBadge = document.getElementById("market-exchange-badge"); const elSymLabel = document.getElementById("mkt-symbol-label"); @@ -35,6 +50,9 @@ let loadToken = 0; let marketInited = false; let refreshTimer = null; + let lastViewKey = ""; + let currentTf = "1d"; + let priceTagTimer = null; function fmtVol(v) { if (v == null || Number.isNaN(Number(v))) return "-"; @@ -89,10 +107,38 @@ updateExchangeDisplay(); } + function fmtAmplitude(bar) { + if (!bar) return "-"; + const o = Number(bar.open); + const h = Number(bar.high); + const l = Number(bar.low); + if (!o || o <= 0 || !Number.isFinite(h) || !Number.isFinite(l)) return "-"; + return (((h - l) / o) * 100).toFixed(2) + "%"; + } + + function barRemainMs(tf) { + const period = TF_MS[tf] || TF_MS["1d"]; + const now = Date.now(); + const barOpen = Math.floor(now / period) * period; + return Math.max(0, barOpen + period - now); + } + + function fmtBarCountdown(ms) { + const total = Math.max(0, Math.floor(ms / 1000)); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + const pad = function (n) { + return n < 10 ? "0" + n : String(n); + }; + if (h > 0) return h + ":" + pad(m) + ":" + pad(s); + return pad(m) + ":" + pad(s); + } + function paintOhlcv(bar) { if (!bar) { - ["o", "h", "l", "c", "v"].forEach(function (k) { - const el = { o: elO, h: elH, l: elL, c: elC, v: elV }[k]; + ["o", "h", "l", "c", "v", "amp"].forEach(function (k) { + const el = { o: elO, h: elH, l: elL, c: elC, v: elV, amp: elAmp }[k]; if (el) el.textContent = "-"; }); return; @@ -102,6 +148,7 @@ if (elL) elL.textContent = fmtPrice(bar.low); if (elC) elC.textContent = fmtPrice(bar.close); if (elV) elV.textContent = fmtVol(bar.volume); + if (elAmp) elAmp.textContent = fmtAmplitude(bar); } function latestCandle() { @@ -110,6 +157,46 @@ function showLatestOhlcv() { paintOhlcv(latestCandle()); + updatePriceTag(); + } + + function updatePriceTag() { + if (!elPriceTag || !candleSeries || !chart) return; + const bar = latestCandle(); + if (!bar || bar.close == null) { + elPriceTag.classList.add("hidden"); + elPriceTag.setAttribute("aria-hidden", "true"); + return; + } + let y = null; + try { + y = candleSeries.priceToCoordinate(Number(bar.close)); + } catch (e) { + y = null; + } + const hostH = chartHost.clientHeight || 0; + if (y == null || y < 8 || y > hostH - 8) { + elPriceTag.classList.add("hidden"); + elPriceTag.setAttribute("aria-hidden", "true"); + return; + } + const up = Number(bar.close) >= Number(bar.open); + elPriceTag.classList.remove("hidden", "is-up", "is-down"); + elPriceTag.classList.add(up ? "is-up" : "is-down"); + elPriceTag.setAttribute("aria-hidden", "false"); + elPriceTag.style.top = y + "px"; + if (elPriceTagValue) elPriceTagValue.textContent = fmtPrice(bar.close); + if (elPriceTagTime) elPriceTagTime.textContent = fmtBarCountdown(barRemainMs(currentTf)); + } + + function startPriceTagTimer() { + stopPriceTagTimer(); + priceTagTimer = setInterval(updatePriceTag, 1000); + } + + function stopPriceTagTimer() { + if (priceTagTimer) clearInterval(priceTagTimer); + priceTagTimer = null; } function applyPriceAutoScale() { @@ -157,7 +244,12 @@ horzLines: { visible: false }, }, rightPriceScale: { borderColor: "#2a4058", autoScale: true }, - timeScale: { borderColor: "#2a4058", timeVisible: true, secondsVisible: false }, + timeScale: { + borderColor: "#2a4058", + timeVisible: true, + secondsVisible: false, + rightOffset: RIGHT_OFFSET_BARS, + }, crosshair: { mode: LightweightCharts.CrosshairMode ? LightweightCharts.CrosshairMode.Normal @@ -171,6 +263,8 @@ borderVisible: false, wickUpColor: "#00ff9d", wickDownColor: "#ff4d6d", + lastValueVisible: false, + priceLineVisible: false, }; if (typeof chart.addCandlestickSeries === "function") { @@ -223,11 +317,13 @@ chart.timeScale().subscribeVisibleLogicalRangeChange(function () { updateVisibleRangeMarkers(); + updatePriceTag(); }); window.addEventListener("resize", function () { if (!chart) return; chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight }); + updatePriceTag(); }); chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight }); return true; @@ -242,6 +338,20 @@ rangeMarkers = []; } + function viewKey(exKey, sym, tf) { + return (exKey || "") + "|" + (sym || "") + "|" + (tf || ""); + } + + function applyDefaultVisibleRange() { + if (!chart || !lastCandles.length) return; + const n = lastCandles.length; + const visible = Math.min(DEFAULT_VISIBLE_BARS, n); + const from = Math.max(0, n - visible); + const to = n - 1; + chart.timeScale().applyOptions({ rightOffset: RIGHT_OFFSET_BARS }); + chart.timeScale().setVisibleLogicalRange({ from: from, to: to }); + } + function updateVisibleRangeMarkers() { clearMarkers(); if (!candleSeries || !chart || !lastCandles.length) return; @@ -305,7 +415,7 @@ refreshTimer = setInterval(function () { const page = document.getElementById("page-market"); if (!page || page.classList.contains("hidden")) return; - loadChart(false); + loadChart(false, { autoTick: true }); }, AUTO_REFRESH_MS); } @@ -330,11 +440,14 @@ updateExchangeDisplay(); } - async function loadChart(force) { + async function loadChart(force, options) { + options = options || {}; + const autoTick = !!options.autoTick; if (!ensureChart()) return; const exKey = (elExchange && elExchange.value) || ""; const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; const tf = (elTf && elTf.value) || "1d"; + currentTf = tf; if (!exKey || !sym) { if (elStatus) { elStatus.className = "market-status err"; @@ -343,7 +456,13 @@ return; } const myToken = ++loadToken; - if (elStatus) { + const vKey = viewKey(exKey, sym, tf); + const resetView = !!force || !autoTick || vKey !== lastViewKey; + let savedRange = null; + if (!resetView && chart) { + savedRange = chart.timeScale().getVisibleLogicalRange(); + } + if (!autoTick && elStatus) { elStatus.className = "market-status"; elStatus.textContent = "加载中…"; } @@ -372,7 +491,12 @@ indexCandles(lastCandles); candleSeries.setData(lastCandles); volumeSeries.setData(buildVolumeData(lastCandles)); - chart.timeScale().fitContent(); + if (resetView) { + lastViewKey = vKey; + applyDefaultVisibleRange(); + } else if (savedRange) { + chart.timeScale().setVisibleLogicalRange(savedRange); + } applyPriceAutoScale(); updateVisibleRangeMarkers(); showLatestOhlcv(); @@ -449,6 +573,7 @@ bind(); } startAutoRefresh(); + startPriceTagTimer(); await loadChart(false); }, reload: function (force) { @@ -456,6 +581,7 @@ }, startAutoRefresh: startAutoRefresh, stopAutoRefresh: stopAutoRefresh, + stopPriceTagTimer: stopPriceTagTimer, }; if ( diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 9437213..82847aa 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -8,7 +8,7 @@ - +
@@ -110,8 +110,13 @@ 低— 收— 量— + 振幅— + @@ -182,7 +187,7 @@ - - + +