mirror of
https://github.com/artemium428/tvsignals-to-tg.git
synced 2026-09-15 17:16:21 +00:00
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>
264 lines
7.1 KiB
Python
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)
|