feat(notify): Telegram alerts on trade open and close
CI/CD / build-and-push (push) Successful in 7s

New module bot/notify/telegram.py — httpx async, fire-and-forget via
asyncio.create_task, swallows all errors so notifications never affect
trade execution.

Three alert types:
  📈/📉 TRADE ABIERTO  — direction, size, edge_net (in execute())
  /  GANADO/PERDIDO — approx PnL (in close_position())
  🔒    LEGACY CLOSE   — recovered capital + reason (in close_legacy_position())

close_position() and close_legacy_position() gain an optional question=""
param so the message shows the market name instead of market_id.
bot/main.py updated to pass question= to close_legacy_position().

Credentials (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID) read from env vars
injected via bot-secrets k8s secret.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chemavx
2026-04-26 15:02:39 +00:00
parent 1f40c59e3c
commit 39cebd3be3
4 changed files with 70 additions and 4 deletions
+16 -3
View File
@@ -4,6 +4,7 @@ Paper Trading Executor — simulates order execution without real money.
Simulates realistic slippage and fees to get accurate paper P&L. Simulates realistic slippage and fees to get accurate paper P&L.
All trades are logged to PostgreSQL for metrics analysis. All trades are logged to PostgreSQL for metrics analysis.
""" """
import asyncio
import logging import logging
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -12,6 +13,7 @@ from typing import Optional
from bot.risk.manager import Order, Portfolio from bot.risk.manager import Order, Portfolio
from bot.data.db import Database from bot.data.db import Database
from bot.notify import telegram
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -183,9 +185,13 @@ class PaperExecutor:
# Persist to DB # Persist to DB
await self._db.save_trade(trade) await self._db.save_trade(trade)
asyncio.create_task(
telegram.trade_opened(trade.question, trade.direction, trade.size_usdc, trade.edge_net)
)
return trade return trade
async def close_legacy_position(self, market_id: str, reason: str) -> float: async def close_legacy_position(self, market_id: str, reason: str, question: str = "") -> float:
""" """
Close a paper position flagged by the legacy scan. Close a paper position flagged by the legacy scan.
@@ -200,9 +206,12 @@ class PaperExecutor:
"LEGACY_CLOSE market=%s | returned $%.2f to cash | %s", "LEGACY_CLOSE market=%s | returned $%.2f to cash | %s",
market_id, cost, reason[:80], market_id, cost, reason[:80],
) )
asyncio.create_task(
telegram.trade_legacy_closed(question or market_id, cost, reason)
)
return cost return cost
async def close_position(self, market_id: str, resolution: float) -> Optional[float]: async def close_position(self, market_id: str, resolution: float, question: str = "") -> Optional[float]:
"""Close a paper position after market resolution. """Close a paper position after market resolution.
resolution: 1.0 if YES won, 0.0 if NO won. resolution: 1.0 if YES won, 0.0 if NO won.
@@ -220,6 +229,10 @@ class PaperExecutor:
reason=f"market_resolved resolution={resolution:.1f}", reason=f"market_resolved resolution={resolution:.1f}",
resolution=resolution, resolution=resolution,
) )
approx_pnl = position_cost * resolution - position_cost
log.info("Closed position in %s, resolution=%.1f", market_id, resolution) log.info("Closed position in %s, resolution=%.1f", market_id, resolution)
asyncio.create_task(
telegram.trade_closed(question or market_id, approx_pnl)
)
# Approximate PnL: settlement value minus cost. Exact value is in close_pnl. # Approximate PnL: settlement value minus cost. Exact value is in close_pnl.
return position_cost * resolution - position_cost return approx_pnl
+1 -1
View File
@@ -347,7 +347,7 @@ async def run_legacy_scan(
log.warning("PAPER MODE: auto-closing %d CLOSE_RECOMMENDED position(s)...", n_close) log.warning("PAPER MODE: auto-closing %d CLOSE_RECOMMENDED position(s)...", n_close)
for p in enriched: for p in enriched:
if p["recommendation"] == "CLOSE_RECOMMENDED": if p["recommendation"] == "CLOSE_RECOMMENDED":
recovered = await executor.close_legacy_position(p["market_id"], p["rec_reason"]) recovered = await executor.close_legacy_position(p["market_id"], p["rec_reason"], question=p["question"])
log.warning( log.warning(
" AUTO_CLOSED market=%s | $%.2f returned to cash | %s", " AUTO_CLOSED market=%s | $%.2f returned to cash | %s",
p["market_id"], recovered, p["question"][:60], p["market_id"], recovered, p["question"][:60],
View File
+53
View File
@@ -0,0 +1,53 @@
"""Telegram notifications — fire-and-forget, never blocks trade execution."""
import logging
import os
import httpx
log = logging.getLogger(__name__)
_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
async def _send(text: str) -> None:
"""POST a message to Telegram. Swallows all errors silently."""
if not _TOKEN or not _CHAT_ID:
return
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
f"https://api.telegram.org/bot{_TOKEN}/sendMessage",
json={"chat_id": _CHAT_ID, "text": text},
)
if resp.status_code != 200:
log.warning("Telegram %d: %s", resp.status_code, resp.text[:200])
except Exception as exc:
log.warning("Telegram notification failed: %s", exc)
async def trade_opened(question: str, direction: str, size_usdc: float, edge_net: float) -> None:
emoji = "📈" if direction == "BUY_YES" else "📉"
await _send(
f"{emoji} TRADE ABIERTO\n"
f"{question[:80]}\n"
f"Dir: {direction} Size: ${size_usdc:.2f} Edge: {edge_net:+.3f}"
)
async def trade_closed(question: str, pnl: float) -> None:
emoji = "" if pnl > 0 else ""
result = "GANADO" if pnl > 0 else "PERDIDO"
await _send(
f"{emoji} {result}\n"
f"{question[:80]}\n"
f"PnL: {pnl:+.2f} USDC"
)
async def trade_legacy_closed(question: str, recovered: float, reason: str) -> None:
await _send(
f"🔒 LEGACY CLOSE\n"
f"{question[:80]}\n"
f"Recuperado: ${recovered:.2f} | {reason[:60]}"
)