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:
1 parent
ef3e6f346c
commit
2ca5032e7e
8 files changed
+203
-5
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user