tvsignals-to-tg/app/heryon.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

81 lines
2.8 KiB
Python

from __future__ import annotations
import logging
from typing import Any
import httpx
from app.binance import get_tick_size
from app.config import Settings
from app.indicators.common import IndicatorSignal, calc_size, format_px, round_to_mintick
logger = logging.getLogger(__name__)
LTF_FIX = {"tp1_fix": "30%", "tp2_fix": "30%", "tp3_fix": "40%"}
FVG_FIX = {"tp1_fix": "30%"}
def to_tv_perp_ticker(symbol: str) -> str:
base = symbol.strip().upper()
if base.endswith(".P"):
return base
return f"{base}.P"
def heryon_nonce(ticker: str, side: str, bar_open_ts: int) -> str:
limit_side = "long" if side == "long" else "short"
return f"{ticker}-limit-{limit_side}-{bar_open_ts}"
def build_heryon_payload(
signal: IndicatorSignal,
*,
symbol: str,
settings: Settings,
) -> dict[str, Any]:
ticker = to_tv_perp_ticker(symbol)
tick = get_tick_size(symbol)
action = "buy" if signal.side == "long" else "sell"
fixes = LTF_FIX if signal.strategy_id == "ltf" else FVG_FIX
entry = round_to_mintick(signal.entry, tick)
sl = round_to_mintick(signal.sl, tick)
size = calc_size(entry, sl, float(settings.risk_usd))
size_str = "" if size != size else str(int(round(size)))
payload: dict[str, Any] = {
"action": action,
"ticker": ticker,
"account_id": settings.geryon_account_id_for(signal.strategy_id),
"order_type": settings.geryon_order_type,
"position_size_usd": size_str,
"stop_loss": format_px(signal.sl, tick),
"tp1": format_px(signal.tp1, tick),
"tp1_fix": fixes.get("tp1_fix", ""),
"sl_to_bk": "tp1",
"nonce": heryon_nonce(ticker, signal.side, signal.bar_open_ts),
"secret": settings.geryon_secret_for(signal.strategy_id),
}
if signal.strategy_id == "ltf":
payload["tp2"] = format_px(signal.tp2, tick)
payload["tp2_fix"] = fixes.get("tp2_fix", "")
payload["tp3"] = format_px(signal.tp3, tick)
payload["tp3_fix"] = fixes.get("tp3_fix", "")
return {key: value for key, value in payload.items() if value != ""}
async def send_heryon(settings: Settings, payload: dict[str, Any]) -> None:
url = (settings.geryon_webhook_url or "").strip()
if not url:
logger.info("Heryon skipped (GERYON_WEBHOOK_URL empty): %s", payload.get("nonce"))
return
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.post(url, json=payload)
if response.status_code >= 400:
raise RuntimeError(
f"Heryon webhook {response.status_code}: {response.text[:500]}"
)
logger.info(
"Heryon accepted nonce=%s ticker=%s action=%s",
payload.get("nonce"),
payload.get("ticker"),
payload.get("action"),
)