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:
+21
-1
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
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();
|
||||
try {
|
||||
updateIndicators();
|
||||
} catch (indErr) {
|
||||
/* 指标序列 priceFormat 异常时不阻断主图 */
|
||||
}
|
||||
scheduleChartResize();
|
||||
|
||||
const limit = data.limit || lastCandles.length;
|
||||
|
||||
@@ -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": {}}}
|
||||
|
||||
Reference in New Issue
Block a user