增加K线

This commit is contained in:
dekun
2026-05-30 10:48:57 +08:00
parent e9f1a6a46f
commit 48df49b09c
3 changed files with 266 additions and 11 deletions
+93 -4
View File
@@ -15,12 +15,36 @@ logger = logging.getLogger(__name__)
_RATE_LIMIT_CODES = {418, 429} _RATE_LIMIT_CODES = {418, 429}
_SYMBOLS_CACHE_FILE = ROOT_DIR / "data" / "symbols_cache.json" _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: class BinanceFuturesClient:
def __init__(self) -> None: def __init__(self) -> None:
self.base = settings.binance_fapi_base.rstrip("/") self.base = settings.binance_fapi_base.rstrip("/")
self._symbols_cache: list[str] | None = None 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._client: httpx.AsyncClient | None = None
self._throttle_lock = asyncio.Lock() self._throttle_lock = asyncio.Lock()
self._last_request_at: float = 0.0 self._last_request_at: float = 0.0
@@ -130,6 +154,69 @@ class BinanceFuturesClient:
except Exception as e: except Exception as e:
logger.warning("Save symbols cache file failed: %s", 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]: async def get_usdt_perpetual_symbols(self) -> list[str]:
if self._symbols_cache: if self._symbols_cache:
return self._symbols_cache return self._symbols_cache
@@ -145,15 +232,20 @@ class BinanceFuturesClient:
try: try:
info = await self._get("/fapi/v1/exchangeInfo") info = await self._get("/fapi/v1/exchangeInfo")
symbols = [] symbols = []
meta: dict[str, dict[str, Any]] = {}
for s in info.get("symbols", []): for s in info.get("symbols", []):
if ( if (
s.get("contractType") == "PERPETUAL" s.get("contractType") == "PERPETUAL"
and s.get("quoteAsset") == "USDT" and s.get("quoteAsset") == "USDT"
and s.get("status") == "TRADING" 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._symbols_cache = sorted(symbols)
self._symbol_meta_cache = meta
self._save_symbols_file(self._symbols_cache) self._save_symbols_file(self._symbols_cache)
self._save_symbol_meta_file(meta)
logger.info("Loaded %d USDT perpetual symbols", len(self._symbols_cache)) logger.info("Loaded %d USDT perpetual symbols", len(self._symbols_cache))
return self._symbols_cache return self._symbols_cache
except BinanceRateLimitedError: except BinanceRateLimitedError:
@@ -164,9 +256,6 @@ class BinanceFuturesClient:
return cached return cached
raise raise
def clear_symbol_cache(self) -> None:
self._symbols_cache = None
async def get_24hr_tickers(self) -> list[dict]: async def get_24hr_tickers(self) -> list[dict]:
data = await self._get("/fapi/v1/ticker/24hr") data = await self._get("/fapi/v1/ticker/24hr")
return data if isinstance(data, list) else [] return data if isinstance(data, list) else []
+13
View File
@@ -10,6 +10,7 @@ from .config import ROOT_DIR, settings
from .funding_store import get_funding_bundle from .funding_store import get_funding_bundle
from .kline_store import get_candles, get_daily_candles, sync_daily_klines, sync_klines 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 .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 .db import get_latest_snapshot, init_db, log_push, save_snapshot
from .exceptions import BinanceRateLimitedError from .exceptions import BinanceRateLimitedError
from .period_api import get_period_top30 from .period_api import get_period_top30
@@ -28,6 +29,14 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING) logging.getLogger("httpcore").setLevel(logging.WARNING)
logger = logging.getLogger(__name__) 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" WEB_DIR = ROOT_DIR / "web"
@@ -165,6 +174,7 @@ async def api_chart(
candles, source = await get_candles( candles, source = await get_candles(
sym, iv, limit or default_limit, force_refresh=refresh sym, iv, limit or default_limit, force_refresh=refresh
) )
price_meta = await _chart_price_meta(sym)
return { return {
"symbol": sym, "symbol": sym,
"interval": iv, "interval": iv,
@@ -172,6 +182,7 @@ async def api_chart(
"candles": candles, "candles": candles,
"source": source, "source": source,
"intervals": list(CHART_INTERVALS), "intervals": list(CHART_INTERVALS),
**price_meta,
} }
except BinanceRateLimitedError as e: except BinanceRateLimitedError as e:
raise HTTPException(503, f"币安限流,请 {e.retry_after_sec} 秒后再试") from 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") raise HTTPException(400, "invalid symbol")
try: try:
candles, source = await get_daily_candles(sym, limit, force_refresh=refresh) candles, source = await get_daily_candles(sym, limit, force_refresh=refresh)
price_meta = await _chart_price_meta(sym)
return { return {
"symbol": sym, "symbol": sym,
"interval": "1d", "interval": "1d",
@@ -195,6 +207,7 @@ async def api_chart_daily(symbol: str, limit: int | None = None, refresh: bool =
"candles": candles, "candles": candles,
"source": source, "source": source,
"intervals": list(CHART_INTERVALS), "intervals": list(CHART_INTERVALS),
**price_meta,
} }
except BinanceRateLimitedError as e: except BinanceRateLimitedError as e:
raise HTTPException(503, f"币安限流,请 {e.retry_after_sec} 秒后再试") from e raise HTTPException(503, f"币安限流,请 {e.retry_after_sec} 秒后再试") from e
+160 -7
View File
@@ -40,6 +40,8 @@ let lwcChart = null;
let lwcCandleSeries = null; let lwcCandleSeries = null;
let lwcVolumeSeries = null; let lwcVolumeSeries = null;
let lwcResizeObserver = null; let lwcResizeObserver = null;
let lwcPriceLines = [];
const symbolPriceMeta = new Map();
function cacheKey(symbol, interval) { function cacheKey(symbol, interval) {
return `${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 { try {
localStorage.setItem( localStorage.setItem(
LS_KLINE_PREFIX + symbol + "_" + interval, 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 { } catch {
/* quota */ /* quota */
@@ -88,6 +97,53 @@ function sourceLabel(source) {
return "同步"; 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) { function toLwcTime(ms, interval) {
if (interval === "1d" || interval === "1w") { if (interval === "1d" || interval === "1w") {
const d = new Date(ms); const d = new Date(ms);
@@ -261,7 +317,14 @@ async function fetchKlines(symbol, interval = DEFAULT_MINI_INTERVAL) {
const ls = loadKlineFromLS(symbol, interval); const ls = loadKlineFromLS(symbol, interval);
if (ls) { 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); chartDataCache.set(key, result);
return result; return result;
} }
@@ -273,13 +336,16 @@ async function fetchKlines(symbol, interval = DEFAULT_MINI_INTERVAL) {
throw new Error(err.detail || res.statusText); throw new Error(err.detail || res.statusText);
} }
const data = await res.json(); const data = await res.json();
const priceMeta = rememberPriceMeta(symbol, data);
const result = { const result = {
candles: data.candles || [], candles: data.candles || [],
source: data.source || "db", source: data.source || "db",
interval, interval,
tick_size: priceMeta.tick_size,
price_precision: priceMeta.price_precision,
}; };
chartDataCache.set(key, result); chartDataCache.set(key, result);
saveKlineToLS(symbol, interval, result.candles, result.source); saveKlineToLS(symbol, interval, result.candles, result.source, priceMeta);
return result; return result;
} }
@@ -308,6 +374,7 @@ async function loadMiniChart(box) {
} }
function destroyLwcChart() { function destroyLwcChart() {
clearHighLowAnnotations();
if (lwcResizeObserver) { if (lwcResizeObserver) {
lwcResizeObserver.disconnect(); lwcResizeObserver.disconnect();
lwcResizeObserver = null; 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) { function ensureLwcChart(container) {
if (typeof LightweightCharts === "undefined") { if (typeof LightweightCharts === "undefined") {
container.innerHTML = '<p class="chart-lwc-fallback">图表库加载失败</p>'; container.innerHTML = '<p class="chart-lwc-fallback">图表库加载失败</p>';
@@ -380,16 +526,20 @@ function ensureLwcChart(container) {
return lwcChart; return lwcChart;
} }
function renderLwcChart(candles, interval) { function renderLwcChart(candles, interval, priceMeta) {
const container = document.getElementById("chart-modal-container"); const container = document.getElementById("chart-modal-container");
if (!container) return; if (!container) return;
if (!lwcChart) ensureLwcChart(container); if (!lwcChart) ensureLwcChart(container);
if (!lwcCandleSeries || !lwcVolumeSeries) return; if (!lwcCandleSeries || !lwcVolumeSeries) return;
const meta = getPriceMeta(chartModalSymbol, priceMeta);
applySeriesPriceFormat(meta);
const { ohlc, vol } = candlesToLwc(candles, interval); const { ohlc, vol } = candlesToLwc(candles, interval);
lwcCandleSeries.setData(ohlc); lwcCandleSeries.setData(ohlc);
lwcVolumeSeries.setData(vol); lwcVolumeSeries.setData(vol);
applyHighLowAnnotations(candles, interval, meta);
lwcChart.timeScale().fitContent(); lwcChart.timeScale().fitContent();
} }
@@ -419,9 +569,12 @@ async function loadModalChart(interval) {
if (hint) hint.textContent = "加载中…"; if (hint) hint.textContent = "加载中…";
try { 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线数据"); if (!candles.length) throw new Error("无K线数据");
renderLwcChart(candles, interval); renderLwcChart(candles, interval, { tick_size, price_precision });
updateModalMeta(candles, source, interval); updateModalMeta(candles, source, interval);
} catch (e) { } catch (e) {
if (hint) hint.textContent = `加载失败: ${e.message}`; if (hint) hint.textContent = `加载失败: ${e.message}`;