Initial TradingView→Telegram webhook service with Render Blueprint.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Artemii Peretiachenko 2026-07-24 16:35:47 +02:00
commit d6c539dca9
15 changed files with 1111 additions and 0 deletions

5
.env.example Normal file
View file

@ -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

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
.env
.env*
!.env.example
.venv/
__pycache__/
*.py[cod]
*.png
.pytest_cache/
.mypy_cache/
.DS_Store

23
Dockerfile Normal file
View file

@ -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}"]

150
README.md Normal file
View file

@ -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 TradingViews 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 %)` / `TP13`
**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 `|priceentry| / |entrySL|` 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 / TP13 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

0
app/__init__.py Normal file
View file

151
app/binance.py Normal file
View file

@ -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

365
app/chart.py Normal file
View file

@ -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()

22
app/config.py Normal file
View file

@ -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()

130
app/formatter.py Normal file
View file

@ -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"<b>{signal.ticker}</b> {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"<b>{signal.ticker}</b> {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}"
)

114
app/main.py Normal file
View file

@ -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

46
app/models.py Normal file
View file

@ -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

59
app/telegram.py Normal file
View file

@ -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

8
docker-compose.yml Normal file
View file

@ -0,0 +1,8 @@
services:
tvsignals:
build: .
ports:
- "${PORT:-8000}:8000"
env_file:
- .env
restart: unless-stopped

19
render.yaml Normal file
View file

@ -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

9
requirements.txt Normal file
View file

@ -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