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" DEFAULT_LIMIT = 90 # 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 async def fetch_klines( symbol: str, interval: str, *, limit: int = DEFAULT_LIMIT, end_ms: int | None = None, timeout: float = 15.0, ) -> 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). """ params: dict[str, str | int] = { "symbol": symbol, "interval": interval, "limit": limit, } if end_ms is not None: params["endTime"] = end_ms async with httpx.AsyncClient(timeout=timeout) as client: response = await client.get(BINANCE_FUTURES_KLINES_URL, params=params) response.raise_for_status() raw = response.json() 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 df.empty: raise ValueError(f"No valid OHLCV rows for {symbol} {interval}") return df