Files
momentum-system/telegram_bot.py
T
momentum devandClaude Fable 5 5b95a9712c qabot: dead-man's switch on the loop's event heartbeat
The loop's Telegram notifier runs inside the loop process — loop-down means
alerts-down. The always-up qabot now reads max(events.timestamp) every ~120s
(ET-naive vs ET wall clock per RUNBOOK): stale > DEADMAN_STALE_MIN during
market hours (holiday-aware via runtime.market_calendar) alerts at most every
30 min; DEADMAN_DAILY_PING sends one OK-ping after 20:30 ET. Injected
clock/read/send; 6 unit tests, no network or DB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:37:01 -04:00

383 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Standalone Telegram Q&A bot — answers free-form questions about the trading
data, READ-ONLY, locked to the authorized chat, Ollama-backed.
Runs as its OWN process (`python telegram_bot.py`) so it can NEVER impede the
trading loop. It long-polls getUpdates; for each message from TELEGRAM_CHAT_ID it
RETRIEVES the relevant data from Postgres (a desk snapshot, plus a focused
per-symbol dive when the question names a ticker) and asks the local Ollama model
to answer. Falls back to the raw snapshot if Ollama is down. Never accepts trade
commands. Sends a help menu so the user knows what it can answer (direction).
Also the DEAD-MAN'S SWITCH: the loop's own notifier lives inside the loop
process and cannot report the loop's death — this always-up bot watches the
event heartbeat (max(events.timestamp)) from outside and alerts when it goes
stale during market hours.
Env: TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID (required), DATABASE_URL,
OLLAMA_HOST / OLLAMA_MODEL / OLLAMA_TIMEOUT_SECONDS / OLLAMA_ENABLED,
DEADMAN_STALE_MIN / DEADMAN_DAILY_PING.
"""
from __future__ import annotations
import json
import logging
import os
import re
import time
import urllib.request
from collections import Counter
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from runtime.market_calendar import is_regular_hours
logger = logging.getLogger("telegram_bot")
_ET = ZoneInfo("America/New_York")
_API = "https://api.telegram.org"
_ACTION_WORDS = ("buy ", "sell ", "flatten", "close all", "cancel ", "short ",
"liquidate", "exit all", "place order", "submit order", "go long")
_HELP_WORDS = ("help", "/help", "/start", "what can you", "what do you", "commands", "menu")
_STOPWORDS = {"P&L", "PNL", "EOD", "VWAP", "ORB", "USD", "AI", "OK", "ET", "AM", "PM",
"WHY", "HOW", "THE", "AND", "ARE", "WAS", "DID", "ANY", "ALL", "NOT",
"YOU", "OUR", "WHAT", "WHEN", "BEST", "WORST", "TODAY", "NOW", "R"}
HELP_TEXT = (
"\U0001F916 I'm your momentum desk assistant (read-only). Ask me in plain English about:\n"
"• today's P&L / trades — \"how did we do?\", \"best and worst trade?\"\n"
"• the book — \"what's open?\", \"how's PLUG doing?\"\n"
"• the watchlist — \"what's on the watchlist?\", \"anything ready?\"\n"
"• why we are / aren't trading — \"why no entries?\", \"what got blocked?\"\n"
"• a specific ticker — \"what happened with VTAK?\", \"why did we skip AMC?\"\n"
"• all-time — \"how are we doing overall?\"\n"
"I can't place or change trades — questions only."
)
def is_help(q: str) -> bool:
ql = q.strip().lower()
return ql in ("help", "/help", "/start", "menu") or any(w in ql for w in _HELP_WORDS)
# --------------------------------------------------------------- data retrieval
def _recent_closed(store, limit=12):
rows = []
for e in store.query_events(event_type="position_closed", limit=None):
p = json.loads(e["payload_json"])
rows.append((str(e["timestamp"]), p))
rows.sort(reverse=True)
return [p for _, p in rows[:limit]]
def known_symbols(store) -> set:
syms = set()
try:
for w in store.query_events(event_type="symbol_state_changed", limit=None):
s = json.loads(w["payload_json"]).get("symbol")
if s:
syms.add(str(s).upper())
except Exception: # noqa: BLE001
pass
return syms
def gather_context(store) -> str:
from storage.projections import (query_account_positions_snapshot,
query_session_pnl, query_alltime_score,
query_watch_states_snapshot,
query_ready_signals_snapshot)
L: list[str] = []
try:
p = query_session_pnl(store)
L.append(f"TODAY (live session) P&L: total {p.get('total_pnl', 0):+.0f} "
f"(realized {p.get('realized_pnl', 0):+.0f}, unrealized {p.get('unrealized_pnl', 0):+.0f}); "
f"closed {p.get('closed_trades', 0)} W/L {p.get('wins', 0)}/{p.get('losses', 0)}.")
except Exception as exc: # noqa: BLE001
L.append(f"(today P&L unavailable: {exc})")
try:
rc = _recent_closed(store, 12)
if rc:
L.append("RECENT CLOSED TRADES (newest first):")
for t in rc:
L.append(f" {t.get('symbol')}: {t.get('realized_pnl', 0):+.0f} "
f"@entry {t.get('entry_price')} ({t.get('exit_reason', '')})")
except Exception: # noqa: BLE001
pass
try:
snap = query_account_positions_snapshot(store)
positions = snap[0]["positions"] if snap else []
if positions:
L.append(f"OPEN POSITIONS ({len(positions)}):")
for q in positions:
L.append(f" {q.get('symbol')}: qty {q.get('quantity')} @ {q.get('avg_entry_price')} "
f"-> {q.get('current_price')} (uPL {q.get('unrealized_pnl', 0):+.0f})")
else:
L.append("OPEN POSITIONS: none (book is flat).")
except Exception: # noqa: BLE001
pass
try:
ws = query_watch_states_snapshot(store)
if ws:
by_state = Counter(str(w.get("state") or "unknown") for w in ws)
L.append(f"WATCHLIST: {len(ws)} symbols — " +
", ".join(f"{n} {st}" for st, n in by_state.most_common()))
hot = [w for w in ws if str(w.get("state")) in ("ready", "armed")]
for w in sorted(hot, key=lambda x: -(x.get("last_score") or 0))[:8]:
L.append(f" {w.get('symbol')}: {w.get('state')} (score {w.get('last_score')})")
except Exception: # noqa: BLE001
pass
try:
rs = query_ready_signals_snapshot(store)
if rs:
L.append(f"READY SIGNALS ({len(rs)}):")
for s in rs[:8]:
av = s.get("above_vwap")
L.append(f" {s.get('symbol')}: entry {s.get('entry_price')} stop {s.get('stop_loss_price')} "
f"vwap {s.get('vwap')} {'(above VWAP)' if av else '(BELOW VWAP)' if av is False else ''}")
except Exception: # noqa: BLE001
pass
try: # why we are/aren't entering — the recent block tally
blocks = Counter()
for e in store.query_events(event_type="risk_rule_triggered", limit=300):
blocks[json.loads(e["payload_json"]).get("rule_type")] += 1
if blocks:
L.append("RECENT ENTRY BLOCKS (why entries were skipped): " +
", ".join(f"{rt}×{n}" for rt, n in blocks.most_common(8)))
except Exception: # noqa: BLE001
pass
try:
a = query_alltime_score(store)
L.append(f"ALL-TIME: realized {a.get('total_realized', 0):+.0f} over "
f"{a.get('trading_days', '?')} days, win% {(a.get('win_rate') or 0)*100:.0f}, "
f"trades {a.get('trades', '?')}.")
except Exception: # noqa: BLE001
pass
return "\n".join(L)
def symbol_context(store, sym: str) -> str:
from storage.projections import query_symbol_criteria
L = [f"\n=== DETAIL: {sym} ==="]
try:
for t in _recent_closed(store, 50):
if str(t.get("symbol")).upper() == sym:
L.append(f"last trade: {t.get('realized_pnl', 0):+.0f} @entry {t.get('entry_price')} "
f"stop {t.get('stop_loss_price')} ({t.get('exit_reason', '')})")
break
except Exception: # noqa: BLE001
pass
try:
c = query_symbol_criteria(store, sym)
crit = c.get("criteria") or []
if crit:
failed = [x.get("key") or x.get("label") for x in crit if not x.get("passed")]
passed = [x.get("key") or x.get("label") for x in crit if x.get("passed")]
L.append(f"setup score {c.get('score')} ({c.get('passed_count')} criteria passed); "
f"passed: {', '.join(passed) or 'none'}; failed: {', '.join(failed) or 'none'}")
except Exception: # noqa: BLE001
pass
return "\n".join(L) if len(L) > 1 else ""
# ---------------------------------------------------------------------- ollama
def ask_ollama(question: str, context: str, *, host: str, model: str,
timeout: int) -> str | None:
prompt = (
"You are the assistant for a small-cap momentum DAY-TRADING bot (opening-range "
"breakouts on $1-20 gappers). Answer the user's question using ONLY the DATA below, "
"which is the bot's live state. Be concrete and brief (a few sentences). Quote the "
"real numbers. If the data doesn't contain the answer, say so and tell the user what "
"you DO have (P&L, trades, the watchlist, ready signals, entry blocks, per-symbol "
"setup detail). Never invent tickers or numbers.\n\n"
f"=== DATA ===\n{context}\n\n=== QUESTION ===\n{question}\n\n=== ANSWER ===\n")
try:
req = urllib.request.Request(
f"{host}/api/generate",
data=json.dumps({"model": model, "prompt": prompt, "stream": False,
"options": {"temperature": 0.2, "num_predict": 500}}).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
text = (json.loads(resp.read().decode()).get("response") or "").strip()
return text or None
except Exception: # noqa: BLE001
logger.debug("ollama unavailable", exc_info=True)
return None
def answer(store, question: str, *, host: str, model: str, timeout: int,
ollama_enabled: bool = True) -> str:
if is_help(question):
return HELP_TEXT
if any(w in (" " + question.lower() + " ") for w in _ACTION_WORDS):
return ("I'm read-only — I answer questions about the data but won't place or "
"change trades. Try \"how did we do today?\" or send \"help\".")
context = gather_context(store)
# focused per-symbol retrieval: tickers in the question that the bot actually knows
tokens = set(re.findall(r"\b[A-Z]{1,5}\b", question.upper())) - _STOPWORDS
known = known_symbols(store)
for sym in [t for t in tokens if t in known][:3]:
context += symbol_context(store, sym)
if ollama_enabled:
out = ask_ollama(question, context, host=host, model=model, timeout=timeout)
if out:
return out
return f"(LLM unavailable — here's the raw snapshot)\n{context}"
# ------------------------------------------------------------------- dead-man
def _now_et() -> datetime:
"""ET wall clock, tz-NAIVE — events.timestamp is ET-naive (RUNBOOK), so the
staleness math must be naive-vs-naive or it reads 4-5h stale from UTC."""
return datetime.now(_ET).replace(tzinfo=None)
def _max_event_ts(store) -> datetime | None:
"""Newest events.timestamp as a naive datetime (None on an empty store).
Raw query on purpose: query_events() orders ASC and stringifies timestamps."""
cur = store.con.cursor()
cur.execute("SELECT max(timestamp) FROM events")
row = cur.fetchone()
return row[0] if row else None
class DeadmanCheck:
"""Loop-death watchdog. During market hours (holiday-aware, from
runtime.market_calendar) a heartbeat older than DEADMAN_STALE_MIN minutes
raises ONE alert, re-raised at most every REALERT_MIN. After 20:30 ET a
once-daily ping (DEADMAN_DAILY_PING=1, default on) proves the watchdog
itself is alive. Clock/read/send are injectable for tests."""
CHECK_EVERY_S = 120
REALERT_MIN = 30
def __init__(self, read_max_ts, send, *, stale_min=None, daily_ping=None,
now_et=_now_et, is_market_hours=is_regular_hours):
self._read_max_ts = read_max_ts
self._send = send
self._stale_min = float(stale_min if stale_min is not None
else os.environ.get("DEADMAN_STALE_MIN", "5"))
dp = daily_ping if daily_ping is not None \
else os.environ.get("DEADMAN_DAILY_PING", "1")
self._daily_ping = str(dp) not in ("0", "false", "False")
self._now_et = now_et
self._is_market_hours = is_market_hours
self._next_check = 0.0
self._last_alerted: datetime | None = None
self._last_ping_date = None
def maybe_check(self) -> str | None:
"""Rate-limited entry point for the poll loop (wakes at least ~every 60s)."""
if time.monotonic() < self._next_check:
return None
self._next_check = time.monotonic() + self.CHECK_EVERY_S
return self.check()
def check(self) -> str | None:
"""Run one check now; returns the message sent, if any."""
now = self._now_et()
try:
max_ts = self._read_max_ts()
except Exception: # noqa: BLE001 — a flaky DB read must not kill the bot
logger.warning("deadman: heartbeat read failed", exc_info=True)
return None
stale_min = None if max_ts is None \
else (now - max_ts).total_seconds() / 60.0
msg = None
if self._is_market_hours(now) and (stale_min is None or stale_min > self._stale_min):
if (self._last_alerted is None
or now - self._last_alerted >= timedelta(minutes=self.REALERT_MIN)):
desc = "no events ever" if stale_min is None \
else f"no events for {stale_min:.0f} min"
msg = f"⚠️ DEAD-MAN: {desc} during market hours — loop may be down."
self._last_alerted = now
elif (self._daily_ping and (now.hour, now.minute) >= (20, 30)
and self._last_ping_date != now.date()):
self._last_ping_date = now.date()
if max_ts is not None and max_ts.date() == now.date():
msg = f"✅ heartbeat: loop wrote events today; last event {max_ts}."
else:
msg = f"⚠️ heartbeat: NO events today; last event {max_ts}."
if msg:
self._send(msg)
return msg
# ------------------------------------------------------------------- transport
def is_authorized(chat_id, allowed) -> bool:
return allowed is not None and str(chat_id) == str(allowed)
def _send(token: str, chat_id, text: str) -> None:
try:
req = urllib.request.Request(
f"{_API}/bot{token}/sendMessage",
data=json.dumps({"chat_id": chat_id, "text": text[:4000]}).encode(),
headers={"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=10).read()
except Exception: # noqa: BLE001
logger.warning("reply send failed", exc_info=True)
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), ".env"))
from runtime.env_overrides import load as _load_overrides
_load_overrides()
token = os.environ.get("TELEGRAM_BOT_TOKEN")
allowed = os.environ.get("TELEGRAM_CHAT_ID")
if not token or not allowed:
logger.error("TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID not set — Q&A bot disabled")
return
host = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
model = os.environ.get("OLLAMA_MODEL", "qwen2.5:7b-instruct")
timeout = int(os.environ.get("OLLAMA_TIMEOUT_SECONDS", "60"))
ollama_enabled = os.environ.get("OLLAMA_ENABLED", "1") not in ("0", "false", "False")
from storage.event_store import EventStore
store = EventStore("momentum")
logger.info("Q&A bot online (chat %s, ollama=%s %s)", allowed, ollama_enabled, model)
_send(token, allowed, HELP_TEXT)
deadman = DeadmanCheck(lambda: _max_event_ts(store),
lambda text: _send(token, allowed, text))
offset = None
while True:
try:
deadman.maybe_check()
except Exception: # noqa: BLE001 — the watchdog must never kill the bot
logger.warning("deadman check failed", exc_info=True)
try:
url = f"{_API}/bot{token}/getUpdates?timeout=50"
if offset is not None:
url += f"&offset={offset}"
with urllib.request.urlopen(url, timeout=60) as resp:
updates = json.loads(resp.read().decode()).get("result", [])
except Exception: # noqa: BLE001
time.sleep(3)
continue
for u in updates:
offset = u["update_id"] + 1
msg = u.get("message") or u.get("edited_message") or {}
chat_id = (msg.get("chat") or {}).get("id")
text = (msg.get("text") or "").strip()
if not text:
continue
if not is_authorized(chat_id, allowed):
logger.warning("ignoring message from unauthorized chat %s", chat_id)
continue
try:
reply = answer(store, text, host=host, model=model, timeout=timeout,
ollama_enabled=ollama_enabled)
except Exception as exc: # noqa: BLE001
logger.warning("answer failed", exc_info=True)
reply = f"(error answering: {exc})"
_send(token, chat_id, reply)
if __name__ == "__main__":
main()