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 ## Behavior
1. Validate payload 1. Validate secret + payload, then immediately respond `200` (`{"ok": true, "accepted": true}`) so TradingView does not time out
2. Fetch ~90 klines from Binance USDT-M Futures (public, no API key) 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`) 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` 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`) 5. If chart/klines fail → text-only `sendMessage` fallback
6. If Telegram fails → `502` 6. If Telegram fails → logged only (HTTP already returned `200`)
## Endpoints ## Endpoints

View file

@ -1,17 +1,18 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
import secrets import secrets
from typing import Any from typing import Any
from fastapi import FastAPI, HTTPException, Request from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import ValidationError from pydantic import ValidationError
from app.binance import fetch_klines, to_binance_interval, to_binance_symbol from app.binance import fetch_klines, to_binance_interval, to_binance_symbol
from app.chart import render_setup_chart 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.formatter import format_caption
from app.models import SignalPayload from app.models import SignalPayload
from app.telegram import TelegramError, send_message, send_photo 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 raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc
@app.api_route("/health", methods=["GET", "HEAD"]) async def _deliver_signal(
async def health() -> dict[str, str]: settings: Settings,
return {"status": "ok"} signal: SignalPayload,
message_thread_id: int,
) -> None:
async def _handle_webhook( """Fetch chart + post to Telegram after the webhook HTTP response is sent."""
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())
caption = format_caption(signal) 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 photo: bytes | None = None
chart_error: str | None = None chart_error: str | None = None
@ -88,10 +67,11 @@ async def _handle_webhook(
symbol = to_binance_symbol(signal.ticker) symbol = to_binance_symbol(signal.ticker)
interval = to_binance_interval(signal.visual_timeframe) interval = to_binance_interval(signal.visual_timeframe)
df = await fetch_klines(symbol, interval) df = await fetch_klines(symbol, interval)
photo = render_setup_chart( photo = await asyncio.to_thread(
render_setup_chart,
df, df,
ticker=signal.ticker, ticker=signal.ticker,
action=signal.action.value, # type: ignore[arg-type] action=signal.action.value,
entry=signal.entry_price, entry=signal.entry_price,
stop_loss=signal.stop_loss_price, stop_loss=signal.stop_loss_price,
tp1=signal.take_profit_1_price, tp1=signal.take_profit_1_price,
@ -115,35 +95,81 @@ async def _handle_webhook(
caption=caption, caption=caption,
message_thread_id=message_thread_id, message_thread_id=message_thread_id,
) )
return JSONResponse( logger.info(
{"ok": True, "delivered": "photo", "chart_error": None}, "Delivered photo: %s %s seq=%s thread=%s",
status_code=200, signal.ticker,
signal.action.value,
signal.signal_sequence,
message_thread_id,
) )
return
await send_message( await send_message(
settings, settings,
text=caption, text=caption,
message_thread_id=message_thread_id, message_thread_id=message_thread_id,
) )
return JSONResponse( logger.info(
{ "Delivered text: %s %s seq=%s thread=%s chart_error=%s",
"ok": True, signal.ticker,
"delivered": "text", signal.action.value,
"chart_error": chart_error, signal.signal_sequence,
}, message_thread_id,
status_code=200, chart_error,
) )
except TelegramError as exc: except TelegramError as exc:
logger.exception("Telegram delivery failed: %s", exc) logger.exception("Telegram delivery failed: %s", exc)
raise HTTPException(status_code=502, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.exception("Unexpected delivery error: %s", exc) 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}") @app.post("/h/{token}")
async def webhook(token: str, request: Request) -> JSONResponse: async def webhook(
return await _handle_webhook(token, request, thread_id=None) 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}") @app.post("/h/{token}/{thread_id}")
@ -151,7 +177,10 @@ async def webhook_with_thread(
token: str, token: str,
thread_id: int, thread_id: int,
request: Request, request: Request,
background_tasks: BackgroundTasks,
) -> JSONResponse: ) -> JSONResponse:
if thread_id < 1: if thread_id < 1:
raise HTTPException(status_code=422, detail="thread_id must be >= 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
)