From c2571f45589c0f749c74df1d003c63097f5ec818 Mon Sep 17 00:00:00 2001 From: Artemii Peretiachenko Date: Fri, 31 Jul 2026 10:42:05 +0200 Subject: [PATCH] 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 --- README.md | 8 ++-- app/main.py | 123 ++++++++++++++++++++++++++++++++-------------------- 2 files changed, 80 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 15c9a62..abc0f9f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/main.py b/app/main.py index 97edaa0..192aa92 100644 --- a/app/main.py +++ b/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 + )