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
|
## 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 / TP1–3 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 / 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`
|
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
|
||||||
|
|
||||||
|
|
|
||||||
123
app/main.py
123
app/main.py
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue