Files
momentum-system/runtime/notifier.py
T
momentum devandClaude Opus 4.8 59e296a736 Telegram bot: gated push alerts + standalone Ollama Q&A
C1 — runtime/notifier.py: gated, fire-and-forget push alerts that NEVER raise
into the loop. Decoupled by POLLING the event store (no notifier threaded
through breaker/exit/execution). Pushes on major events only: daily-loss
breaker, catastrophe-stop, naked-stop flatten, any |closed trade| > $300, and a
once-per-day EOD summary. Wired as a 45s scheduler step + EOD summary/failure
hooks in step_eod_flatten. Boot ping on start. Silent no-op when unconfigured.

C2 — telegram_bot.py: standalone (separate process) READ-ONLY Q&A listener,
locked to TELEGRAM_CHAT_ID. Long-polls getUpdates; gathers a Postgres data
snapshot (today P&L, open positions, recent trades, all-time) and asks the
local Ollama model. Falls back to the raw snapshot if Ollama is down/disabled;
refuses any action command (buy/sell/flatten) — questions only.

Tests: disabled no-op, major-event dedup, EOD-once, auth gate, action-refusal,
ollama-down + ollama-disabled fallback. 8/8 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:29:15 -04:00

139 lines
5.5 KiB
Python

"""Telegram push notifier — gated alerts on MAJOR trading events.
Fully gated like the Ollama integration: if TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID
are unset, every call is a SILENT no-op that NEVER raises into the trading loop.
Decoupled by design: it POLLS the event store for major events rather than
threading a notifier through the breaker / exit / execution subsystems, so it
cannot slow or break trading. One small scheduler step calls ``poll()``.
Major events (the only things worth a phone buzz):
- daily-loss breaker trip (risk_rule_triggered rule_type=daily_loss)
- catastrophe-stop exit (rule_type=exit_catastrophe)
- naked-stop enforcement / flatten (rule_type=exit_naked_stop)
- any closed trade with |realized| > BIG_TRADE_USD
- a once-per-day EOD summary (send_eod_summary, called by the loop)
"""
from __future__ import annotations
import json
import logging
import os
import urllib.request
logger = logging.getLogger(__name__)
MAJOR_RULES = {"daily_loss", "exit_catastrophe", "exit_naked_stop"}
BIG_TRADE_USD = 300.0
_API = "https://api.telegram.org"
def telegram_enabled() -> bool:
return bool(os.environ.get("TELEGRAM_BOT_TOKEN")
and os.environ.get("TELEGRAM_CHAT_ID"))
def send_telegram(text: str, *, token: str | None = None,
chat_id: str | None = None, timeout: int = 10) -> bool:
"""Send one message. Gated + fire-and-forget: returns False (never raises)
if unconfigured or on any error — trading must never be impeded by Telegram."""
token = token or os.environ.get("TELEGRAM_BOT_TOKEN")
chat_id = chat_id or os.environ.get("TELEGRAM_CHAT_ID")
if not token or not chat_id:
return False
try:
req = urllib.request.Request(
f"{_API}/bot{token}/sendMessage",
data=json.dumps({"chat_id": chat_id, "text": text,
"disable_web_page_preview": True}).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return bool(json.loads(resp.read().decode()).get("ok"))
except Exception: # noqa: BLE001 — must never raise into the loop
logger.debug("telegram send failed", exc_info=True)
return False
def _fmt_rule(p: dict) -> str:
rt = p.get("rule_type")
icon = {"exit_catastrophe": "\U0001F6D1", "exit_naked_stop": "⚠️",
"daily_loss": "\U0001F53B"}.get(rt, "❗")
return f"{icon} {p.get('message') or rt}"
def _fmt_close(p: dict) -> str:
pnl = p.get("realized_pnl") or 0.0
icon = "\U0001F7E2" if pnl > 0 else "\U0001F534"
return f"{icon} {p.get('symbol')} closed {pnl:+.0f} ({p.get('exit_reason') or ''})"
def fmt_eod(for_date: str, pnl: dict) -> str:
total = pnl.get("total_pnl", 0.0)
icon = "\U0001F4C8" if total >= 0 else "\U0001F4C9"
wr = pnl.get("win_rate")
wr_s = f"{wr*100:.0f}%" if wr is not None else "n/a"
return (f"{icon} EOD {for_date}: {total:+.0f}\n"
f"closed {pnl.get('closed_trades', '?')} "
f"W/L {pnl.get('wins', '?')}/{pnl.get('losses', '?')} win% {wr_s}")
class TelegramNotifier:
"""Polls the event store and pushes one message per NEW major event since it
started. In-memory dedup; primes 'seen' at startup so history is never
replayed. All methods are no-ops + non-raising when Telegram is unconfigured."""
def __init__(self, store, *, big_trade_usd: float = BIG_TRADE_USD):
self.store = store
self.big_trade_usd = big_trade_usd
self._seen: set = set()
self._eod_sent_date: str | None = None
self.enabled = telegram_enabled()
if self.enabled:
try:
self._scan(notify=False) # prime: mark existing events as seen
except Exception: # noqa: BLE001
logger.debug("notifier prime failed", exc_info=True)
def poll(self) -> int:
"""One poll cycle. Returns messages sent. Never raises."""
if not self.enabled:
return 0
try:
return self._scan(notify=True)
except Exception: # noqa: BLE001
logger.debug("notifier poll failed", exc_info=True)
return 0
def _scan(self, *, notify: bool) -> int:
sent = 0
for e in self.store.query_events(event_type="risk_rule_triggered", limit=200):
p = json.loads(e["payload_json"])
if p.get("rule_type") not in MAJOR_RULES:
continue
key = ("rule", p.get("rule_type"), str(e["timestamp"]), p.get("message"))
if key in self._seen:
continue
self._seen.add(key)
if notify and send_telegram(_fmt_rule(p)):
sent += 1
for e in self.store.query_events(event_type="position_closed", limit=200):
p = json.loads(e["payload_json"])
if abs(p.get("realized_pnl") or 0.0) < self.big_trade_usd:
continue
key = ("close", p.get("symbol"), str(e["timestamp"]))
if key in self._seen:
continue
self._seen.add(key)
if notify and send_telegram(_fmt_close(p)):
sent += 1
return sent
def send_eod_summary(self, for_date: str, pnl: dict) -> bool:
"""One-per-day EOD summary. ``pnl`` = query_session_pnl(...) dict."""
if not self.enabled or self._eod_sent_date == for_date:
return False
self._eod_sent_date = for_date
return send_telegram(fmt_eod(for_date, pnl))