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

@ -32,6 +32,7 @@ from shared.broker.base import BaseBroker
from shared.constants.kevin import KEVIN_STRATEGY_UUID
from shared.models.trading import Trade, TradeSide, TradeStatus
from shared.schemas.trading import BrokerOrder, OrderResult, OrderStatus
from shared.slack_notifier import SlackNotifier
logger = logging.getLogger(__name__)
@ -63,32 +64,31 @@ async def _already_booked(session: AsyncSession, leg_order_id: str) -> bool:
async def _reconcile_trade(
session: AsyncSession, entry: Trade, order: BrokerOrder
) -> bool:
) -> Trade | None:
"""Apply one trade's reconciliation.
Returns ``True`` if a closing trade was booked. Books an auto-close when a
stop-loss / take-profit leg has filled (idempotent on the leg order id);
otherwise syncs a non-terminal local status from the parent order.
Returns the booked closing ``Trade`` when a stop-loss / take-profit leg
has filled (idempotent on the leg order id); otherwise syncs a
non-terminal local status from the parent order and returns ``None``.
"""
leg = _filled_leg(order)
if leg is not None and leg.filled_price is not None:
if await _already_booked(session, leg.order_id):
return False
return None
fill_price = leg.filled_price
pnl = (fill_price - entry.price) * leg.qty
session.add(
Trade(
ticker=entry.ticker,
side=TradeSide.SELL,
qty=leg.qty,
price=fill_price,
status=TradeStatus.FILLED,
strategy_id=KEVIN_STRATEGY_UUID,
signal_id=entry.signal_id,
broker_order_id=leg.order_id,
pnl=pnl,
)
close = Trade(
ticker=entry.ticker,
side=TradeSide.SELL,
qty=leg.qty,
price=fill_price,
status=TradeStatus.FILLED,
strategy_id=KEVIN_STRATEGY_UUID,
signal_id=entry.signal_id,
broker_order_id=leg.order_id,
pnl=pnl,
)
session.add(close)
logger.info(
"Reconciled auto-close for %s: leg %s filled @ %.2f, pnl=%.2f",
entry.ticker,
@ -96,7 +96,7 @@ async def _reconcile_trade(
leg.filled_price,
pnl,
)
return True
return close
# No filled exit leg — sync a non-terminal local status from the parent.
if entry.status == TradeStatus.PENDING:
@ -111,12 +111,13 @@ async def _reconcile_trade(
entry.broker_order_id,
mapped.value,
)
return False
return None
async def reconcile_once(
broker: BaseBroker,
session_factory: async_sessionmaker,
notifier: SlackNotifier | None = None,
) -> None:
"""Perform a single reconciliation cycle over open Kevin entries."""
async with session_factory() as session:
@ -162,14 +163,30 @@ async def reconcile_once(
)
continue
try:
if await _reconcile_trade(session, entry, order):
booked += 1
close = await _reconcile_trade(session, entry, order)
except Exception:
logger.exception(
"reconcile: booking failed for %s (%s) — skipping row",
entry.ticker,
entry.broker_order_id,
)
continue
if close is None:
continue
booked += 1
if notifier is not None:
# Slack is an observer — its failure must not lose the row.
try:
await notifier.notify_close(
ticker=close.ticker,
qty=close.qty,
price=close.price,
pnl=close.pnl or 0.0,
strategy_id=close.strategy_id,
reason="bracket leg filled at broker",
)
except Exception:
logger.warning("reconcile: close notification failed", exc_info=True)
await session.commit()
@ -197,17 +214,23 @@ async def trade_reconcile_loop(
secret_key=config.alpaca_secret_key,
paper=config.paper_trading,
)
notifier = SlackNotifier(
webhook_url=config.slack_webhook_url,
bot_token=config.slack_bot_token,
channel=config.slack_channel,
)
logger.info(
"Trade reconcile started (interval=%ds, paper=%s)",
"Trade reconcile started (interval=%ds, paper=%s, slack=%s)",
config.snapshot_interval_seconds,
config.paper_trading,
"on" if notifier.enabled else "off",
)
while True:
try:
if is_market_open():
await reconcile_once(broker, session_factory)
await reconcile_once(broker, session_factory, notifier=notifier)
else:
logger.debug("Market closed — skipping trade reconcile")
except asyncio.CancelledError: