From 48df49b09c4e8515da9848ddbd0a94570ff9cd6b Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 30 May 2026 10:48:57 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0K=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/binance.py | 97 +++++++++++++++++++++++- backend/app/main.py | 13 ++++ web/charts.js | 167 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 266 insertions(+), 11 deletions(-) diff --git a/backend/app/binance.py b/backend/app/binance.py index 0467348..bcfa577 100644 --- a/backend/app/binance.py +++ b/backend/app/binance.py @@ -15,12 +15,36 @@ logger = logging.getLogger(__name__) _RATE_LIMIT_CODES = {418, 429} _SYMBOLS_CACHE_FILE = ROOT_DIR / "data" / "symbols_cache.json" +_SYMBOL_META_CACHE_FILE = ROOT_DIR / "data" / "symbol_meta_cache.json" + + +def _precision_from_tick_size(tick_size: str) -> int: + tick = tick_size.strip() + if "." not in tick: + return 0 + dec = tick.split(".", 1)[1] + trimmed = dec.rstrip("0") + return len(trimmed) if trimmed else len(dec) + + +def _parse_symbol_price_meta(symbol_info: dict[str, Any]) -> dict[str, Any]: + tick_size = "0.01" + for f in symbol_info.get("filters", []): + if f.get("filterType") == "PRICE_FILTER": + tick_size = str(f.get("tickSize", tick_size)) + break + precision = _precision_from_tick_size(tick_size) + api_precision = symbol_info.get("pricePrecision") + if isinstance(api_precision, int) and api_precision > precision: + precision = api_precision + return {"tick_size": tick_size, "price_precision": precision} class BinanceFuturesClient: def __init__(self) -> None: self.base = settings.binance_fapi_base.rstrip("/") self._symbols_cache: list[str] | None = None + self._symbol_meta_cache: dict[str, dict[str, Any]] | None = None self._client: httpx.AsyncClient | None = None self._throttle_lock = asyncio.Lock() self._last_request_at: float = 0.0 @@ -130,6 +154,69 @@ class BinanceFuturesClient: except Exception as e: logger.warning("Save symbols cache file failed: %s", e) + def _load_symbol_meta_file(self) -> dict[str, dict[str, Any]] | None: + try: + if _SYMBOL_META_CACHE_FILE.is_file(): + data = json.loads(_SYMBOL_META_CACHE_FILE.read_text(encoding="utf-8")) + if isinstance(data, dict): + return data + except Exception as e: + logger.warning("Load symbol meta cache file failed: %s", e) + return None + + def _save_symbol_meta_file(self, meta: dict[str, dict[str, Any]]) -> None: + try: + _SYMBOL_META_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + _SYMBOL_META_CACHE_FILE.write_text( + json.dumps(meta, ensure_ascii=False), + encoding="utf-8", + ) + except Exception as e: + logger.warning("Save symbol meta cache file failed: %s", e) + + async def _ensure_symbol_meta(self) -> dict[str, dict[str, Any]]: + if self._symbol_meta_cache: + return self._symbol_meta_cache + + if self.is_rate_limited(): + cached = self._load_symbol_meta_file() + if cached: + self._symbol_meta_cache = cached + return cached + raise BinanceRateLimitedError(self.rate_limit_remaining_sec(), "symbol_meta") + + try: + info = await self._get("/fapi/v1/exchangeInfo") + meta: dict[str, dict[str, Any]] = {} + for s in info.get("symbols", []): + if ( + s.get("contractType") == "PERPETUAL" + and s.get("quoteAsset") == "USDT" + and s.get("status") == "TRADING" + ): + sym = s["symbol"] + meta[sym] = _parse_symbol_price_meta(s) + self._symbol_meta_cache = meta + self._save_symbol_meta_file(meta) + return meta + except BinanceRateLimitedError: + cached = self._load_symbol_meta_file() + if cached: + self._symbol_meta_cache = cached + return cached + raise + + async def get_symbol_price_meta(self, symbol: str) -> dict[str, Any]: + sym = symbol.upper().strip() + meta_map = await self._ensure_symbol_meta() + if sym in meta_map: + return meta_map[sym] + return {"tick_size": "0.01", "price_precision": 2} + + def clear_symbol_cache(self) -> None: + self._symbols_cache = None + self._symbol_meta_cache = None + async def get_usdt_perpetual_symbols(self) -> list[str]: if self._symbols_cache: return self._symbols_cache @@ -145,15 +232,20 @@ class BinanceFuturesClient: try: info = await self._get("/fapi/v1/exchangeInfo") symbols = [] + meta: dict[str, dict[str, Any]] = {} for s in info.get("symbols", []): if ( s.get("contractType") == "PERPETUAL" and s.get("quoteAsset") == "USDT" and s.get("status") == "TRADING" ): - symbols.append(s["symbol"]) + sym = s["symbol"] + symbols.append(sym) + meta[sym] = _parse_symbol_price_meta(s) self._symbols_cache = sorted(symbols) + self._symbol_meta_cache = meta self._save_symbols_file(self._symbols_cache) + self._save_symbol_meta_file(meta) logger.info("Loaded %d USDT perpetual symbols", len(self._symbols_cache)) return self._symbols_cache except BinanceRateLimitedError: @@ -164,9 +256,6 @@ class BinanceFuturesClient: return cached raise - def clear_symbol_cache(self) -> None: - self._symbols_cache = None - async def get_24hr_tickers(self) -> list[dict]: data = await self._get("/fapi/v1/ticker/24hr") return data if isinstance(data, list) else [] diff --git a/backend/app/main.py b/backend/app/main.py index 82dae46..f7adc75 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,6 +10,7 @@ from .config import ROOT_DIR, settings from .funding_store import get_funding_bundle from .kline_store import get_candles, get_daily_candles, sync_daily_klines, sync_klines from .chart_intervals import CHART_INTERVALS, limit_for_interval, validate_interval +from .binance import binance_client from .db import get_latest_snapshot, init_db, log_push, save_snapshot from .exceptions import BinanceRateLimitedError from .period_api import get_period_top30 @@ -28,6 +29,14 @@ logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpcore").setLevel(logging.WARNING) logger = logging.getLogger(__name__) + +async def _chart_price_meta(sym: str) -> dict: + try: + return await binance_client.get_symbol_price_meta(sym) + except Exception as e: + logger.warning("price meta %s fallback: %s", sym, e) + return {"tick_size": "0.01", "price_precision": 2} + WEB_DIR = ROOT_DIR / "web" @@ -165,6 +174,7 @@ async def api_chart( candles, source = await get_candles( sym, iv, limit or default_limit, force_refresh=refresh ) + price_meta = await _chart_price_meta(sym) return { "symbol": sym, "interval": iv, @@ -172,6 +182,7 @@ async def api_chart( "candles": candles, "source": source, "intervals": list(CHART_INTERVALS), + **price_meta, } except BinanceRateLimitedError as e: raise HTTPException(503, f"币安限流,请 {e.retry_after_sec} 秒后再试") from e @@ -188,6 +199,7 @@ async def api_chart_daily(symbol: str, limit: int | None = None, refresh: bool = raise HTTPException(400, "invalid symbol") try: candles, source = await get_daily_candles(sym, limit, force_refresh=refresh) + price_meta = await _chart_price_meta(sym) return { "symbol": sym, "interval": "1d", @@ -195,6 +207,7 @@ async def api_chart_daily(symbol: str, limit: int | None = None, refresh: bool = "candles": candles, "source": source, "intervals": list(CHART_INTERVALS), + **price_meta, } except BinanceRateLimitedError as e: raise HTTPException(503, f"币安限流,请 {e.retry_after_sec} 秒后再试") from e diff --git a/web/charts.js b/web/charts.js index 752dd2f..d7d2edd 100644 --- a/web/charts.js +++ b/web/charts.js @@ -40,6 +40,8 @@ let lwcChart = null; let lwcCandleSeries = null; let lwcVolumeSeries = null; let lwcResizeObserver = null; +let lwcPriceLines = []; +const symbolPriceMeta = new Map(); function cacheKey(symbol, interval) { return `${symbol}:${interval}`; @@ -69,11 +71,18 @@ function loadKlineFromLS(symbol, interval) { } } -function saveKlineToLS(symbol, interval, candles, source) { +function saveKlineToLS(symbol, interval, candles, source, priceMeta) { try { localStorage.setItem( LS_KLINE_PREFIX + symbol + "_" + interval, - JSON.stringify({ ts: Date.now(), candles, source, interval }) + JSON.stringify({ + ts: Date.now(), + candles, + source, + interval, + tick_size: priceMeta?.tick_size, + price_precision: priceMeta?.price_precision, + }) ); } catch { /* quota */ @@ -88,6 +97,53 @@ function sourceLabel(source) { return "同步"; } +function parseMinMove(tickSize) { + const n = Number(tickSize); + return Number.isFinite(n) && n > 0 ? n : 0.01; +} + +function formatPrice(price, precision) { + return Number(price).toFixed(precision); +} + +function rememberPriceMeta(symbol, meta) { + if (!meta?.tick_size) return null; + const priceMeta = { + tick_size: meta.tick_size, + price_precision: Number(meta.price_precision ?? 2), + }; + symbolPriceMeta.set(symbol, priceMeta); + return priceMeta; +} + +function getPriceMeta(symbol, fallback) { + return ( + symbolPriceMeta.get(symbol) || + (fallback?.tick_size ? rememberPriceMeta(symbol, fallback) : null) || { + tick_size: "0.01", + price_precision: 2, + } + ); +} + +function findCandleExtremes(candles, interval) { + let maxHigh = -Infinity; + let minLow = Infinity; + let highTime = null; + let lowTime = null; + for (const c of candles) { + if (c.high > maxHigh) { + maxHigh = c.high; + highTime = toLwcTime(c.time, interval); + } + if (c.low < minLow) { + minLow = c.low; + lowTime = toLwcTime(c.time, interval); + } + } + return { maxHigh, minLow, highTime, lowTime }; +} + function toLwcTime(ms, interval) { if (interval === "1d" || interval === "1w") { const d = new Date(ms); @@ -261,7 +317,14 @@ async function fetchKlines(symbol, interval = DEFAULT_MINI_INTERVAL) { const ls = loadKlineFromLS(symbol, interval); if (ls) { - const result = { candles: ls.candles, source: ls.source || "browser", interval }; + const priceMeta = rememberPriceMeta(symbol, ls); + const result = { + candles: ls.candles, + source: ls.source || "browser", + interval, + tick_size: priceMeta?.tick_size, + price_precision: priceMeta?.price_precision, + }; chartDataCache.set(key, result); return result; } @@ -273,13 +336,16 @@ async function fetchKlines(symbol, interval = DEFAULT_MINI_INTERVAL) { throw new Error(err.detail || res.statusText); } const data = await res.json(); + const priceMeta = rememberPriceMeta(symbol, data); const result = { candles: data.candles || [], source: data.source || "db", interval, + tick_size: priceMeta.tick_size, + price_precision: priceMeta.price_precision, }; chartDataCache.set(key, result); - saveKlineToLS(symbol, interval, result.candles, result.source); + saveKlineToLS(symbol, interval, result.candles, result.source, priceMeta); return result; } @@ -308,6 +374,7 @@ async function loadMiniChart(box) { } function destroyLwcChart() { + clearHighLowAnnotations(); if (lwcResizeObserver) { lwcResizeObserver.disconnect(); lwcResizeObserver = null; @@ -320,6 +387,85 @@ function destroyLwcChart() { } } +function clearHighLowAnnotations() { + if (lwcCandleSeries) { + lwcPriceLines.forEach((line) => { + try { + lwcCandleSeries.removePriceLine(line); + } catch { + /* already removed */ + } + }); + lwcCandleSeries.setMarkers([]); + } + lwcPriceLines = []; +} + +function applySeriesPriceFormat(priceMeta) { + if (!lwcCandleSeries) return; + const precision = priceMeta.price_precision; + const minMove = parseMinMove(priceMeta.tick_size); + lwcCandleSeries.applyOptions({ + priceFormat: { + type: "price", + precision, + minMove, + }, + }); +} + +function applyHighLowAnnotations(candles, interval, priceMeta) { + if (!lwcCandleSeries || !candles.length) return; + clearHighLowAnnotations(); + + const { maxHigh, minLow, highTime, lowTime } = findCandleExtremes(candles, interval); + if (!Number.isFinite(maxHigh) || !Number.isFinite(minLow)) return; + + const precision = priceMeta.price_precision; + const highText = formatPrice(maxHigh, precision); + const lowText = formatPrice(minLow, precision); + + lwcPriceLines.push( + lwcCandleSeries.createPriceLine({ + price: maxHigh, + color: COLORS.up, + lineWidth: 1, + lineStyle: LightweightCharts.LineStyle.Dotted, + axisLabelVisible: true, + title: `最高 ${highText}`, + }), + lwcCandleSeries.createPriceLine({ + price: minLow, + color: COLORS.down, + lineWidth: 1, + lineStyle: LightweightCharts.LineStyle.Dotted, + axisLabelVisible: true, + title: `最低 ${lowText}`, + }) + ); + + const markers = []; + if (highTime != null) { + markers.push({ + time: highTime, + position: "aboveBar", + color: COLORS.up, + shape: "circle", + text: `高 ${highText}`, + }); + } + if (lowTime != null) { + markers.push({ + time: lowTime, + position: "belowBar", + color: COLORS.down, + shape: "circle", + text: `低 ${lowText}`, + }); + } + lwcCandleSeries.setMarkers(markers); +} + function ensureLwcChart(container) { if (typeof LightweightCharts === "undefined") { container.innerHTML = '

图表库加载失败

'; @@ -380,16 +526,20 @@ function ensureLwcChart(container) { return lwcChart; } -function renderLwcChart(candles, interval) { +function renderLwcChart(candles, interval, priceMeta) { const container = document.getElementById("chart-modal-container"); if (!container) return; if (!lwcChart) ensureLwcChart(container); if (!lwcCandleSeries || !lwcVolumeSeries) return; + const meta = getPriceMeta(chartModalSymbol, priceMeta); + applySeriesPriceFormat(meta); + const { ohlc, vol } = candlesToLwc(candles, interval); lwcCandleSeries.setData(ohlc); lwcVolumeSeries.setData(vol); + applyHighLowAnnotations(candles, interval, meta); lwcChart.timeScale().fitContent(); } @@ -419,9 +569,12 @@ async function loadModalChart(interval) { if (hint) hint.textContent = "加载中…"; try { - const { candles, source } = await fetchKlines(chartModalSymbol, interval); + const { candles, source, tick_size, price_precision } = await fetchKlines( + chartModalSymbol, + interval + ); if (!candles.length) throw new Error("无K线数据"); - renderLwcChart(candles, interval); + renderLwcChart(candles, interval, { tick_size, price_precision }); updateModalMeta(candles, source, interval); } catch (e) { if (hint) hint.textContent = `加载失败: ${e.message}`;