Files
momentum-system/telegram_bot.py
T
momentum devandClaude Opus 4.8 045692c3a5 Telegram Q&A bot: real retrieval + direction (was answering blind)
The bot only knew P&L + positions, so "what's on the watchlist?" and "why
isn't it trading?" both fell back to "positions: none" — useless. Now it
retrieves the actual desk state and gives the user direction.

- gather_context: today P&L + recent closed trades (cross-day, robust to the
  midnight boundary) + open positions + the live WATCHLIST (states + scores) +
  READY SIGNALS (entry/stop/vwap) + a why-blocked tally (risk_rule_triggered
  counts) + all-time.
- symbol_context: when a question names a ticker the bot knows, append a focused
  dive — its last trade + the setup criteria it passed/failed.
- Help menu (help / /start) lists exactly what it can answer; sent on startup so
  the user has direction. Sharper system prompt (concrete, quote real numbers,
  say what data exists when it can't answer).

Verified live: "what's on the watchlist?" -> "148 symbols: 97 blocked, 48 late,
3 ready (LAES, NOWL, PBK)"; "why isn't it trading?" -> coherent; "what happened
with VTAK?" -> "stop loss -$659, entry $1.14". Tests: help menu, retrieval
includes watchlist/blocks; 10/10 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:04:51 -04:00

285 lines
13 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).
Env: TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID (required), DATABASE_URL,
OLLAMA_HOST / OLLAMA_MODEL / OLLAMA_TIMEOUT_SECONDS / OLLAMA_ENABLED.
"""
from __future__ import annotations
import json
import logging
import os
import re
import time
import urllib.request
from collections import Counter
logger = logging.getLogger("telegram_bot")
_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}"
# ------------------------------------------------------------------- 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"))
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)
offset = None
while 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()