From 2d8f65bf1d9fc64ce048d6795bfdf4acbfdc6ab2 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 3 Jun 2026 17:36:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(hub):=20=E4=BF=AE=E5=A4=8D=E5=B8=81?= =?UTF-8?q?=E5=AE=89=E8=A1=8C=E6=83=85=E5=8C=BA=20unexpected=20base=20?= =?UTF-8?q?=E4=BB=B7=E6=A0=BC=E7=B2=BE=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalize_price_tick 对齐 tick 为 10^-n;chart.js 使用整数 base 并在 applyOptions 失败时回退安全 priceFormat。 Co-authored-by: Cursor --- hub_ohlcv_lib.py | 22 +++++++- manual_trading_hub/static/chart.js | 80 ++++++++++++++++++------------ tests/test_hub_ohlcv_lib.py | 6 +++ 3 files changed, 75 insertions(+), 33 deletions(-) diff --git a/hub_ohlcv_lib.py b/hub_ohlcv_lib.py index 19cf201..7a4b756 100644 --- a/hub_ohlcv_lib.py +++ b/hub_ohlcv_lib.py @@ -139,6 +139,26 @@ def price_tick_from_market(exchange, exchange_symbol: str) -> Optional[float]: return None +def normalize_price_tick(tick: Optional[float]) -> Optional[float]: + """将 tick 对齐为 10^-n,避免浮点噪声导致前端 lightweight-charts unexpected base。""" + if tick is None: + return None + try: + t = float(tick) + except (TypeError, ValueError): + return None + if t <= 0: + return None + if t >= 1: + return t + try: + exp = int(round(-math.log10(t))) + except (ValueError, OverflowError): + return None + exp = max(0, min(12, exp)) + return 10 ** (-exp) + + def _decimals_from_tick(tick: float) -> int: if tick >= 1: return 0 @@ -413,7 +433,7 @@ def fetch_ohlcv_for_hub( if not merged: return {"ok": False, "msg": "交易所未返回 K 线"} - tick = price_tick_from_market(exchange, ex_sym) + tick = normalize_price_tick(price_tick_from_market(exchange, ex_sym)) return { "ok": True, diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js index fa97ab8..78f5221 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -1009,35 +1009,49 @@ return Math.max(0, Math.min(12, Math.round(-Math.log10(minMove)))); } + const SAFE_PRICE_FORMAT = { type: "price", precision: 4, minMove: 0.0001, base: 10000 }; + function tickToPriceFormat(tick) { - const minMove = - tick != null && Number.isFinite(Number(tick)) && Number(tick) > 0 ? Number(tick) : 0.01; - const precision = decimalsFromTick(minMove) ?? 2; - const fmt = { type: "price", precision: precision, minMove: minMove }; - // 避免 minMove 浮点导致 lightweight-charts 报 "unexpected base" - if (minMove > 0 && minMove < 1) { - const inv = 1 / minMove; - if (Number.isFinite(inv) && inv >= 1 && inv <= 1e15) { - const base = Math.round(inv); - if (base > 0 && Math.abs(inv - base) / Math.max(inv, 1) < 1e-6) { - fmt.base = base; - } + try { + if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) { + return { type: "price", precision: 2, minMove: 0.01, base: 100 }; } + const raw = Number(tick); + if (raw >= 1) { + return { type: "price", precision: 0, minMove: 1 }; + } + let prec = decimalsFromTick(raw); + if (prec == null || prec < 0) prec = 4; + prec = Math.min(8, Math.max(0, Math.floor(prec))); + const base = Math.pow(10, prec); + if (!Number.isFinite(base) || base < 1 || base > 1e12) { + return SAFE_PRICE_FORMAT; + } + return { type: "price", precision: prec, minMove: 1 / base, base: base }; + } catch (e) { + return SAFE_PRICE_FORMAT; + } + } + + function applyPriceFormatToSeries(series, pf) { + if (!series || !series.applyOptions) return; + try { + series.applyOptions({ priceFormat: pf }); + } catch (e) { + series.applyOptions({ priceFormat: SAFE_PRICE_FORMAT }); } - return fmt; } 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 }); + let pf = SAFE_PRICE_FORMAT; + try { + pf = tickToPriceFormat(priceTick); + } catch (e) { + pf = SAFE_PRICE_FORMAT; } + applyPriceFormatToSeries(candleSeries, pf); + applyPriceFormatToSeries(indSeries.ema21, pf); + applyPriceFormatToSeries(indSeries.ema55, pf); if (chart) { chart.applyOptions({ localization: { @@ -1288,7 +1302,7 @@ wickDownColor: "#ff4d6d", lastValueVisible: false, priceLineVisible: false, - priceFormat: tickToPriceFormat(priceTick), + priceFormat: SAFE_PRICE_FORMAT, }; if (typeof chart.addCandlestickSeries === "function") { @@ -1515,18 +1529,16 @@ } priceTick = data.price_tick; - applyChartPriceFormat(); - lastCandles = data.candles; - indexCandles(lastCandles); try { - candleSeries.setData(lastCandles); - volumeSeries.setData(buildVolumeData(lastCandles)); - } catch (setErr) { + applyChartPriceFormat(); + } catch (fmtErr) { priceTick = null; applyChartPriceFormat(); - candleSeries.setData(lastCandles); - volumeSeries.setData(buildVolumeData(lastCandles)); } + lastCandles = data.candles; + indexCandles(lastCandles); + candleSeries.setData(lastCandles); + volumeSeries.setData(buildVolumeData(lastCandles)); applyChartRightGap(); if (resetView) { lastViewKey = vKey; @@ -1538,7 +1550,11 @@ updateVisibleRangeMarkers(); syncPosContextForView(exKey, sym); showLatestOhlcv(); - updateIndicators(); + try { + updateIndicators(); + } catch (indErr) { + /* 指标序列 priceFormat 异常时不阻断主图 */ + } scheduleChartResize(); const limit = data.limit || lastCandles.length; diff --git a/tests/test_hub_ohlcv_lib.py b/tests/test_hub_ohlcv_lib.py index 1580aa3..96e2962 100644 --- a/tests/test_hub_ohlcv_lib.py +++ b/tests/test_hub_ohlcv_lib.py @@ -7,6 +7,7 @@ from hub_ohlcv_lib import ( aggregate_ohlcv_bars, bars_spacing_matches_timeframe, fetch_ohlcv_for_hub, + normalize_price_tick, ) @@ -30,6 +31,11 @@ class _FakeExchange: class TestHubOhlcvLib(unittest.TestCase): + def test_normalize_price_tick_snaps_powers_of_ten(self): + self.assertAlmostEqual(normalize_price_tick(0.00001), 0.00001) + self.assertAlmostEqual(normalize_price_tick(0.001), 0.001) + self.assertIsNone(normalize_price_tick(0)) + def test_price_tick_from_decimal_precision(self): class _Ex: markets = {"BTC/USDT:USDT": {"precision": {"price": 2}, "info": {}, "limits": {}}}