fc735f251a
1. hyperliquid.open_position now returns effective_leverage. HL clips the requested leverage to each asset's max (e.g. memes capped at 3×) and silently applied the lower value; we were discarding that. For System-2 this meant sys2_protective_stop_pct was computed against the REQUESTED leverage, so the "inside-liquidation" full-close rung was actually well inside an 8.5% stop while the real liquidation sat ~33% away — strategy intent broken. bot_engine now reads the effective value back, recomputes the protective stop + derisk ladder against it, and freezes the correct values into BotTrade.eff_* / .leverage. 2. circuit_breaker.clear_trip now clears BOTH systems by default. The sys2 column was never reachable from any reset path — a sys2 trip forced users to wait the full 24h lockout even after explicit human re-arm. 3. /api/user/.../manual-window and /auto-trade re-arm endpoints now clear sys1 AND sys2 CB on the same human ack. Symmetry with check_and_trip / is_tripped which already supported both systems. 4. telegram.py deep-link map: btc_bottom_reversal + funding_reversal alerts now point at /en/macro instead of the now-404 /en/btc. 5. (Already landed: _sys2_mode UnboundLocalError fix in bot_engine — restated here for the audit trail.) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
281 lines
11 KiB
Python
281 lines
11 KiB
Python
"""
|
||
Telegram push alerts — send + signal dispatcher.
|
||
|
||
This module is fire-and-forget by design. `notify_signal()` returns immediately
|
||
to the caller (signal ingestion path) and dispatches in a background task. A
|
||
DB failure or Telegram API error MUST NOT block a signal from being saved.
|
||
|
||
The bot token is loaded from `settings.telegram_bot_token`. If empty, every
|
||
function in this module degrades into a no-op (and logs once at module load).
|
||
|
||
notify_signal(post) ← called from /api/signals/ingest
|
||
send_test_message(wallet) ← called from /api/telegram/test
|
||
send_message(chat_id, text) ← low-level escape hatch
|
||
|
||
Source → user-toggle mapping:
|
||
truth → alert_trump
|
||
btc_bottom_reversal → alert_btc_bottom
|
||
funding_reversal → alert_funding
|
||
kol_divergence (future) → alert_kol_divergence
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
from datetime import datetime, timezone
|
||
from typing import Optional
|
||
|
||
import httpx
|
||
from sqlalchemy import select, update
|
||
|
||
from app.config import settings
|
||
from app.database import AsyncSessionLocal as async_session
|
||
from app.models import Post, TelegramBinding
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
TG_API = "https://api.telegram.org/bot{token}/{method}"
|
||
# Telegram caps a single message at 4096 chars; we render way below this.
|
||
MAX_LEN = 3500
|
||
|
||
|
||
# ── Source → preference column mapping ────────────────────────────────────
|
||
|
||
|
||
def _pref_column_for_source(source: str) -> Optional[str]:
|
||
"""Which user-toggle column gates this source. None → unknown source,
|
||
don't send."""
|
||
if source == "truth":
|
||
return "alert_trump"
|
||
if source == "btc_bottom_reversal":
|
||
return "alert_btc_bottom"
|
||
if source == "funding_reversal":
|
||
return "alert_funding"
|
||
if source == "kol_divergence":
|
||
return "alert_kol_divergence"
|
||
return None
|
||
|
||
|
||
def _is_in_mute_window(now_hour: int, mute_from: Optional[int],
|
||
mute_until: Optional[int]) -> bool:
|
||
"""Both null → never mute. start<until → mute inside [start, until).
|
||
start>until → window wraps midnight (e.g. 23..7 → mute 23, 0–6)."""
|
||
if mute_from is None or mute_until is None:
|
||
return False
|
||
if mute_from == mute_until:
|
||
return False
|
||
if mute_from < mute_until:
|
||
return mute_from <= now_hour < mute_until
|
||
# wraps midnight
|
||
return now_hour >= mute_from or now_hour < mute_until
|
||
|
||
|
||
# ── Formatting ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _signal_emoji(post: Post) -> str:
|
||
if post.signal == "buy":
|
||
return "🟢"
|
||
if post.signal == "short":
|
||
return "🔴"
|
||
return "⚪"
|
||
|
||
|
||
def _source_label(source: str) -> str:
|
||
return {
|
||
"truth": "Trump · Truth Social",
|
||
"btc_bottom_reversal": "BTC · Macro Bottom",
|
||
"funding_reversal": "BTC · Funding Reversal",
|
||
"kol_divergence": "KOL · Talks vs Trades",
|
||
}.get(source, source)
|
||
|
||
|
||
def format_post(post: Post) -> str:
|
||
"""Render a Post into a single Telegram message (HTML parse mode).
|
||
Keep it scannable: heading, one-line verdict, the underlying text, link."""
|
||
emoji = _signal_emoji(post)
|
||
src = _source_label(post.source)
|
||
sig = (post.signal or "noise").upper()
|
||
asset = post.target_asset or "?"
|
||
conf = post.ai_confidence or 0
|
||
|
||
# Heading: emoji + asset + direction + confidence
|
||
head = f"{emoji} <b>{asset} · {sig}</b> · conf <b>{conf}</b>"
|
||
sub = f"<i>{src}</i>"
|
||
|
||
body = (post.text or "").strip()
|
||
if len(body) > 600:
|
||
body = body[:600].rstrip() + "…"
|
||
|
||
# Move size hint if present (BTC bottom & funding emit expected_move_pct)
|
||
extra = ""
|
||
if post.expected_move_pct is not None:
|
||
extra = f"\n📐 expected move: <b>{post.expected_move_pct:+.1f}%</b>"
|
||
if post.invalidation_price is not None:
|
||
extra += f"\n🛑 invalidation @ <code>{post.invalidation_price:g}</code>"
|
||
|
||
# Deep-link back to the dashboard. Frontend URL comes from settings.
|
||
fe = (settings.frontend_url or "").rstrip("/")
|
||
link = ""
|
||
if fe:
|
||
# Use the section that matches the source
|
||
path = {
|
||
"truth": "/en/trump",
|
||
"btc_bottom_reversal": "/en/macro",
|
||
"funding_reversal": "/en/macro",
|
||
"kol_divergence": "/en/kol",
|
||
}.get(post.source, "/en")
|
||
link = f'\n\n<a href="{fe}{path}">→ open in dashboard</a>'
|
||
|
||
msg = f"{head}\n{sub}\n\n{body}{extra}{link}"
|
||
return msg[:MAX_LEN]
|
||
|
||
|
||
# ── Low-level HTTP ────────────────────────────────────────────────────────
|
||
|
||
|
||
async def send_message(chat_id: int, text: str, *,
|
||
parse_mode: str = "HTML",
|
||
disable_preview: bool = True) -> bool:
|
||
"""Single HTTP POST to Telegram Bot API. Returns True on 200, False on
|
||
any failure (caller decides whether to bump the failure counter)."""
|
||
token = settings.telegram_bot_token
|
||
if not token:
|
||
return False
|
||
url = TG_API.format(token=token, method="sendMessage")
|
||
try:
|
||
async with httpx.AsyncClient(timeout=10) as client:
|
||
r = await client.post(url, json={
|
||
"chat_id": chat_id, "text": text,
|
||
"parse_mode": parse_mode,
|
||
"disable_web_page_preview": disable_preview,
|
||
})
|
||
if r.status_code != 200:
|
||
logger.warning("Telegram sendMessage failed chat=%d status=%d body=%s",
|
||
chat_id, r.status_code, r.text[:200])
|
||
return False
|
||
return True
|
||
except Exception as exc:
|
||
logger.warning("Telegram sendMessage exception chat=%d: %s", chat_id, exc)
|
||
return False
|
||
|
||
|
||
# ── Dispatcher ────────────────────────────────────────────────────────────
|
||
|
||
|
||
async def _dispatch(post_id: int) -> None:
|
||
"""Fan-out a single Post to every eligible subscriber. Always runs in
|
||
its own DB session so signal ingestion's session is unaffected."""
|
||
if not settings.telegram_bot_token:
|
||
return
|
||
|
||
async with async_session() as db:
|
||
post = await db.get(Post, post_id)
|
||
if not post:
|
||
logger.warning("Telegram dispatch: post id=%d not found", post_id)
|
||
return
|
||
|
||
# Only fan out for actionable signals (not NOISE / null)
|
||
if not post.signal or post.signal not in ("buy", "short"):
|
||
return
|
||
|
||
pref_col = _pref_column_for_source(post.source)
|
||
if pref_col is None:
|
||
logger.debug("Telegram: unknown source %r — not fanning out", post.source)
|
||
return
|
||
|
||
# Build the query: alerts_enabled + the relevant per-source flag.
|
||
# min_confidence applies to every source — scanner-emitted signals
|
||
# carry their own confidence in the Post.ai_confidence column.
|
||
col = getattr(TelegramBinding, pref_col)
|
||
base_filters = [
|
||
TelegramBinding.alerts_enabled.is_(True),
|
||
col.is_(True),
|
||
]
|
||
# Only apply confidence gate when the post has a real score (> 0).
|
||
# Scanner-generated signals (funding, BTC) always carry a score, but
|
||
# a Truth-Social post might be dispatched before Claude scores it (score=0).
|
||
# In that edge case we let it through so no alert is silently swallowed.
|
||
if post.ai_confidence and post.ai_confidence > 0:
|
||
base_filters.append(TelegramBinding.min_confidence <= post.ai_confidence)
|
||
q = select(TelegramBinding).where(*base_filters)
|
||
bindings = (await db.execute(q)).scalars().all()
|
||
|
||
if not bindings:
|
||
return
|
||
|
||
text = format_post(post)
|
||
now = datetime.now(timezone.utc)
|
||
hour = now.hour
|
||
|
||
sent = 0
|
||
for b in bindings:
|
||
if _is_in_mute_window(hour, b.mute_from_hour, b.mute_until_hour):
|
||
continue
|
||
ok = await send_message(b.chat_id, text)
|
||
# Update audit counters per binding. Single UPDATE per row keeps
|
||
# us out of trouble if one user blocks the bot.
|
||
if ok:
|
||
await db.execute(
|
||
update(TelegramBinding)
|
||
.where(TelegramBinding.id == b.id)
|
||
.values(
|
||
last_alert_at=now,
|
||
total_alerts_sent=TelegramBinding.total_alerts_sent + 1,
|
||
)
|
||
)
|
||
sent += 1
|
||
else:
|
||
await db.execute(
|
||
update(TelegramBinding)
|
||
.where(TelegramBinding.id == b.id)
|
||
.values(
|
||
total_alerts_failed=TelegramBinding.total_alerts_failed + 1,
|
||
)
|
||
)
|
||
await db.commit()
|
||
logger.info("Telegram fan-out: post=%d source=%s sent=%d/%d",
|
||
post_id, post.source, sent, len(bindings))
|
||
|
||
|
||
def notify_signal(post: Post) -> None:
|
||
"""Fire-and-forget. Schedules `_dispatch(post.id)` on the running loop
|
||
and returns immediately. Safe to call from any async context — falls
|
||
back to a no-op if telegram is disabled or no event loop is running.
|
||
|
||
We pass post.id rather than the post object because the caller's DB
|
||
session might close before our background task runs."""
|
||
if not settings.telegram_bot_token:
|
||
return
|
||
if not post or not post.id:
|
||
return
|
||
try:
|
||
asyncio.create_task(_dispatch(post.id))
|
||
except RuntimeError:
|
||
# No running loop — extremely unusual in our FastAPI context.
|
||
logger.warning("notify_signal: no running event loop, skipping post=%s", post.id)
|
||
|
||
|
||
async def send_test_message(wallet: str) -> bool:
|
||
"""Triggered from the Settings UI to verify the binding works end-to-end."""
|
||
if not settings.telegram_bot_token:
|
||
return False
|
||
async with async_session() as db:
|
||
b = (await db.execute(
|
||
select(TelegramBinding).where(TelegramBinding.wallet_address == wallet.lower())
|
||
)).scalar_one_or_none()
|
||
if not b:
|
||
return False
|
||
return await send_message(
|
||
b.chat_id,
|
||
"✅ <b>Trump Alpha connected.</b>\n\n"
|
||
"You'll get push alerts here when signals fire. "
|
||
"Use /trump /btc /funding /kol /conf /quiet in this chat to tune "
|
||
"which sources and thresholds apply to you.",
|
||
)
|
||
|
||
|
||
if not settings.telegram_bot_token:
|
||
logger.info("Telegram bot token not set — push alerts disabled.")
|