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>
103 lines
4.2 KiB
Python
103 lines
4.2 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 "TODAY 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()
|