fix(hub): 修复币安行情区 unexpected base 价格精度

normalize_price_tick 对齐 tick 为 10^-n;chart.js 使用整数 base 并在 applyOptions 失败时回退安全 priceFormat。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-03 17:36:51 +08:00
parent c56326734e
commit 2d8f65bf1d
3 changed files with 75 additions and 33 deletions
+21 -1
View File
@@ -139,6 +139,26 @@ def price_tick_from_market(exchange, exchange_symbol: str) -> Optional[float]:
return None 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: def _decimals_from_tick(tick: float) -> int:
if tick >= 1: if tick >= 1:
return 0 return 0
@@ -413,7 +433,7 @@ def fetch_ohlcv_for_hub(
if not merged: if not merged:
return {"ok": False, "msg": "交易所未返回 K 线"} 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 { return {
"ok": True, "ok": True,
+45 -29
View File
@@ -1009,35 +1009,49 @@
return Math.max(0, Math.min(12, Math.round(-Math.log10(minMove)))); 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) { function tickToPriceFormat(tick) {
const minMove = try {
tick != null && Number.isFinite(Number(tick)) && Number(tick) > 0 ? Number(tick) : 0.01; if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) {
const precision = decimalsFromTick(minMove) ?? 2; return { type: "price", precision: 2, minMove: 0.01, base: 100 };
const fmt = { type: "price", precision: precision, minMove: minMove }; }
// 避免 minMove 浮点导致 lightweight-charts 报 "unexpected base" const raw = Number(tick);
if (minMove > 0 && minMove < 1) { if (raw >= 1) {
const inv = 1 / minMove; return { type: "price", precision: 0, minMove: 1 };
if (Number.isFinite(inv) && inv >= 1 && inv <= 1e15) { }
const base = Math.round(inv); let prec = decimalsFromTick(raw);
if (base > 0 && Math.abs(inv - base) / Math.max(inv, 1) < 1e-6) { if (prec == null || prec < 0) prec = 4;
fmt.base = base; 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() { function applyChartPriceFormat() {
const pf = tickToPriceFormat(priceTick); let pf = SAFE_PRICE_FORMAT;
if (candleSeries && candleSeries.applyOptions) { try {
candleSeries.applyOptions({ priceFormat: pf }); pf = tickToPriceFormat(priceTick);
} } catch (e) {
if (indSeries.ema21 && indSeries.ema21.applyOptions) { pf = SAFE_PRICE_FORMAT;
indSeries.ema21.applyOptions({ priceFormat: pf });
}
if (indSeries.ema55 && indSeries.ema55.applyOptions) {
indSeries.ema55.applyOptions({ priceFormat: pf });
} }
applyPriceFormatToSeries(candleSeries, pf);
applyPriceFormatToSeries(indSeries.ema21, pf);
applyPriceFormatToSeries(indSeries.ema55, pf);
if (chart) { if (chart) {
chart.applyOptions({ chart.applyOptions({
localization: { localization: {
@@ -1288,7 +1302,7 @@
wickDownColor: "#ff4d6d", wickDownColor: "#ff4d6d",
lastValueVisible: false, lastValueVisible: false,
priceLineVisible: false, priceLineVisible: false,
priceFormat: tickToPriceFormat(priceTick), priceFormat: SAFE_PRICE_FORMAT,
}; };
if (typeof chart.addCandlestickSeries === "function") { if (typeof chart.addCandlestickSeries === "function") {
@@ -1515,18 +1529,16 @@
} }
priceTick = data.price_tick; priceTick = data.price_tick;
applyChartPriceFormat();
lastCandles = data.candles;
indexCandles(lastCandles);
try { try {
candleSeries.setData(lastCandles); applyChartPriceFormat();
volumeSeries.setData(buildVolumeData(lastCandles)); } catch (fmtErr) {
} catch (setErr) {
priceTick = null; priceTick = null;
applyChartPriceFormat(); applyChartPriceFormat();
}
lastCandles = data.candles;
indexCandles(lastCandles);
candleSeries.setData(lastCandles); candleSeries.setData(lastCandles);
volumeSeries.setData(buildVolumeData(lastCandles)); volumeSeries.setData(buildVolumeData(lastCandles));
}
applyChartRightGap(); applyChartRightGap();
if (resetView) { if (resetView) {
lastViewKey = vKey; lastViewKey = vKey;
@@ -1538,7 +1550,11 @@
updateVisibleRangeMarkers(); updateVisibleRangeMarkers();
syncPosContextForView(exKey, sym); syncPosContextForView(exKey, sym);
showLatestOhlcv(); showLatestOhlcv();
try {
updateIndicators(); updateIndicators();
} catch (indErr) {
/* 指标序列 priceFormat 异常时不阻断主图 */
}
scheduleChartResize(); scheduleChartResize();
const limit = data.limit || lastCandles.length; const limit = data.limit || lastCandles.length;
+6
View File
@@ -7,6 +7,7 @@ from hub_ohlcv_lib import (
aggregate_ohlcv_bars, aggregate_ohlcv_bars,
bars_spacing_matches_timeframe, bars_spacing_matches_timeframe,
fetch_ohlcv_for_hub, fetch_ohlcv_for_hub,
normalize_price_tick,
) )
@@ -30,6 +31,11 @@ class _FakeExchange:
class TestHubOhlcvLib(unittest.TestCase): 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): def test_price_tick_from_decimal_precision(self):
class _Ex: class _Ex:
markets = {"BTC/USDT:USDT": {"precision": {"price": 2}, "info": {}, "limits": {}}} markets = {"BTC/USDT:USDT": {"precision": {"price": 2}, "info": {}, "limits": {}}}