Files
matitos 2ca5032e7e Notification upgrades: lifecycle, configurable levels, summaries
- NOTIFY_LEVELS chooses which event levels are sent (default trade,error).
- NOTIFY_LIFECYCLE sends explicit start/stop messages; the kill switch always
  notifies at error level.
- SUMMARY_INTERVAL_MINUTES sends a periodic portfolio summary (equity, per
  strategy P&L, allocation, halted state) via a background task started with
  the run and stopped on stop/kill/shutdown.
- Wires the new vars plus the go-live vars through both compose files and the
  deploy workflow. Adds tests.
2026-09-22 13:18:02 +00:00

167 lines
6.0 KiB
Python

from __future__ import annotations
import asyncio
from app.config import Settings
from app.notify import FanoutNotifier, Notifier, WebhookNotifier, build_notifier
from app.runtime import Runtime
class RecordingNotifier(Notifier):
def __init__(self, name: str = "rec", fail: bool = False) -> None:
self.name = name
self.fail = fail
self.messages: list[tuple[str, str]] = []
async def send(self, level: str, message: str) -> None:
if self.fail:
raise RuntimeError("boom")
self.messages.append((level, message))
async def test_fanout_sends_to_all() -> None:
first = RecordingNotifier("a")
second = RecordingNotifier("b")
await FanoutNotifier([first, second]).send("trade", "hello")
assert first.messages == [("trade", "hello")]
assert second.messages == [("trade", "hello")]
async def test_fanout_survives_a_failing_channel() -> None:
bad = RecordingNotifier("bad", fail=True)
good = RecordingNotifier("good")
await FanoutNotifier([bad, good]).send("error", "still delivered")
assert good.messages == [("error", "still delivered")]
def test_build_notifier_none_without_config() -> None:
assert build_notifier(Settings(telegram_bot_token="", notify_webhook_url="")) is None
def test_build_notifier_telegram_requires_both_fields() -> None:
assert build_notifier(Settings(telegram_bot_token="t", telegram_chat_id="")) is None
notifier = build_notifier(Settings(telegram_bot_token="t", telegram_chat_id="c"))
assert notifier is not None
def test_build_notifier_webhook() -> None:
notifier = build_notifier(Settings(notify_webhook_url="https://example.com/hook"))
assert isinstance(notifier, FanoutNotifier)
assert any(isinstance(n, WebhookNotifier) for n in notifier.notifiers)
async def test_runtime_notifies_on_trade_events() -> None:
recorder = RecordingNotifier()
settings = Settings(telegram_bot_token="", notify_webhook_url="")
runtime = Runtime(settings, notifier=recorder)
runtime._broadcast({"type": "event", "data": {"level": "trade", "message": "opened long"}})
await asyncio.gather(*list(runtime._notify_tasks))
assert recorder.messages == [("trade", "opened long")]
async def test_runtime_ignores_info_events() -> None:
recorder = RecordingNotifier()
settings = Settings(telegram_bot_token="", notify_webhook_url="")
runtime = Runtime(settings, notifier=recorder)
runtime._broadcast({"type": "event", "data": {"level": "info", "message": "started"}})
await asyncio.gather(*list(runtime._notify_tasks))
assert recorder.messages == []
async def test_notify_levels_are_configurable() -> None:
recorder = RecordingNotifier()
settings = Settings(telegram_bot_token="", notify_webhook_url="", notify_levels="info,trade")
runtime = Runtime(settings, notifier=recorder)
runtime._broadcast({"type": "event", "data": {"level": "info", "message": "started"}})
await asyncio.gather(*list(runtime._notify_tasks))
assert recorder.messages == [("info", "started")]
async def test_start_and_stop_notify_lifecycle(settings, store, market) -> None:
from app.broker.paper import PaperBroker
from app.schemas import StartManyRequest, StrategySpec
settings.notify_lifecycle = True
recorder = RecordingNotifier()
runtime = Runtime(
settings,
store,
feed_factory=lambda _s: market,
broker_factory=lambda _s: PaperBroker(fee_bps=0, slippage_bps=0),
notifier=recorder,
)
await runtime.start_many(StartManyRequest(strategies=[StrategySpec(strategy_key="sma_cross")]))
await runtime.stop()
await asyncio.gather(*list(runtime._notify_tasks))
levels = [level for level, _ in recorder.messages]
assert "info" in levels
assert any("Started" in msg for _, msg in recorder.messages)
assert any("Stopped" in msg for _, msg in recorder.messages)
await runtime.shutdown()
async def test_kill_notifies(settings, store, market) -> None:
from app.broker.paper import PaperBroker
from app.schemas import StartManyRequest, StrategySpec
recorder = RecordingNotifier()
runtime = Runtime(
settings,
store,
feed_factory=lambda _s: market,
broker_factory=lambda _s: PaperBroker(fee_bps=0, slippage_bps=0),
notifier=recorder,
)
await runtime.start_many(StartManyRequest(strategies=[StrategySpec(strategy_key="sma_cross")]))
await runtime.kill("panic")
await asyncio.gather(*list(runtime._notify_tasks))
assert any(level == "error" and "KILL SWITCH" in msg for level, msg in recorder.messages)
await runtime.shutdown()
async def test_summary_message_lists_strategies(settings, store, market) -> None:
from app.broker.paper import PaperBroker
from app.schemas import StartManyRequest, StrategySpec
runtime = Runtime(
settings,
store,
feed_factory=lambda _s: market,
broker_factory=lambda _s: PaperBroker(fee_bps=0, slippage_bps=0),
notifier=RecordingNotifier(),
)
await runtime.start_many(
StartManyRequest(
strategies=[
StrategySpec(strategy_key="sma_cross", weight=1),
StrategySpec(strategy_key="ema_cross", weight=3),
],
starting_balance=10000,
)
)
message = runtime.summary_message()
assert "Portfolio" in message
assert "SMA Crossover" in message
assert "EMA Crossover" in message
assert "75%" in message # the weighted strategy's allocation
await runtime.shutdown()
async def test_summary_loop_runs(settings, store, market) -> None:
from app.broker.paper import PaperBroker
settings.summary_interval_minutes = 1 # -> 60s; we only check it starts
recorder = RecordingNotifier()
runtime = Runtime(
settings,
store,
feed_factory=lambda _s: market,
broker_factory=lambda _s: PaperBroker(fee_bps=0, slippage_bps=0),
notifier=recorder,
)
runtime._start_summary_loop()
assert runtime._summary_task is not None
runtime._stop_summary_loop()
assert runtime._summary_task is None