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:
Artemii Peretiachenko 2026-07-31 10:42:05 +02:00
parent eba9df453c
commit c2571f4558
2 changed files with 80 additions and 51 deletions

View file

@ -143,12 +143,12 @@ For seq >1, profit % is signed vs entry; RR is `|priceentry| / |entrySL|`
## 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 / 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 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

View file

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