mirror of
https://github.com/artemium428/tvsignals-to-tg.git
synced 2026-09-15 17:16:21 +00:00
Initial TradingView→Telegram webhook service with Render Blueprint.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
d6c539dca9
15 changed files with 1111 additions and 0 deletions
5
.env.example
Normal file
5
.env.example
Normal 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
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
.env
|
||||
.env*
|
||||
!.env.example
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.png
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.DS_Store
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal 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
150
README.md
Normal 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 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
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
151
app/binance.py
Normal file
151
app/binance.py
Normal 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
365
app/chart.py
Normal 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
22
app/config.py
Normal 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
130
app/formatter.py
Normal 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
114
app/main.py
Normal 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
46
app/models.py
Normal 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
59
app/telegram.py
Normal 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
8
docker-compose.yml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
services:
|
||||
tvsignals:
|
||||
build: .
|
||||
ports:
|
||||
- "${PORT:-8000}:8000"
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
19
render.yaml
Normal file
19
render.yaml
Normal 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
9
requirements.txt
Normal 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
|
||||
Loading…
Add table
Reference in a new issue