tvsignals-to-tg/app/binance.py
Artemii Peretiachenko cdbda8fea3 Replace TradingView polling with a local LTF/FVG scanner that posts Telegram cards and Heryon webhooks.
Per-strategy Heryon accounts, reversal captions with previous-trade path on charts, and Telegram replies chained by ticker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 20:37:16 +02:00

264 lines
7.1 KiB
Python

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)