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.
This commit is contained in:
matitos committed 2026-09-22 13:18:02 +00:00
1 parent ef3e6f346c
commit 2ca5032e7e
8 files changed
+203 -5

No files matched your search

+98
View File
@@ -66,3 +66,101 @@ async def test_runtime_ignores_info_events() -> None:
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