From d6c539dca9dca6d9693c862199b544c80197344b Mon Sep 17 00:00:00 2001 From: Artemii Peretiachenko Date: Fri, 24 Jul 2026 16:35:47 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20TradingView=E2=86=92Telegram=20webhoo?= =?UTF-8?q?k=20service=20with=20Render=20Blueprint.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .env.example | 5 + .gitignore | 10 ++ Dockerfile | 23 +++ README.md | 150 +++++++++++++++++++ app/__init__.py | 0 app/binance.py | 151 +++++++++++++++++++ app/chart.py | 365 +++++++++++++++++++++++++++++++++++++++++++++ app/config.py | 22 +++ app/formatter.py | 130 ++++++++++++++++ app/main.py | 114 ++++++++++++++ app/models.py | 46 ++++++ app/telegram.py | 59 ++++++++ docker-compose.yml | 8 + render.yaml | 19 +++ requirements.txt | 9 ++ 15 files changed, 1111 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/binance.py create mode 100644 app/chart.py create mode 100644 app/config.py create mode 100644 app/formatter.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/telegram.py create mode 100644 docker-compose.yml create mode 100644 render.yaml create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8eecc91 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +TELEGRAM_BOT_TOKEN=123456:ABC-DEF +TELEGRAM_CHAT_ID=-1001234567890 +TELEGRAM_MESSAGE_THREAD_ID=1 +HOST=0.0.0.0 +PORT=8000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..370dec5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +.env* +!.env.example +.venv/ +__pycache__/ +*.py[cod] +*.png +.pytest_cache/ +.mypy_cache/ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c90377b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libfreetype6 \ + libpng16-16 \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +ENV PYTHONUNBUFFERED=1 +ENV MPLBACKEND=Agg +ENV HOST=0.0.0.0 +ENV PORT=8000 + +EXPOSE 8000 + +CMD ["sh", "-c", "uvicorn app.main:app --host ${HOST} --port ${PORT}"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce43705 --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# TradingView → Telegram setup service + +Accepts TradingView webhook alerts, renders a Binance Futures candlestick setup chart, and posts photo + caption into a Telegram forum topic. + +## Quick start (Docker / VPS) + +1. Copy env and fill Telegram values: + +```bash +cp .env.example .env +``` + +```env +TELEGRAM_BOT_TOKEN=... +TELEGRAM_CHAT_ID=-100... +TELEGRAM_MESSAGE_THREAD_ID=... +HOST=0.0.0.0 +PORT=8000 +``` + +2. Build and run: + +```bash +docker compose up -d --build +``` + +3. Health check: + +```bash +curl http://127.0.0.1:8000/health +``` + +4. Put HTTPS in front (nginx/Caddy) and point TradingView webhook to: + +`https://your-domain/webhook` + +Bot must be added to the group/forum and allowed to post in the target topic. + +## Local run (without Docker) + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # fill values +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +## TradingView alert JSON + +The webhook accepts a **single-line or pretty-printed JSON** body, including TradingView’s common `Content-Type: text/plain`. + +Static example (alert **Webhook message** body) — primary setup (`signal_sequence: 1`): + +```json +{ + "ticker": "{{ticker}}", + "action": "long", + "entry_price": "65034.7", + "current_price": "65034.7", + "stop_loss_price": "63904.9", + "take_profit_1_price": "66085.9", + "take_profit_2_price": "67209.2", + "take_profit_3_price": "68355.5", + "visual_timeframe": "15", + "signal_sequence": 1, + "signal_time": 1721736000 +} +``` + +Control update (`signal_sequence` > 1) — Pine freezes `entry_price` / SL / TPs / `signal_time` from seq 1 and sends live `current_price`: + +```json +{ + "ticker": "{{ticker}}", + "action": "short", + "entry_price": "65034.7", + "current_price": "64000.1", + "stop_loss_price": "65896.8", + "take_profit_1_price": "63904.9", + "take_profit_2_price": "62781.6", + "take_profit_3_price": "61635.3", + "visual_timeframe": "15", + "signal_sequence": 2, + "signal_time": 1721736000 +} +``` + +### PineScript `alert()` (recommended) + +Build the JSON inside `alert()`. A continuous one-line string is fine. + +In the TradingView alert dialog: + +- Webhook URL: `https://your-domain/webhook` +- Message: only `{{alert_message}}` (do not paste a second JSON next to it) + +On **seq == 1**: store `entry_price = close`, freeze SL/TPs, and `signal_time = time / 1000` (bar open, unix seconds). On **seq > 1**: keep those frozen fields; only refresh `current_price` (= live `close`). Example shape: + +```pinescript +alert('{"ticker":"' + syminfo.ticker + '","action":"long","entry_price":"' + str.tostring(long_entry_price, format.mintick) + '","current_price":"' + str.tostring(close, format.mintick) + '","stop_loss_price":"' + str.tostring(long_sl_price, format.mintick) + '","take_profit_1_price":"' + str.tostring(long_tp1_price, format.mintick) + '","take_profit_2_price":"' + str.tostring(long_tp2_price, format.mintick) + '","take_profit_3_price":"' + str.tostring(long_tp3_price, format.mintick) + '","visual_timeframe":"' + timeframe.period + '","signal_sequence":' + str.tostring(buyCount) + ',"signal_time":' + str.tostring(signal_buy_time) + '}', alert.freq_once_per_bar_close) +``` + +(Same idea for shorts with `short_entry_price` / `sellCount` / `signal_sell_time`.) Caption emoji/labels are built by the service from `action` + `signal_sequence` — do not put them in the webhook JSON. + +Field notes: + +| Field | Description | +|---|---| +| `ticker` | any common TV form (`BTCUSDT.P`, `BTCUSDT`, `BINANCE:ETHUSDT`, `BTC/USDT`, …) → normalized to Binance Futures symbol for the chart; caption keeps the original | +| `action` | `long` or `short` | +| `entry_price` | trade entry from seq 1 (equals `current_price` on primary signal) | +| `current_price` | live price (`close` at alert time) | +| `*_price` | strings with your display precision | +| `visual_timeframe` | `1`, `3`, `5`, `15`, `30`, `60`, `120`, `240`, `D`, `W` (also `15m`, `1h`, …) | +| `signal_sequence` | `1` = primary setup; `>1` = control update of that trade | +| `signal_time` | unix seconds of the seq-1 bar open (UTC); chart draws Entry/SL/TP zones from that candle | + +## Caption format + +**seq `1` (setup):** + +- long: `BTCUSDT.P 💚 Buy` +- short: `BTCUSDT.P 💔 Sell` +- body: `Price` / `SL (risk %)` / `TP1–3` + +**seq `>1` (control):** + +- long: `BTCUSDT.P 🌱 Buy Seq: N` +- short: `BTCUSDT.P 🥀 Sell Seq: N` +- body: `Entry price` (from seq 1) / live `Price` / `Current profit: +1.6% (RR 1:1.2)` +- no new SL/TP lines in the caption + +Prices are shown with `$` and thousand spaces (`65034.7` → `$65 034.7`). +For seq 1, SL includes distance from entry: `SL: $63 904.9 (-2.37%)` (risk %, negative for both long and short). +For seq >1, profit % is signed vs entry; RR is `|price−entry| / |entry−SL|` with the same sign as profit. + +## Behavior + +1. Validate payload +2. Fetch ~90 klines from Binance USDT-M Futures (public, no API key) +3. Render PNG: candles + Entry / SL / TP1–3 from the payload, starting at the `signal_time` candle (seq `>1` reuses frozen seq-1 levels/time and also marks live `Price`) +4. `sendPhoto` to `TELEGRAM_CHAT_ID` topic `TELEGRAM_MESSAGE_THREAD_ID` +5. If chart/klines fail → text-only `sendMessage` fallback (still `200`) +6. If Telegram fails → `502` + +## Endpoints + +- `GET /health` → `{"status":"ok"}` +- `POST /webhook` → signal payload above diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/binance.py b/app/binance.py new file mode 100644 index 0000000..df5771d --- /dev/null +++ b/app/binance.py @@ -0,0 +1,151 @@ +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 diff --git a/app/chart.py b/app/chart.py new file mode 100644 index 0000000..74334aa --- /dev/null +++ b/app/chart.py @@ -0,0 +1,365 @@ +from __future__ import annotations + +import io +import logging +from datetime import timedelta, timezone +from typing import Literal + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt +import mplfinance as mpf +import pandas as pd +from matplotlib.patches import Rectangle + +logger = logging.getLogger(__name__) + +ActionSide = Literal["long", "short"] +RIGHT_PAD_CANDLES = 15 +UTC_PLUS_2 = timezone(timedelta(hours=2)) + +COLORS = { + "bg": "#0f1115", + "panel": "#0f1115", + "grid": "#1e222d", + "text": "#d1d4dc", + "up": "#26a69a", + "down": "#ef5350", + "entry": "#42a5f5", + "price": "#ffca28", + "sl": "#ef5350", + "tp1": "#66bb6a", + "tp2": "#43a047", + "tp3": "#2e7d32", + "long_fill": (0.15, 0.65, 0.45), + "short_fill": (0.85, 0.25, 0.25), + "risk_fill": (0.85, 0.25, 0.25, 0.10), + "label_bg": "#0f1115", + "vol_up": "#26a69a", + "vol_down": "#ef5350", +} + +# Reward zone opacities: Entry→TP1, TP1→TP2, TP2→TP3 (decreasing) +REWARD_ALPHAS = (0.16, 0.10, 0.06) + + +def _parse_price(raw: str) -> float: + return float(raw.strip().replace(" ", "").replace(",", "").replace("$", "")) + + +def _to_utc_plus_2(df: pd.DataFrame) -> pd.DataFrame: + out = df.copy() + idx = out.index + if idx.tz is None: + idx = idx.tz_localize("UTC") + out.index = idx.tz_convert(UTC_PLUS_2) + return out + + +def _pad_right(df: pd.DataFrame, candles: int = RIGHT_PAD_CANDLES) -> pd.DataFrame: + """Append empty (NaN) candles so there is free space to the right of price action.""" + if len(df) < 2: + delta = pd.Timedelta(minutes=15) + else: + delta = df.index[-1] - df.index[-2] + if not isinstance(delta, pd.Timedelta) or delta <= pd.Timedelta(0): + delta = pd.Timedelta(minutes=15) + + future_index = pd.date_range( + start=df.index[-1] + delta, + periods=candles, + freq=delta, + tz=df.index.tz, + ) + pad = pd.DataFrame(index=future_index, columns=df.columns, dtype=float) + return pd.concat([df, pad]) + + +def _volume_overlay( + df: pd.DataFrame, + *, + y_low: float, + y_high: float, + fraction: float = 0.18, +) -> tuple[pd.Series, list[str]]: + """Scale volume into the bottom of the price pane (TradingView-style).""" + span = y_high - y_low + height = span * fraction + base = y_low - span * 0.01 + vol = df["Volume"].astype(float) + # Right-pad / empty candles: NaN so mplfinance skips the bar entirely + # (zero height still draws a stub from y=0 → base, often as black). + empty = df["Open"].isna() | df["Close"].isna() | vol.isna() + vol_filled = vol.fillna(0.0) + real = vol_filled[~empty] + vmax = float(real.max()) if len(real) else 1.0 + if vmax <= 0: + vmax = 1.0 + scaled = base + (vol_filled / vmax) * height + scaled = scaled.mask(empty) + + colors: list[str] = [] + for _, row in df.iterrows(): + if pd.isna(row["Close"]) or pd.isna(row["Open"]): + colors.append(COLORS["bg"]) + elif row["Close"] >= row["Open"]: + colors.append(COLORS["vol_up"]) + else: + colors.append(COLORS["vol_down"]) + return scaled, colors + + +def _position_start_x(df: pd.DataFrame, signal_time: int | None) -> int: + """Integer x of the candle where the seq==1 position starts (fallback: last).""" + last_x = len(df) - 1 + if signal_time is None or last_x < 0: + return max(last_x, 0) + ts = pd.Timestamp(int(signal_time), unit="s", tz="UTC") + if df.index.tz is not None: + ts = ts.tz_convert(df.index.tz) + # Last candle whose open time is <= signal time + pos = int(df.index.searchsorted(ts, side="right") - 1) + if pos < 0: + return 0 + return min(pos, last_x) + + +def render_setup_chart( + df: pd.DataFrame, + *, + ticker: str, + action: ActionSide, + entry: str, + stop_loss: str, + tp1: str, + tp2: str, + tp3: str, + timeframe: str, + current_price: str | None = None, + signal_time: int | None = None, +) -> bytes: + entry_p = _parse_price(entry) + sl_p = _parse_price(stop_loss) + tp1_p = _parse_price(tp1) + tp2_p = _parse_price(tp2) + tp3_p = _parse_price(tp3) + current_p = _parse_price(current_price) if current_price is not None else None + + is_long = action == "long" + reward_rgb = COLORS["long_fill"] if is_long else COLORS["short_fill"] + + df = _to_utc_plus_2(df) + + # Position tool starts at seq==1 candle; "now" is the last real candle + now_x = len(df) - 1 + entry_x = _position_start_x(df, signal_time) + plot_df = _pad_right(df, RIGHT_PAD_CANDLES) + + level_prices = [entry_p, sl_p, tp1_p, tp2_p, tp3_p] + if current_p is not None: + level_prices.append(current_p) + y_min = min(float(df["Low"].min()), *level_prices) + y_max = max(float(df["High"].max()), *level_prices) + price_pad = (y_max - y_min) * 0.06 or y_max * 0.002 + + vol_scaled, vol_colors = _volume_overlay(plot_df, y_low=y_min, y_high=y_max) + + addplots = [ + mpf.make_addplot( + vol_scaled, + type="bar", + panel=0, + color=vol_colors, + width=0.8, + alpha=0.15, # ~85% transparent + secondary_y=False, + ), + ] + + mc = mpf.make_marketcolors( + up=COLORS["up"], + down=COLORS["down"], + edge="inherit", + wick="inherit", + volume="in", + ) + style = mpf.make_mpf_style( + base_mpf_style="nightclouds", + marketcolors=mc, + facecolor=COLORS["bg"], + figcolor=COLORS["bg"], + gridcolor=COLORS["grid"], + gridstyle="--", + y_on_right=True, + rc={ + "axes.labelcolor": COLORS["text"], + "xtick.color": COLORS["text"], + "ytick.color": COLORS["text"], + "axes.edgecolor": COLORS["grid"], + "figure.facecolor": COLORS["bg"], + "axes.facecolor": COLORS["panel"], + "font.size": 9, + }, + ) + + fig, axes = mpf.plot( + plot_df, + type="candle", + style=style, + volume=False, + addplot=addplots, + returnfig=True, + figsize=(12, 7), + tight_layout=True, + datetime_format="%m-%d\n%H:%M", + warn_too_much_data=10_000, + xrotation=0, + ylabel="", + ) + ax = axes[0] + ax.set_ylabel("") + for label in ax.get_xticklabels(): + label.set_horizontalalignment("center") + label.set_fontsize(8) + label.set_linespacing(1.35) + + # Room for volume bars under candles + vol_floor = float(vol_scaled.dropna().min()) if vol_scaled.notna().any() else y_min + ax.set_ylim(min(y_min - price_pad, vol_floor) - price_pad * 0.3, y_max + price_pad) + + x_right = ax.get_xlim()[1] + zone_width = x_right - entry_x + + # Levels + zones start at the entry (last real) candle, not full chart width + level_specs = [ + (entry_p, COLORS["entry"], "-", 1.4), + (sl_p, COLORS["sl"], "--", 1.2), + (tp1_p, COLORS["tp1"], ":", 1.0), + (tp2_p, COLORS["tp2"], ":", 1.0), + (tp3_p, COLORS["tp3"], ":", 1.0), + ] + for price, color, ls, lw in level_specs: + ax.hlines( + price, + xmin=entry_x, + xmax=x_right, + colors=color, + linestyles=ls, + linewidths=lw, + alpha=0.95, + zorder=4, + ) + + risk_low = min(entry_p, sl_p) + risk_high = max(entry_p, sl_p) + ax.add_patch( + Rectangle( + (entry_x, risk_low), + zone_width, + risk_high - risk_low, + facecolor=COLORS["risk_fill"], + edgecolor="none", + zorder=0, + ) + ) + + # Three reward bands with decreasing opacity toward farther TPs + reward_bands = ( + (entry_p, tp1_p, REWARD_ALPHAS[0]), + (tp1_p, tp2_p, REWARD_ALPHAS[1]), + (tp2_p, tp3_p, REWARD_ALPHAS[2]), + ) + for price_a, price_b, alpha in reward_bands: + band_low = min(price_a, price_b) + band_high = max(price_a, price_b) + ax.add_patch( + Rectangle( + (entry_x, band_low), + zone_width, + band_high - band_low, + facecolor=(*reward_rgb, alpha), + edgecolor="none", + zorder=0, + ) + ) + + ax.scatter( + [entry_x], + [entry_p], + s=22, + c=COLORS["entry"], + marker="o", + zorder=7, + edgecolors="#ffffff", + linewidths=0.7, + ) + + if current_p is not None: + ax.hlines( + current_p, + xmin=entry_x, + xmax=x_right, + colors=COLORS["price"], + linestyles="-.", + linewidths=1.3, + alpha=0.95, + zorder=5, + ) + ax.scatter( + [now_x], + [current_p], + s=28, + c=COLORS["price"], + marker="D", + zorder=7, + edgecolors="#ffffff", + linewidths=0.7, + ) + + labels = [ + (entry_p, f"Entry {entry}", COLORS["entry"]), + (sl_p, f"SL {stop_loss}", COLORS["sl"]), + (tp1_p, f"TP1 {tp1}", COLORS["tp1"]), + (tp2_p, f"TP2 {tp2}", COLORS["tp2"]), + (tp3_p, f"TP3 {tp3}", COLORS["tp3"]), + ] + if current_p is not None and current_price is not None: + labels.append((current_p, f"Price {current_price}", COLORS["price"])) + for price, text, color in labels: + ax.annotate( + text, + xy=(x_right, price), + xytext=(6, 0), + textcoords="offset points", + va="center", + ha="left", + fontsize=8, + color=color, + clip_on=False, + zorder=8, + bbox={ + "boxstyle": "round,pad=0.28", + "facecolor": COLORS["label_bg"], + "edgecolor": color, + "linewidth": 0.8, + "alpha": 0.92, + }, + ) + + side = "LONG" if is_long else "SHORT" + ax.set_title( + f"{ticker} · {timeframe} · {side}", + color=COLORS["text"], + fontsize=12, + pad=12, + ) + + fig.subplots_adjust(right=0.82) + + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=140, facecolor=COLORS["bg"], bbox_inches="tight") + plt.close(fig) + buf.seek(0) + return buf.read() diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..2cea7ad --- /dev/null +++ b/app/config.py @@ -0,0 +1,22 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + telegram_bot_token: str + telegram_chat_id: str + telegram_message_thread_id: int + host: str = "0.0.0.0" + port: int = 8000 + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/app/formatter.py b/app/formatter.py new file mode 100644 index 0000000..a034db2 --- /dev/null +++ b/app/formatter.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from app.models import Action, SignalPayload + + +def _parse_price_number(raw: str) -> float: + text = raw.strip().replace(" ", "").replace(",", "") + if text.startswith("$"): + text = text[1:] + return float(text) + + +def format_sl_distance_pct(entry_raw: str, sl_raw: str, *, is_long: bool) -> str: + """Percent move from entry to SL; shown as risk (negative) for both sides.""" + entry = _parse_price_number(entry_raw) + if entry == 0: + raise ValueError("entry price is zero") + sl = _parse_price_number(sl_raw) + pct = (sl - entry) / entry * 100 + if not is_long: + pct = -pct + return f"({pct:.2f}%)" + + +def format_current_profit(entry_raw: str, current_raw: str, sl_raw: str, *, is_long: bool) -> str: + """Signed profit % vs entry and RR vs original SL distance (e.g. +1.6% (RR 1:1.2)).""" + entry = _parse_price_number(entry_raw) + if entry == 0: + raise ValueError("entry price is zero") + current = _parse_price_number(current_raw) + sl = _parse_price_number(sl_raw) + + if is_long: + profit_pct = (current - entry) / entry * 100 + else: + profit_pct = (entry - current) / entry * 100 + + sl_dist = abs(entry - sl) + if sl_dist == 0: + raise ValueError("stop loss equals entry") + move = abs(current - entry) + rr = move / sl_dist + if profit_pct < 0: + rr = -rr + + return f"{profit_pct:+.1f}% (RR 1:{rr:.1f})" + + +def format_price(raw: str) -> str: + """Insert thousand spaces and prefix with $; preserve decimal precision from TV.""" + text = raw.strip().replace(" ", "").replace(",", "") + if text.startswith("$"): + text = text[1:] + + negative = text.startswith("-") + if negative: + text = text[1:] + + if "." in text: + whole, frac = text.split(".", 1) + else: + whole, frac = text, None + + whole = whole.lstrip("0") or "0" + grouped = _group_thousands(whole) + if frac is not None: + formatted = f"{grouped}.{frac}" + else: + formatted = grouped + + if negative: + formatted = f"-{formatted}" + return f"${formatted}" + + +def _group_thousands(digits: str) -> str: + if len(digits) <= 3: + return digits + parts: list[str] = [] + while digits: + parts.append(digits[-3:]) + digits = digits[:-3] + return " ".join(reversed(parts)) + + +def format_caption(signal: SignalPayload) -> str: + is_long = signal.action == Action.LONG + seq = signal.signal_sequence + + if seq == 1: + label = "💚 Buy" if is_long else "💔 Sell" + else: + label = f"🌱 Buy Seq: {seq}" if is_long else f"🥀 Sell Seq: {seq}" + + if seq > 1: + entry = format_price(signal.entry_price) + price = format_price(signal.current_price) + profit = format_current_profit( + signal.entry_price, + signal.current_price, + signal.stop_loss_price, + is_long=is_long, + ) + return ( + f"{signal.ticker} {label}\n" + f"\n" + f"Entry price: {entry}\n" + f"Price: {price}\n" + f"Current profit: {profit}" + ) + + price = format_price(signal.entry_price) + sl = format_price(signal.stop_loss_price) + sl_pct = format_sl_distance_pct( + signal.entry_price, signal.stop_loss_price, is_long=is_long + ) + tp1 = format_price(signal.take_profit_1_price) + tp2 = format_price(signal.take_profit_2_price) + tp3 = format_price(signal.take_profit_3_price) + + return ( + f"{signal.ticker} {label}\n" + f"\n" + f"Price: {price}\n" + f"SL: {sl} {sl_pct}\n" + f"\n" + f"TP1: {tp1}\n" + f"TP2: {tp2}\n" + f"TP3: {tp3}" + ) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..95d86dd --- /dev/null +++ b/app/main.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import json +import logging +from typing import Any + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse +from pydantic import ValidationError + +from app.binance import fetch_klines, to_binance_interval, to_binance_symbol +from app.chart import render_setup_chart +from app.config import get_settings +from app.formatter import format_caption +from app.models import SignalPayload +from app.telegram import TelegramError, send_message, send_photo + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", +) +logger = logging.getLogger(__name__) + +app = FastAPI(title="TV Signals → Telegram", version="1.0.0") + + +def _parse_signal_body(raw: bytes) -> SignalPayload: + """Parse JSON from raw body (works for application/json and text/plain).""" + try: + text = raw.decode("utf-8").strip() + except UnicodeDecodeError as exc: + raise HTTPException(status_code=422, detail="Body must be UTF-8 text") from exc + + if not text: + raise HTTPException(status_code=422, detail="Empty body") + + try: + data: Any = json.loads(text) + except json.JSONDecodeError as exc: + raise HTTPException(status_code=422, detail=f"Invalid JSON: {exc}") from exc + + try: + return SignalPayload.model_validate(data) + except ValidationError as exc: + raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.post("/webhook") +async def webhook(request: Request) -> JSONResponse: + signal = _parse_signal_body(await request.body()) + settings = get_settings() + caption = format_caption(signal) + logger.info( + "Signal received: %s %s seq=%s tf=%s", + signal.ticker, + signal.action.value, + signal.signal_sequence, + signal.visual_timeframe, + ) + + photo: bytes | None = None + chart_error: str | None = None + + try: + symbol = to_binance_symbol(signal.ticker) + interval = to_binance_interval(signal.visual_timeframe) + df = await fetch_klines(symbol, interval) + photo = render_setup_chart( + df, + ticker=signal.ticker, + action=signal.action.value, # type: ignore[arg-type] + entry=signal.entry_price, + stop_loss=signal.stop_loss_price, + tp1=signal.take_profit_1_price, + tp2=signal.take_profit_2_price, + tp3=signal.take_profit_3_price, + timeframe=signal.visual_timeframe, + current_price=( + signal.current_price if signal.signal_sequence > 1 else None + ), + signal_time=signal.signal_time, + ) + except Exception as exc: # noqa: BLE001 — fallback to text-only post + chart_error = str(exc) + logger.exception("Chart generation failed, falling back to text-only: %s", exc) + + try: + if photo is not None: + await send_photo(settings, photo=photo, caption=caption) + return JSONResponse( + {"ok": True, "delivered": "photo", "chart_error": None}, + status_code=200, + ) + + await send_message(settings, text=caption) + return JSONResponse( + { + "ok": True, + "delivered": "text", + "chart_error": chart_error, + }, + status_code=200, + ) + except TelegramError as exc: + logger.exception("Telegram delivery failed: %s", exc) + raise HTTPException(status_code=502, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 + logger.exception("Unexpected delivery error: %s", exc) + raise HTTPException(status_code=500, detail=str(exc)) from exc diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..c8bb966 --- /dev/null +++ b/app/models.py @@ -0,0 +1,46 @@ +from enum import Enum + +from pydantic import BaseModel, Field, field_validator + + +class Action(str, Enum): + LONG = "long" + SHORT = "short" + + +class SignalPayload(BaseModel): + ticker: str + action: Action + entry_price: str + current_price: str + stop_loss_price: str + take_profit_1_price: str + take_profit_2_price: str + take_profit_3_price: str + visual_timeframe: str + signal_sequence: int = Field(ge=1) + signal_time: int = Field(gt=0) # unix seconds of seq==1 bar open (UTC) + + @field_validator("action", mode="before") + @classmethod + def normalize_action(cls, value: object) -> object: + if isinstance(value, str): + return value.strip().lower() + return value + + @field_validator( + "ticker", + "entry_price", + "current_price", + "stop_loss_price", + "take_profit_1_price", + "take_profit_2_price", + "take_profit_3_price", + "visual_timeframe", + mode="before", + ) + @classmethod + def strip_strings(cls, value: object) -> object: + if isinstance(value, str): + return value.strip() + return value diff --git a/app/telegram.py b/app/telegram.py new file mode 100644 index 0000000..4259ed2 --- /dev/null +++ b/app/telegram.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import logging + +import httpx + +from app.config import Settings + +logger = logging.getLogger(__name__) + +TELEGRAM_API = "https://api.telegram.org" + + +class TelegramError(Exception): + pass + + +async def send_photo( + settings: Settings, + *, + photo: bytes, + caption: str, + filename: str = "setup.png", +) -> dict: + url = f"{TELEGRAM_API}/bot{settings.telegram_bot_token}/sendPhoto" + data = { + "chat_id": settings.telegram_chat_id, + "message_thread_id": str(settings.telegram_message_thread_id), + "caption": caption, + "parse_mode": "HTML", + } + files = {"photo": (filename, photo, "image/png")} + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, data=data, files=files) + payload = response.json() + if response.status_code >= 400 or not payload.get("ok"): + raise TelegramError(f"sendPhoto failed: {payload}") + return payload + + +async def send_message( + settings: Settings, + *, + text: str, +) -> dict: + url = f"{TELEGRAM_API}/bot{settings.telegram_bot_token}/sendMessage" + data = { + "chat_id": settings.telegram_chat_id, + "message_thread_id": str(settings.telegram_message_thread_id), + "text": text, + "parse_mode": "HTML", + "disable_web_page_preview": True, + } + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, data=data) + payload = response.json() + if response.status_code >= 400 or not payload.get("ok"): + raise TelegramError(f"sendMessage failed: {payload}") + return payload diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6cfbda4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + tvsignals: + build: . + ports: + - "${PORT:-8000}:8000" + env_file: + - .env + restart: unless-stopped diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..476e1d5 --- /dev/null +++ b/render.yaml @@ -0,0 +1,19 @@ +services: + - type: web + name: tvsignals-to-tg + runtime: docker + plan: free + dockerfilePath: ./Dockerfile + dockerContext: . + healthCheckPath: /health + envVars: + - key: TELEGRAM_BOT_TOKEN + sync: false + - key: TELEGRAM_CHAT_ID + sync: false + - key: TELEGRAM_MESSAGE_THREAD_ID + sync: false + - key: MPLBACKEND + value: Agg + - key: HOST + value: 0.0.0.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a3b06cc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +pydantic>=2.9.0 +pydantic-settings>=2.6.0 +httpx>=0.27.0 +pandas>=2.2.0 +mplfinance>=0.12.10b0 +matplotlib>=3.9.0 +python-multipart>=0.0.12