feat(notify): Slack message for reconcile-booked closes (realized P&L)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled

Entries, deferrals and rejections posted to #trading-bot, but exits booked by the reconcile loop (bracket stop/take-profit legs that fill at Alpaca) were silent — the two Jun 9 stop-outs produced no message. Viktor asked for a Slack message on each position execution.

- move SlackNotifier to shared/ (now used by trade-executor AND api-gateway)

- add notify_close (ticker/qty/price/realized P&L/reason, win-loss emoji)

- reconcile loop notifies on each booked close, fail-soft; api-gateway config gains slack fields (channel defaults to trading-bot since the env carries only the bot token; chat:write.public covers posting)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-10 20:44:35 +00:00
parent c6ad39310c
commit 6fec9963fb
6 changed files with 516 additions and 25 deletions

View file

@ -257,6 +257,60 @@ class TestReconcileBooksClose:
assert session.added == []
async def test_booked_close_notifies_slack(self) -> None:
"""A reconcile-booked close must post to Slack with realized P&L."""
entry = _entry_trade(broker_order_id="parent-1", price=100.0, qty=10.0)
tp = _leg(order_id="tp-1", status=OrderStatus.PENDING, filled_price=None)
sl = _leg(order_id="sl-1", status=OrderStatus.FILLED, filled_price=92.0)
broker = AsyncMock()
broker.get_order = AsyncMock(
return_value=_bracket(parent_id="parent-1", legs=[tp, sl])
)
session = _FakeSession([entry])
notifier = AsyncMock()
await reconcile_once(broker, _factory(session), notifier=notifier)
notifier.notify_close.assert_awaited_once()
kwargs = notifier.notify_close.await_args.kwargs
assert kwargs["ticker"] == "NVDA"
assert kwargs["qty"] == 10.0
assert kwargs["price"] == 92.0
assert kwargs["pnl"] == pytest.approx(-80.0)
assert kwargs["strategy_id"] == KEVIN_STRATEGY_UUID
async def test_no_notification_when_nothing_booked(self) -> None:
entry = _entry_trade(broker_order_id="parent-1")
tp = _leg(order_id="tp-1", status=OrderStatus.PENDING, filled_price=None)
sl = _leg(order_id="sl-1", status=OrderStatus.PENDING, filled_price=None)
broker = AsyncMock()
broker.get_order = AsyncMock(
return_value=_bracket(parent_id="parent-1", legs=[tp, sl])
)
session = _FakeSession([entry])
notifier = AsyncMock()
await reconcile_once(broker, _factory(session), notifier=notifier)
notifier.notify_close.assert_not_awaited()
async def test_notifier_failure_does_not_block_booking(self) -> None:
"""Slack is an observer — its failure must not lose the Trade row."""
entry = _entry_trade(broker_order_id="parent-1", price=100.0, qty=10.0)
sl = _leg(order_id="sl-1", status=OrderStatus.FILLED, filled_price=92.0)
broker = AsyncMock()
broker.get_order = AsyncMock(
return_value=_bracket(parent_id="parent-1", legs=[sl])
)
session = _FakeSession([entry])
notifier = AsyncMock()
notifier.notify_close = AsyncMock(side_effect=RuntimeError("slack down"))
await reconcile_once(broker, _factory(session), notifier=notifier)
assert len(session.added) == 1
assert session.committed
# ---------------------------------------------------------------------------
# Idempotency
@ -405,7 +459,7 @@ class TestReconcileLoop:
cfg = _config()
calls = 0
async def boom(broker, sf): # noqa: ANN001
async def boom(broker, sf, notifier=None): # noqa: ANN001
nonlocal calls
calls += 1
if calls == 1: