tvsignals-to-tg/app/main.py
Artemii Peretiachenko cdbda8fea3 Replace TradingView polling with a local LTF/FVG scanner that posts Telegram cards and Heryon webhooks.
Per-strategy Heryon accounts, reversal captions with previous-trade path on charts, and Telegram replies chained by ticker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 20:37:16 +02:00

139 lines
3.7 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import secrets
from contextlib import asynccontextmanager
from typing import Any
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from app.config import Settings, get_settings
from app.models import SignalPayload
from app.pipeline import deliver_telegram
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(_app: FastAPI):
settings = get_settings()
task: asyncio.Task[None] | None = None
if settings.scanner_enabled:
from app.scanner import run_scanner
task = asyncio.create_task(run_scanner(settings))
logger.info(
"Scanner enabled watchlist=%s poll=%ss",
settings.watchlist_path,
settings.scanner_poll_seconds,
)
yield
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
logger.info("Scanner stopped")
app = FastAPI(
title="TV Signals → Telegram",
version="1.0.0",
docs_url=None,
redoc_url=None,
openapi_url=None,
lifespan=lifespan,
)
def _parse_signal_body(raw: bytes) -> SignalPayload:
"""Parse JSON from raw body (works for application/json and text/plain)."""
try:
text = raw.decode("utf-8").strip()
except UnicodeDecodeError as exc:
raise HTTPException(status_code=422, detail="Body must be UTF-8 text") from exc
if not text:
raise HTTPException(status_code=422, detail="Empty body")
try:
data: Any = json.loads(text)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=422, detail=f"Invalid JSON: {exc}") from exc
try:
return SignalPayload.model_validate(data)
except ValidationError as exc:
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,
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_telegram,
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,
background_tasks: BackgroundTasks,
) -> JSONResponse:
return await _handle_webhook(
token, request, thread_id=None, background_tasks=background_tasks
)
@app.post("/h/{token}/{thread_id}")
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, background_tasks=background_tasks
)