from __future__ import annotations import logging import httpx import pandas as pd logger = logging.getLogger(__name__) BINANCE_FUTURES_KLINES_URL = "https://fapi.binance.com/fapi/v1/klines" BINANCE_EXCHANGE_INFO_URL = "https://fapi.binance.com/fapi/v1/exchangeInfo" DEFAULT_LIMIT = 90 # 6h charts use 1/3 of the default window so price action looks closer. CHART_LIMIT_BY_INTERVAL: dict[str, int] = { "15m": 135, "6h": 75, } def chart_kline_limit(interval: str) -> int: return CHART_LIMIT_BY_INTERVAL.get(interval, DEFAULT_LIMIT) def chart_right_pad(interval: str) -> int: """Empty candles to the right; scale with the visible window.""" if interval == "6h": return 12 return 15 # TradingView-style timeframe → Binance Futures interval TIMEFRAME_MAP: dict[str, str] = { "1": "1m", "1m": "1m", "3": "3m", "3m": "3m", "5": "5m", "5m": "5m", "15": "15m", "15m": "15m", "30": "30m", "30m": "30m", "60": "1h", "1h": "1h", "120": "2h", "2h": "2h", "240": "4h", "4h": "4h", "360": "6h", "6h": "6h", "480": "8h", "8h": "8h", "720": "12h", "12h": "12h", "d": "1d", "1d": "1d", "1D": "1d", "D": "1d", "w": "1w", "1w": "1w", "1W": "1w", "W": "1w", } # TradingView / broker suffixes stripped before Binance Futures lookup _PERP_SUFFIXES = (".P", ".PERP", "_PERP", "-PERP") def to_binance_symbol(ticker: str) -> str: """Map TradingView ticker to Binance Futures symbol (e.g. BTCUSDT). Accepts common TV forms: - BTCUSDT.P / BTCUSDT - BINANCE:BTCUSDT.P / BYBIT:ETHUSDT - BTC/USDT, BTC-USDT, BTCUSDTPERP """ symbol = ticker.strip().upper() if not symbol: raise ValueError("Empty ticker") # Exchange / broker prefix: BINANCE:BTCUSDT.P → BTCUSDT.P if ":" in symbol: symbol = symbol.rsplit(":", 1)[-1].strip() symbol = symbol.replace(" ", "").replace("/", "").replace("-", "") for suffix in _PERP_SUFFIXES: if symbol.endswith(suffix): symbol = symbol[: -len(suffix)] break else: # BTCUSDTPERP (no separator) if symbol.endswith("PERP") and len(symbol) > 4: symbol = symbol[:-4] # Continuous-contract markers (CME-style), ignore for Binance if symbol.endswith("1!"): symbol = symbol[:-2] elif symbol.endswith("!"): symbol = symbol[:-1] if not symbol: raise ValueError(f"Empty symbol after normalizing ticker: {ticker!r}") return symbol def to_binance_interval(visual_timeframe: str) -> str: key = visual_timeframe.strip() interval = TIMEFRAME_MAP.get(key) or TIMEFRAME_MAP.get(key.lower()) if interval is None: raise ValueError(f"Unsupported visual_timeframe: {visual_timeframe!r}") return interval _INTERVAL_DELTA: dict[str, pd.Timedelta] = { "1m": pd.Timedelta(minutes=1), "3m": pd.Timedelta(minutes=3), "5m": pd.Timedelta(minutes=5), "15m": pd.Timedelta(minutes=15), "30m": pd.Timedelta(minutes=30), "1h": pd.Timedelta(hours=1), "2h": pd.Timedelta(hours=2), "4h": pd.Timedelta(hours=4), "6h": pd.Timedelta(hours=6), "8h": pd.Timedelta(hours=8), "12h": pd.Timedelta(hours=12), "1d": pd.Timedelta(days=1), "1w": pd.Timedelta(weeks=1), } def interval_timedelta(interval: str) -> pd.Timedelta: key = interval if interval in _INTERVAL_DELTA else to_binance_interval(interval) delta = _INTERVAL_DELTA.get(key) if delta is None: raise ValueError(f"Unsupported interval: {interval!r}") return delta def drop_forming_candles(df: pd.DataFrame, interval: str) -> pd.DataFrame: """Drop the in-progress candle (open + interval > now).""" if df.empty: return df delta = interval_timedelta(interval) now = pd.Timestamp.now(tz="UTC") idx = df.index if idx.tz is None: idx = idx.tz_localize("UTC") else: idx = idx.tz_convert("UTC") return df.loc[idx + delta <= now] async def fetch_klines( symbol: str, interval: str, *, limit: int = DEFAULT_LIMIT, end_ms: int | None = None, timeout: float = 15.0, closed_only: bool = False, client: httpx.AsyncClient | None = None, ) -> pd.DataFrame: """Fetch OHLCV klines from Binance USDT-M Futures. If ``end_ms`` is set, returns candles ending at/before that UTC epoch millis (useful for historical / as-of charts). """ if limit > 1500: raise ValueError("Binance klines limit is 1500") params: dict[str, str | int] = { "symbol": symbol, "interval": interval, "limit": limit, } if end_ms is not None: params["endTime"] = end_ms http = client or httpx.AsyncClient(timeout=timeout) own_client = client is None try: response = await http.get(BINANCE_FUTURES_KLINES_URL, params=params) response.raise_for_status() raw = response.json() finally: if own_client: await http.aclose() if not raw: raise ValueError(f"Empty klines for {symbol} {interval}") df = pd.DataFrame( raw, columns=[ "open_time", "open", "high", "low", "close", "volume", "close_time", "quote_volume", "trades", "taker_buy_base", "taker_buy_quote", "ignore", ], ) df["Date"] = pd.to_datetime(df["open_time"], unit="ms", utc=True) for col in ("open", "high", "low", "close", "volume"): df[col] = pd.to_numeric(df[col], errors="coerce") df = df.set_index("Date")[["open", "high", "low", "close", "volume"]] df.columns = ["Open", "High", "Low", "Close", "Volume"] df = df.dropna() if closed_only: df = drop_forming_candles(df, interval) return df if df.empty: raise ValueError(f"No valid OHLCV rows for {symbol} {interval}") return df _TICK_SIZE: dict[str, float] = {} async def load_tick_sizes( client: httpx.AsyncClient | None = None, *, timeout: float = 20.0, ) -> None: """Cache Binance USDT-M PRICE_FILTER.tickSize per symbol (Pine mintick).""" http = client or httpx.AsyncClient(timeout=timeout) own = client is None try: response = await http.get(BINANCE_EXCHANGE_INFO_URL) response.raise_for_status() payload = response.json() finally: if own: await http.aclose() ticks: dict[str, float] = {} for item in payload.get("symbols") or []: name = str(item.get("symbol") or "") if not name: continue for filt in item.get("filters") or []: if filt.get("filterType") == "PRICE_FILTER": raw = filt.get("tickSize") if raw is None: continue tick = float(raw) if tick > 0: ticks[name] = tick break if ticks: _TICK_SIZE.update(ticks) logger.info("Loaded tick sizes for %s symbols", len(ticks)) def get_tick_size(symbol: str) -> float: key = to_binance_symbol(symbol) return _TICK_SIZE.get(key, 0.01)