Files
momentum-system/tests/unit/test_telegram.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

117 lines
4.8 KiB
Python

"""Telegram notifier (push) + Q&A bot (interactive) — gating, dedup, auth, fallback."""
import pytest
import runtime.notifier as nf
import telegram_bot as qa
from storage.event_schema import EventMode, PositionClosedEvent, RiskRuleTriggeredEvent
from storage.event_store import EventStore
@pytest.fixture
def store():
s = EventStore(":memory:")
yield s
s.close()
def _rule(store, rule_type, message="m"):
store.emit(RiskRuleTriggeredEvent(
timestamp=__import__("datetime").datetime(2026, 6, 23, 10, 0),
mode=EventMode.PAPER, correlation_id="t", message=message,
rule_type=rule_type, rule_value=0.0, current_state={}, action_taken="x"))
def _close(store, symbol, pnl):
store.emit(PositionClosedEvent(
timestamp=__import__("datetime").datetime(2026, 6, 23, 10, 1),
mode=EventMode.PAPER, correlation_id="t", message="c",
position_id=f"{symbol}-1", symbol=symbol, exit_price=10.0,
realized_pnl=pnl, exit_reason="stop_loss"))
# ----------------------------------------------------------------- C1 notifier
def test_send_telegram_noop_when_unconfigured(monkeypatch):
monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False)
monkeypatch.delenv("TELEGRAM_CHAT_ID", raising=False)
assert nf.send_telegram("hi") is False # silent no-op, no network
assert nf.telegram_enabled() is False
def test_notifier_disabled_is_inert(store, monkeypatch):
monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False)
monkeypatch.delenv("TELEGRAM_CHAT_ID", raising=False)
n = nf.TelegramNotifier(store)
_rule(store, "daily_loss")
assert n.poll() == 0 # never sends, never raises
def test_notifier_pushes_major_events_once(store, monkeypatch):
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "x")
monkeypatch.setenv("TELEGRAM_CHAT_ID", "1")
sent = []
monkeypatch.setattr(nf, "send_telegram", lambda text, **kw: sent.append(text) or True)
n = nf.TelegramNotifier(store) # primes on empty store
_rule(store, "exit_catastrophe", "CAT") # major
_rule(store, "entry_backout", "noise") # routine -> ignored
_close(store, "BIG", 553) # > $300 -> major
_close(store, "SMALL", 40) # < $300 -> ignored
assert n.poll() == 2
assert n.poll() == 0 # dedup: no re-send
joined = " ".join(sent)
assert "CAT" in joined and "BIG" in joined
assert "noise" not in joined and "SMALL" not in joined
def test_eod_summary_once_per_day(store, monkeypatch):
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "x")
monkeypatch.setenv("TELEGRAM_CHAT_ID", "1")
sent = []
monkeypatch.setattr(nf, "send_telegram", lambda text, **kw: sent.append(text) or True)
n = nf.TelegramNotifier(store)
pnl = {"total_pnl": -2464, "closed_trades": 12, "wins": 3, "losses": 9, "win_rate": 0.25}
assert n.send_eod_summary("2026-06-23", pnl) is True
assert n.send_eod_summary("2026-06-23", pnl) is False # not twice
assert "2026-06-23" in sent[0] and "-2464" in sent[0]
# ------------------------------------------------------------------- C2 Q&A bot
def test_auth_gate():
assert qa.is_authorized(2082795871, "2082795871") is True
assert qa.is_authorized(999, "2082795871") is False
assert qa.is_authorized(2082795871, None) is False
def test_answer_refuses_action_commands(store):
for cmd in ("buy 100 AAPL", "flatten everything", "sell PLUG now"):
out = qa.answer(store, cmd, host="h", model="m", timeout=1, ollama_enabled=True)
assert "read-only" in out.lower()
def test_answer_falls_back_when_ollama_disabled(store):
out = qa.answer(store, "what's my P&L?", host="h", model="m", timeout=1,
ollama_enabled=False)
assert "raw snapshot" in out.lower()
assert "P&L" in out # gather_context ran
def test_answer_falls_back_when_ollama_down(store, monkeypatch):
monkeypatch.setattr(qa, "ask_ollama", lambda *a, **k: None) # simulate down
out = qa.answer(store, "how are positions?", host="h", model="m", timeout=1,
ollama_enabled=True)
assert "raw snapshot" in out.lower()
def test_help_menu_gives_direction(store):
for q in ("help", "/start", "what can you do?"):
out = qa.answer(store, q, host="h", model="m", timeout=1, ollama_enabled=True)
assert "watchlist" in out.lower() and "read-only" in out.lower()
def test_context_includes_watchlist_and_blocks(store):
# the gap the user hit: bot must retrieve more than P&L/positions
_rule(store, "vwap_below", "AMC below vwap") # an entry block
ctx = qa.gather_context(store)
assert "WATCHLIST" in ctx or "READY SIGNALS" in ctx or "ENTRY BLOCKS" in ctx
assert "vwap_below" in ctx # why-not-trading is retrievable