feat(notify): Slack message for reconcile-booked closes (realized P&L)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
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:
parent
c6ad39310c
commit
6fec9963fb
6 changed files with 516 additions and 25 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue