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
|
|
@ -25,6 +25,13 @@ class ApiGatewayConfig(BaseConfig):
|
|||
paper_trading: bool = True
|
||||
snapshot_interval_seconds: int = 60
|
||||
|
||||
# Slack — close notifications from the trade-reconcile loop. The channel
|
||||
# defaults here (rather than "") because the deployment env carries only
|
||||
# the bot token; chat:write.public lets the bot post without an invite.
|
||||
slack_webhook_url: str = ""
|
||||
slack_bot_token: str = ""
|
||||
slack_channel: str = "trading-bot"
|
||||
|
||||
# CORS settings
|
||||
cors_origins: list[str] = ["http://localhost:5173"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker
|
|||
from services.trade_executor.config import TradeExecutorConfig
|
||||
from services.trade_executor.deferred_queue import DeferredSignalQueue
|
||||
from services.trade_executor.risk_manager import RiskManager
|
||||
from services.trade_executor.slack_notifier import SlackNotifier
|
||||
from shared.slack_notifier import SlackNotifier
|
||||
from shared.broker.alpaca_broker import AlpacaBroker
|
||||
from shared.db import create_db
|
||||
from shared.models.trading import Trade as TradeModel
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue