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>
117 lines
4.8 KiB
Python
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
|