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"), )