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