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