diff --git a/hub_kline_store.py b/hub_kline_store.py index ca99a61..ca540fb 100644 --- a/hub_kline_store.py +++ b/hub_kline_store.py @@ -11,6 +11,7 @@ from typing import Any, Callable, Optional from hub_ohlcv_lib import ( TIMEFRAME_MS, bar_limit_for_timeframe, + chart_fetch_start_ms, format_price_by_tick, last_closed_bar_open_ms, normalize_chart_timeframe, @@ -233,14 +234,15 @@ def resolve_chart_bars( need = bar_limit_for_timeframe(tf) now_ms = int(time.time() * 1000) - start_ms = window_start_ms(tf, need, retention_days(), now_ms) + fetch_start_ms = chart_fetch_start_ms(tf, need, now_ms) + db_read_start_ms = window_start_ms(tf, need, retention_days(), now_ms) last_closed = last_closed_bar_open_ms(tf, now_ms) db_rows: list[dict[str, Any]] = [] if not force_refresh: period_ms = TIMEFRAME_MS[tf] db_rows = load_bars_range( - ex_k, sym, tf, max(0, start_ms - period_ms), now_ms + period_ms, db_path + ex_k, sym, tf, max(0, db_read_start_ms - period_ms), now_ms + period_ms, db_path ) newest_db = db_rows[-1]["open_time_ms"] if db_rows else None @@ -253,7 +255,7 @@ def resolve_chart_bars( remote_err: Optional[str] = None if need_fetch: - since = start_ms + since = fetch_start_ms if db_rows and not force_refresh: since = min(since, int(db_rows[0]["open_time_ms"])) remote = remote_fetch( @@ -265,7 +267,7 @@ def resolve_chart_bars( if remote.get("ok") and remote.get("bars"): fetched = upsert_bars(ex_k, sym, tf, remote["bars"], db_path) price_tick = remote.get("price_tick") - db_rows = load_bars_range(ex_k, sym, tf, start_ms, now_ms, db_path) + db_rows = load_bars_range(ex_k, sym, tf, fetch_start_ms, now_ms, db_path) else: remote_err = remote.get("msg") or remote.get("error") or "实例拉取 K 线失败" if not db_rows: @@ -282,9 +284,6 @@ def resolve_chart_bars( if fetched: from_cache = max(0, len(candles) - min(fetched, len(candles))) - hi = max(candles, key=lambda x: x["high"]) - lo = min(candles, key=lambda x: x["low"]) - return { "ok": True, "symbol": sym, @@ -297,8 +296,6 @@ def resolve_chart_bars( "fetched": fetched, "purged": purged, "price_tick": price_tick, - "range_high": {"time": hi["time"], "price": hi["high"]}, - "range_low": {"time": lo["time"], "price": lo["low"]}, "stale": bool(remote_err), "stale_message": remote_err if remote_err else None, "updated_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), diff --git a/hub_ohlcv_lib.py b/hub_ohlcv_lib.py index 49b8d1c..bfa0b82 100644 --- a/hub_ohlcv_lib.py +++ b/hub_ohlcv_lib.py @@ -40,6 +40,7 @@ def last_closed_bar_open_ms(timeframe: str, now_ms: int | None = None) -> int: def window_start_ms(timeframe: str, need: int, retention_days: int, now_ms: int | None = None) -> int: + """本地库清理/读库窗口:不超过 retention_days。""" now = int(now_ms if now_ms is not None else time.time() * 1000) period = TIMEFRAME_MS[normalize_chart_timeframe(timeframe)] retention_cutoff = now - max(1, int(retention_days)) * 86400000 @@ -47,6 +48,13 @@ def window_start_ms(timeframe: str, need: int, retention_days: int, now_ms: int return max(retention_cutoff, want) +def chart_fetch_start_ms(timeframe: str, need: int, now_ms: int | None = None) -> int: + """行情展示拉取起点:按 need 根回看(日线 500 / 日内 1000),不受 DB 保留天数限制。""" + now = int(now_ms if now_ms is not None else time.time() * 1000) + period = TIMEFRAME_MS[normalize_chart_timeframe(timeframe)] + return max(0, now - max(1, int(need)) * period) + + def price_tick_from_market(exchange, exchange_symbol: str) -> Optional[float]: try: markets = getattr(exchange, "markets", None) or {} diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index f2ddc0e..2ad7f29 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -363,7 +363,7 @@ def api_chart_meta(): def api_chart_ohlcv( exchange_key: str = "", symbol: str = "", - timeframe: str = "5m", + timeframe: str = "1d", refresh: str = "", ): ex = _find_exchange_by_key(exchange_key) diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 28c1690..6de8715 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -1988,6 +1988,14 @@ body.login-page { border: 1px solid var(--border-soft); font-size: 0.78rem; min-width: 200px; + opacity: 0; + visibility: hidden; + transition: opacity 0.12s ease; +} + +.market-ohlcv-overlay.is-active { + opacity: 1; + visibility: visible; } .market-ohlcv-title { diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js index 7cae43a..dcbe92d 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -1,8 +1,7 @@ /** - * 中控行情区:单图 + 周期切换,数据来自 /api/chart/ohlcv(本地库优先)。 + * 中控行情区:K 线 + 底部成交量;十字线时显示 OHLCV;可视区间高低点。 */ (function () { - const TF_ORDER = ["1m", "5m", "15m", "1h", "4h", "1d", "1w"]; const chartHost = document.getElementById("market-chart"); if (!chartHost) return; @@ -12,6 +11,7 @@ const elRefresh = document.getElementById("market-refresh"); const elStatus = document.getElementById("market-status"); const elUpdated = document.getElementById("market-updated"); + const elOverlay = document.querySelector(".market-ohlcv-overlay"); const elO = document.getElementById("mkt-o"); const elH = document.getElementById("mkt-h"); const elL = document.getElementById("mkt-l"); @@ -22,9 +22,11 @@ let chart = null; let candleSeries = null; + let volumeSeries = null; let priceTick = null; let rangeMarkers = []; let lastCandles = []; + let candleByTime = {}; let chartMeta = null; let loadToken = 0; let marketInited = false; @@ -38,6 +40,33 @@ return n.toFixed(2); } + 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 av = Math.abs(n); + let d = 8; + if (av >= 10000) d = 2; + else if (av >= 100) d = 3; + else if (av >= 1) d = 4; + else if (av >= 0.01) d = 6; + let text = n.toFixed(d); + if (text.indexOf(".") >= 0) text = text.replace(/\.?0+$/, ""); + return text; + } + + function setOverlayVisible(on) { + if (!elOverlay) return; + elOverlay.classList.toggle("is-active", !!on); + } + function paintOhlcv(bar) { if (!bar) { ["o", "h", "l", "c", "v"].forEach(function (k) { @@ -46,15 +75,43 @@ }); return; } - if (elO) elO.textContent = bar.open != null ? String(bar.open) : "-"; - if (elH) elH.textContent = bar.high != null ? String(bar.high) : "-"; - if (elL) elL.textContent = bar.low != null ? String(bar.low) : "-"; - if (elC) elC.textContent = bar.close != null ? String(bar.close) : "-"; + if (elO) elO.textContent = fmtPrice(bar.open); + if (elH) elH.textContent = fmtPrice(bar.high); + if (elL) elL.textContent = fmtPrice(bar.low); + if (elC) elC.textContent = fmtPrice(bar.close); if (elV) elV.textContent = fmtVol(bar.volume); } + function hideOhlcvOverlay() { + setOverlayVisible(false); + paintOhlcv(null); + } + + function indexCandles(candles) { + candleByTime = {}; + (candles || []).forEach(function (c) { + if (c && c.time != null) candleByTime[c.time] = c; + }); + } + + function candleAtTime(t) { + if (t == null) return null; + return candleByTime[t] || null; + } + + function buildVolumeData(candles) { + return (candles || []).map(function (c) { + const up = Number(c.close) >= Number(c.open); + return { + time: c.time, + value: Number(c.volume) || 0, + color: up ? "rgba(0, 255, 157, 0.5)" : "rgba(255, 77, 109, 0.5)", + }; + }); + } + function ensureChart() { - if (chart && candleSeries) return true; + if (chart && candleSeries && volumeSeries) return true; if (!window.LightweightCharts) { if (elStatus) { elStatus.className = "market-status err"; @@ -64,40 +121,77 @@ } chart = LightweightCharts.createChart(chartHost, { layout: { background: { color: "#0a1018" }, textColor: "#b8d4e8" }, - grid: { vertLines: { color: "#1a2838" }, horzLines: { color: "#1a2838" } }, + grid: { + vertLines: { visible: false }, + horzLines: { visible: false }, + }, rightPriceScale: { borderColor: "#2a4058" }, timeScale: { borderColor: "#2a4058", timeVisible: true, secondsVisible: false }, - crosshair: { mode: LightweightCharts.CrosshairMode ? LightweightCharts.CrosshairMode.Normal : 0 }, + crosshair: { + mode: LightweightCharts.CrosshairMode + ? LightweightCharts.CrosshairMode.Normal + : 0, + }, }); - const opts = { + + const candleOpts = { upColor: "#00ff9d", downColor: "#ff4d6d", borderVisible: false, wickUpColor: "#00ff9d", wickDownColor: "#ff4d6d", }; + if (typeof chart.addCandlestickSeries === "function") { - candleSeries = chart.addCandlestickSeries(opts); + candleSeries = chart.addCandlestickSeries(candleOpts); } else if ( typeof chart.addSeries === "function" && window.LightweightCharts && window.LightweightCharts.CandlestickSeries ) { - candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, opts); + candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, candleOpts); } if (!candleSeries) return false; + const volOpts = { + priceFormat: { type: "volume" }, + priceScaleId: "volume", + lastValueVisible: false, + }; + if (typeof chart.addHistogramSeries === "function") { + volumeSeries = chart.addHistogramSeries(volOpts); + } else if ( + typeof chart.addSeries === "function" && + window.LightweightCharts && + window.LightweightCharts.HistogramSeries + ) { + volumeSeries = chart.addSeries(window.LightweightCharts.HistogramSeries, volOpts); + } + if (!volumeSeries) return false; + + chart.priceScale("right").applyOptions({ + scaleMargins: { top: 0.06, bottom: 0.28 }, + }); + chart.priceScale("volume").applyOptions({ + scaleMargins: { top: 0.78, bottom: 0 }, + }); + chart.subscribeCrosshairMove(function (param) { - if (!param || !param.time || !param.seriesData) return; - const d = param.seriesData.get(candleSeries); - if (!d) return; - paintOhlcv({ - open: d.open, - high: d.high, - low: d.low, - close: d.close, - volume: d.volume, - }); + if (!param || param.time == null) { + hideOhlcvOverlay(); + return; + } + const bar = candleAtTime(param.time); + if (!bar) { + hideOhlcvOverlay(); + return; + } + setOverlayVisible(true); + paintOhlcv(bar); + }); + + chart.timeScale().subscribeVisibleLogicalRangeChange(function () { + updateVisibleRangeMarkers(); }); window.addEventListener("resize", function () { @@ -105,6 +199,7 @@ chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight }); }); chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight }); + hideOhlcvOverlay(); return true; } @@ -117,35 +212,47 @@ rangeMarkers = []; } - function addRangeMarkers(data) { + function updateVisibleRangeMarkers() { clearMarkers(); - if (!candleSeries || !data) return; - const hi = data.range_high; - const lo = data.range_low; - if (hi && hi.price != null) { - rangeMarkers.push( - candleSeries.createPriceLine({ - price: Number(hi.price), - color: "#ffb84d", - lineWidth: 1, - lineStyle: 2, - axisLabelVisible: true, - title: "区间高", - }) - ); - } - if (lo && lo.price != null) { - rangeMarkers.push( - candleSeries.createPriceLine({ - price: Number(lo.price), - color: "#4cd97f", - lineWidth: 1, - lineStyle: 2, - axisLabelVisible: true, - title: "区间低", - }) - ); + if (!candleSeries || !chart || !lastCandles.length) return; + + const range = chart.timeScale().getVisibleLogicalRange(); + if (!range) return; + + const from = Math.max(0, Math.floor(range.from)); + const to = Math.min(lastCandles.length - 1, Math.ceil(range.to)); + if (to < from) return; + + let hi = null; + let lo = null; + for (let i = from; i <= to; i++) { + const c = lastCandles[i]; + if (!c) continue; + if (!hi || c.high > hi.high) hi = c; + if (!lo || c.low < lo.low) lo = c; } + if (!hi || !lo) return; + + rangeMarkers.push( + candleSeries.createPriceLine({ + price: Number(hi.high), + color: "#ffb84d", + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: true, + title: "可视高", + }) + ); + rangeMarkers.push( + candleSeries.createPriceLine({ + price: Number(lo.low), + color: "#4cd97f", + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: true, + title: "可视低", + }) + ); } function readQuery() { @@ -158,6 +265,11 @@ if (tf && elTf) elTf.value = tf; } + function applyDefaults() { + if (elSymbol && !elSymbol.value.trim()) elSymbol.value = "BTC/USDT"; + if (elTf && !elTf.value) elTf.value = "1d"; + } + async function loadMeta() { const r = await fetch("/api/chart/meta", { credentials: "same-origin" }); chartMeta = await r.json(); @@ -170,13 +282,14 @@ elExchange.appendChild(opt); }); readQuery(); + applyDefaults(); } async function loadChart(force) { if (!ensureChart()) return; const exKey = (elExchange && elExchange.value) || ""; const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; - const tf = (elTf && elTf.value) || "5m"; + const tf = (elTf && elTf.value) || "1d"; if (!exKey || !sym) { if (elStatus) { elStatus.className = "market-status err"; @@ -189,6 +302,7 @@ elStatus.className = "market-status"; elStatus.textContent = "加载中…"; } + hideOhlcvOverlay(); if (elSymLabel) elSymLabel.textContent = sym; if (elTfLabel) elTfLabel.textContent = tf; @@ -212,23 +326,19 @@ priceTick = data.price_tick; lastCandles = data.candles; - candleSeries.setData(data.candles); + indexCandles(lastCandles); + candleSeries.setData(lastCandles); + volumeSeries.setData(buildVolumeData(lastCandles)); chart.timeScale().fitContent(); - addRangeMarkers(data); - - const ohlcv = data.ohlcv || {}; - paintOhlcv({ - open: ohlcv.open, - high: ohlcv.high, - low: ohlcv.low, - close: ohlcv.close, - volume: ohlcv.volume, - }); + updateVisibleRangeMarkers(); + const limit = data.limit || lastCandles.length; let hint = "已加载 " + data.candles.length + - " 根(库 " + + " 根(目标 " + + limit + + ")· 库 " + (data.from_cache || 0) + " / 新拉 " + (data.fetched || 0) + @@ -274,9 +384,11 @@ }); } const btnLoad = document.getElementById("market-load"); - if (btnLoad) btnLoad.addEventListener("click", function () { - loadChart(false); - }); + if (btnLoad) { + btnLoad.addEventListener("click", function () { + loadChart(false); + }); + } } window.hubMarketChart = { @@ -293,7 +405,10 @@ }, }; - if (document.getElementById("page-market") && !document.getElementById("page-market").classList.contains("hidden")) { + if ( + document.getElementById("page-market") && + !document.getElementById("page-market").classList.contains("hidden") + ) { window.hubMarketChart.init(); } })(); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 5f82380..23bb882 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -8,7 +8,7 @@ - + @@ -76,17 +76,17 @@ @@ -100,7 +100,7 @@
- 5m + 1d
@@ -179,7 +179,7 @@
- - + + diff --git a/tests/test_hub_kline_store.py b/tests/test_hub_kline_store.py index 1120c45..3a3e460 100644 --- a/tests/test_hub_kline_store.py +++ b/tests/test_hub_kline_store.py @@ -15,7 +15,7 @@ from hub_kline_store import ( upsert_bars, window_start_ms, ) -from hub_ohlcv_lib import TIMEFRAME_MS +from hub_ohlcv_lib import TIMEFRAME_MS, chart_fetch_start_ms, window_start_ms class TestHubKlineStore(unittest.TestCase): @@ -28,7 +28,18 @@ class TestHubKlineStore(unittest.TestCase): def test_bar_limits(self): self.assertEqual(bar_limit_for_timeframe("5m"), 1000) + self.assertEqual(bar_limit_for_timeframe("1h"), 1000) self.assertEqual(bar_limit_for_timeframe("1d"), 500) + self.assertEqual(bar_limit_for_timeframe("1w"), 500) + + def test_chart_fetch_window_exceeds_retention(self): + import time + + now = int(time.time() * 1000) + need = bar_limit_for_timeframe("1d") + fetch_start = chart_fetch_start_ms("1d", need, now) + db_start = window_start_ms("1d", need, retention_days(), now) + self.assertLess(fetch_start, db_start) def test_purge_retention(self): import time