mirror of
https://github.com/artemium428/tvsignals-to-tg.git
synced 2026-09-15 17:16:21 +00:00
Accept TradingView webhooks immediately and deliver in background.
Chart and Telegram work no longer block the HTTP response, avoiding TV webhook timeouts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
eba9df453c
commit
c2571f4558
2 changed files with 80 additions and 51 deletions
|
|
@ -143,12 +143,12 @@ For seq >1, profit % is signed vs entry; RR is `|price−entry| / |entry−SL|`
|
|||
|
||||
## Behavior
|
||||
|
||||
1. Validate payload
|
||||
2. Fetch ~90 klines from Binance USDT-M Futures (public, no API key)
|
||||
1. Validate secret + payload, then immediately respond `200` (`{"ok": true, "accepted": true}`) so TradingView does not time out
|
||||
2. In the background: 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 from URL path or env `TELEGRAM_MESSAGE_THREAD_ID`
|
||||
5. If chart/klines fail → text-only `sendMessage` fallback (still `200`)
|
||||
6. If Telegram fails → `502`
|
||||
5. If chart/klines fail → text-only `sendMessage` fallback
|
||||
6. If Telegram fails → logged only (HTTP already returned `200`)
|
||||
|
||||
## Endpoints
|
||||
|
||||
|
|
|
|||
123
app/main.py
123
app/main.py
|
|
@ -1,17 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi import BackgroundTasks, 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.config import Settings, get_settings
|
||||
from app.formatter import format_caption
|
||||
from app.models import SignalPayload
|
||||
from app.telegram import TelegramError, send_message, send_photo
|
||||
|
|
@ -52,35 +53,13 @@ def _parse_signal_body(raw: bytes) -> SignalPayload:
|
|||
raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc
|
||||
|
||||
|
||||
@app.api_route("/health", methods=["GET", "HEAD"])
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def _handle_webhook(
|
||||
token: str,
|
||||
request: Request,
|
||||
thread_id: int | None,
|
||||
) -> JSONResponse:
|
||||
settings = get_settings()
|
||||
if not secrets.compare_digest(token, settings.webhook_secret):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
message_thread_id = (
|
||||
thread_id if thread_id is not None else settings.telegram_message_thread_id
|
||||
)
|
||||
|
||||
signal = _parse_signal_body(await request.body())
|
||||
async def _deliver_signal(
|
||||
settings: Settings,
|
||||
signal: SignalPayload,
|
||||
message_thread_id: int,
|
||||
) -> None:
|
||||
"""Fetch chart + post to Telegram after the webhook HTTP response is sent."""
|
||||
caption = format_caption(signal)
|
||||
logger.info(
|
||||
"Signal received: %s %s seq=%s tf=%s thread=%s",
|
||||
signal.ticker,
|
||||
signal.action.value,
|
||||
signal.signal_sequence,
|
||||
signal.visual_timeframe,
|
||||
message_thread_id,
|
||||
)
|
||||
|
||||
photo: bytes | None = None
|
||||
chart_error: str | None = None
|
||||
|
||||
|
|
@ -88,10 +67,11 @@ async def _handle_webhook(
|
|||
symbol = to_binance_symbol(signal.ticker)
|
||||
interval = to_binance_interval(signal.visual_timeframe)
|
||||
df = await fetch_klines(symbol, interval)
|
||||
photo = render_setup_chart(
|
||||
photo = await asyncio.to_thread(
|
||||
render_setup_chart,
|
||||
df,
|
||||
ticker=signal.ticker,
|
||||
action=signal.action.value, # type: ignore[arg-type]
|
||||
action=signal.action.value,
|
||||
entry=signal.entry_price,
|
||||
stop_loss=signal.stop_loss_price,
|
||||
tp1=signal.take_profit_1_price,
|
||||
|
|
@ -115,35 +95,81 @@ async def _handle_webhook(
|
|||
caption=caption,
|
||||
message_thread_id=message_thread_id,
|
||||
)
|
||||
return JSONResponse(
|
||||
{"ok": True, "delivered": "photo", "chart_error": None},
|
||||
status_code=200,
|
||||
logger.info(
|
||||
"Delivered photo: %s %s seq=%s thread=%s",
|
||||
signal.ticker,
|
||||
signal.action.value,
|
||||
signal.signal_sequence,
|
||||
message_thread_id,
|
||||
)
|
||||
return
|
||||
|
||||
await send_message(
|
||||
settings,
|
||||
text=caption,
|
||||
message_thread_id=message_thread_id,
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"delivered": "text",
|
||||
"chart_error": chart_error,
|
||||
},
|
||||
status_code=200,
|
||||
logger.info(
|
||||
"Delivered text: %s %s seq=%s thread=%s chart_error=%s",
|
||||
signal.ticker,
|
||||
signal.action.value,
|
||||
signal.signal_sequence,
|
||||
message_thread_id,
|
||||
chart_error,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@app.api_route("/health", methods=["GET", "HEAD"])
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def _handle_webhook(
|
||||
token: str,
|
||||
request: Request,
|
||||
thread_id: int | None,
|
||||
background_tasks: BackgroundTasks,
|
||||
) -> JSONResponse:
|
||||
settings = get_settings()
|
||||
if not secrets.compare_digest(token, settings.webhook_secret):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
message_thread_id = (
|
||||
thread_id if thread_id is not None else settings.telegram_message_thread_id
|
||||
)
|
||||
|
||||
signal = _parse_signal_body(await request.body())
|
||||
logger.info(
|
||||
"Signal accepted: %s %s seq=%s tf=%s thread=%s",
|
||||
signal.ticker,
|
||||
signal.action.value,
|
||||
signal.signal_sequence,
|
||||
signal.visual_timeframe,
|
||||
message_thread_id,
|
||||
)
|
||||
|
||||
background_tasks.add_task(
|
||||
_deliver_signal,
|
||||
settings,
|
||||
signal,
|
||||
message_thread_id,
|
||||
)
|
||||
return JSONResponse({"ok": True, "accepted": True}, status_code=200)
|
||||
|
||||
|
||||
@app.post("/h/{token}")
|
||||
async def webhook(token: str, request: Request) -> JSONResponse:
|
||||
return await _handle_webhook(token, request, thread_id=None)
|
||||
async def webhook(
|
||||
token: str,
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
) -> JSONResponse:
|
||||
return await _handle_webhook(
|
||||
token, request, thread_id=None, background_tasks=background_tasks
|
||||
)
|
||||
|
||||
|
||||
@app.post("/h/{token}/{thread_id}")
|
||||
|
|
@ -151,7 +177,10 @@ async def webhook_with_thread(
|
|||
token: str,
|
||||
thread_id: int,
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
) -> JSONResponse:
|
||||
if thread_id < 1:
|
||||
raise HTTPException(status_code=422, detail="thread_id must be >= 1")
|
||||
return await _handle_webhook(token, request, thread_id=thread_id)
|
||||
return await _handle_webhook(
|
||||
token, request, thread_id=thread_id, background_tasks=background_tasks
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue