Files
XauBot/src/telegram_notifier.py
T
GifariKemal b9e283878a refactor: separate all Telegram code from main_live.py into dedicated modules
- telegram_notifier.py: low-level API (send, format, poll, command system)
- telegram_commands.py: command handlers (/status /market /risk /positions /daily /filters /help)
- telegram_notifications.py: notification helpers (startup, shutdown, trade open/close, hourly, alerts)
- main_live.py reduced by ~400 lines — only 4 infrastructure calls remain (set_balance, close, poll)
- Auto-send limited to: startup, hourly report, trade open, trade close
- Market update & daily summary available on-demand via Telegram commands
- Win rate tracking added to trade close notifications

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 07:07:04 +07:00

1496 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Telegram Notifier Module
========================
Smart Telegram integration for AI Trading Bot.
Features:
- Trade notifications with ALL features as text array
- Market condition updates with full context
- ML prediction insights
- Volatility alerts
- Daily summary with charts
- Interactive commands
- PDF report generation
"""
import asyncio
import os
from datetime import datetime, timedelta
from typing import Optional, Dict, List, Any
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from zoneinfo import ZoneInfo
import io
from loguru import logger
# Timezone
WIB = ZoneInfo("Asia/Jakarta")
class NotificationType(Enum):
"""Types of Telegram notifications."""
TRADE_OPEN = "trade_open"
TRADE_CLOSE = "trade_close"
MARKET_UPDATE = "market_update"
DAILY_SUMMARY = "daily_summary"
ALERT = "alert"
ERROR = "error"
SYSTEM = "system"
@dataclass
class TradeInfo:
"""Trade information for notifications."""
ticket: int
symbol: str
order_type: str # BUY or SELL
lot_size: float
entry_price: float
close_price: Optional[float] = None
stop_loss: float = 0
take_profit: float = 0
profit: float = 0
profit_pips: float = 0
balance_before: float = 0
balance_after: float = 0
duration_seconds: int = 0
ml_confidence: float = 0
signal_reason: str = ""
regime: str = ""
volatility: str = ""
@dataclass
class MarketCondition:
"""Market condition information."""
symbol: str
price: float
regime: str
volatility: str
ml_signal: str
ml_confidence: float
trend_direction: str
session: str
can_trade: bool
atr: float = 0
spread: float = 0
class TelegramNotifier:
"""
Smart Telegram notification system for trading bot.
Sends formatted messages with trade info, market conditions,
and educational content.
"""
def __init__(
self,
bot_token: str,
chat_id: str,
enabled: bool = True,
):
self.bot_token = bot_token
self.chat_id = chat_id
self.enabled = enabled
self._session = None
# Track daily stats
self._daily_trades: List[TradeInfo] = []
self._daily_start_balance: float = 0
self._last_daily_report: Optional[datetime] = None
# Rate limiting
self._last_message_time: Optional[datetime] = None
self._min_message_interval = 1 # seconds
# API URL
self._api_url = f"https://api.telegram.org/bot{bot_token}"
# Chart storage
self._charts_dir = Path("data/charts")
self._charts_dir.mkdir(parents=True, exist_ok=True)
# Command polling
self._last_update_id: int = 0
self._command_handlers: Dict[str, Any] = {}
logger.info(f"Telegram notifier initialized (enabled={enabled})")
async def _get_session(self):
"""Get or create aiohttp session."""
if self._session is None:
import aiohttp
self._session = aiohttp.ClientSession()
return self._session
async def close(self):
"""Close the session."""
if self._session:
await self._session.close()
self._session = None
async def send_message(
self,
text: str,
parse_mode: str = "HTML",
disable_notification: bool = False,
) -> bool:
"""Send a text message to Telegram."""
if not self.enabled:
return True
try:
session = await self._get_session()
url = f"{self._api_url}/sendMessage"
payload = {
"chat_id": self.chat_id,
"text": text,
"parse_mode": parse_mode,
"disable_notification": disable_notification,
}
async with session.post(url, json=payload) as resp:
if resp.status == 200:
return True
else:
error = await resp.text()
logger.error(f"Telegram send failed: {error}")
return False
except Exception as e:
logger.error(f"Telegram error: {e}")
return False
# ========== COMMAND SYSTEM ==========
def register_command(self, command: str, handler):
"""Register a command handler. Handler is an async callable returning str."""
self._command_handlers[command.lstrip("/")] = handler
async def poll_commands(self) -> int:
"""
Poll Telegram for new commands and dispatch handlers.
Returns number of commands processed.
"""
if not self.enabled:
return 0
try:
session = await self._get_session()
url = f"{self._api_url}/getUpdates"
params = {"offset": self._last_update_id + 1, "timeout": 0, "limit": 10}
async with session.get(url, params=params, timeout=5) as resp:
if resp.status != 200:
return 0
data = await resp.json()
if not data.get("ok") or not data.get("result"):
return 0
processed = 0
for update in data["result"]:
self._last_update_id = update["update_id"]
msg = update.get("message", {})
text = msg.get("text", "")
chat_id = str(msg.get("chat", {}).get("id", ""))
# Only respond to our chat
if chat_id != self.chat_id:
continue
if not text.startswith("/"):
continue
# Parse command (e.g., "/status" or "/status@botname")
cmd = text.split()[0].split("@")[0].lstrip("/").lower()
if cmd in self._command_handlers:
try:
response = await self._command_handlers[cmd]()
if response:
await self.send_message(response)
processed += 1
except Exception as e:
logger.warning(f"Command /{cmd} error: {e}")
await self.send_message(f"⚠️ Error: <code>{e}</code>")
elif cmd == "help":
await self._send_help()
processed += 1
else:
await self.send_message(f"❓ Unknown: <code>/{cmd}</code>\nKetik /help untuk daftar command.")
processed += 1
return processed
except asyncio.TimeoutError:
return 0
except Exception as e:
logger.debug(f"Command poll error: {e}")
return 0
async def _send_help(self):
"""Send help message with all available commands."""
cmd_list = sorted(self._command_handlers.keys())
help_items = []
for cmd in cmd_list:
doc = getattr(self._command_handlers[cmd], "_cmd_desc", "")
help_items.append(f"/{cmd}{doc}" if doc else f"/{cmd}")
msg = f"""📋 <b>COMMANDS</b>
{self._build_section("Available", help_items)}
{datetime.now(WIB).strftime('%H:%M')} WIB"""
await self.send_message(msg.strip())
async def send_photo(
self,
photo_path: str,
caption: str = "",
parse_mode: str = "HTML",
) -> bool:
"""Send a photo to Telegram."""
if not self.enabled:
return True
try:
session = await self._get_session()
url = f"{self._api_url}/sendPhoto"
import aiohttp
data = aiohttp.FormData()
data.add_field("chat_id", self.chat_id)
data.add_field("caption", caption)
data.add_field("parse_mode", parse_mode)
with open(photo_path, "rb") as f:
data.add_field("photo", f, filename="chart.png")
async with session.post(url, data=data) as resp:
if resp.status == 200:
return True
else:
error = await resp.text()
logger.error(f"Telegram photo send failed: {error}")
return False
except Exception as e:
logger.error(f"Telegram photo error: {e}")
return False
async def send_document(
self,
doc_path: str,
caption: str = "",
parse_mode: str = "HTML",
) -> bool:
"""Send a document (PDF) to Telegram."""
if not self.enabled:
return True
try:
session = await self._get_session()
url = f"{self._api_url}/sendDocument"
import aiohttp
data = aiohttp.FormData()
data.add_field("chat_id", self.chat_id)
data.add_field("caption", caption)
data.add_field("parse_mode", parse_mode)
with open(doc_path, "rb") as f:
filename = Path(doc_path).name
data.add_field("document", f, filename=filename)
async with session.post(url, data=data) as resp:
if resp.status == 200:
return True
else:
error = await resp.text()
logger.error(f"Telegram doc send failed: {error}")
return False
except Exception as e:
logger.error(f"Telegram doc error: {e}")
return False
# ========== HELPER: Build text array ==========
@staticmethod
def _build_section(title: str, items: List[str]) -> str:
"""Build a section with tree-style connectors."""
if not items:
return ""
lines = [f"<b>{title}</b>"]
for i, item in enumerate(items):
prefix = "└" if i == len(items) - 1 else "├"
lines.append(f"{prefix} {item}")
return "\n".join(lines)
# ========== FORMATTED MESSAGES ==========
def _format_trade_open(self, trade: TradeInfo, ctx: dict) -> str:
"""Format trade open notification with ALL features as text array."""
emoji = "🟢" if trade.order_type == "BUY" else "🔴"
direction = "LONG" if trade.order_type == "BUY" else "SHORT"
# Calculate risk/reward
sl_distance = abs(trade.entry_price - trade.stop_loss)
tp_distance = abs(trade.take_profit - trade.entry_price)
rr_ratio = tp_distance / sl_distance if sl_distance > 0 else 0
# SL display
sl_display = f"{trade.stop_loss:.2f}" if trade.stop_loss > 0 else "Smart"
# Calculate potential profit/loss
potential_loss = abs(trade.entry_price - trade.stop_loss) * trade.lot_size * 100 if trade.stop_loss > 0 else 0
potential_profit = abs(trade.take_profit - trade.entry_price) * trade.lot_size * 100
# === Section 1: Trade ===
trade_items = [
f"<b>{trade.symbol}</b>",
f"Entry: <code>{trade.entry_price:.2f}</code>",
f"Lot: <code>{trade.lot_size}</code>",
f"SL: <code>{sl_display}</code> (-${potential_loss:.0f})",
f"TP: <code>{trade.take_profit:.2f}</code> (+${potential_profit:.0f})",
f"R:R: <code>1:{rr_ratio:.1f}</code>",
]
# === Section 2: AI / ML ===
ml_conf = trade.ml_confidence
threshold = ctx.get("dynamic_threshold", 0.5)
quality = ctx.get("market_quality", "unknown")
score = ctx.get("market_score", 0)
ai_items = [
f"ML: <code>{ml_conf:.0%}</code> / thresh <code>{threshold:.0%}</code>",
f"Quality: <code>{quality.upper()}</code> (score:{score})",
]
# === Section 3: SMC ===
smc_signal = ctx.get("smc_signal", "")
smc_conf = ctx.get("smc_confidence", 0)
smc_fvg = ctx.get("smc_fvg", False)
smc_ob = ctx.get("smc_ob", False)
smc_bos = ctx.get("smc_bos", False)
smc_choch = ctx.get("smc_choch", False)
patterns = []
if smc_fvg: patterns.append("FVG")
if smc_ob: patterns.append("OB")
if smc_bos: patterns.append("BOS")
if smc_choch: patterns.append("CHoCH")
smc_items = [
f"Signal: <code>{smc_signal or 'NONE'}</code> ({smc_conf:.0%})",
f"Patterns: <code>{', '.join(patterns) if patterns else 'None'}</code>",
]
# === Section 4: Market ===
session = ctx.get("session", "Unknown")
h1_bias = ctx.get("h1_bias", "NEUTRAL")
regime = trade.regime or "unknown"
vol = trade.volatility or "unknown"
market_items = [
f"Session: <code>{session}</code>",
f"Regime: <code>{regime}</code> | Vol: <code>{vol}</code>",
f"H1 Bias: <code>{h1_bias}</code>",
]
# === Section 5: Risk ===
risk_mode = ctx.get("risk_mode", "normal")
daily_loss = ctx.get("daily_loss", 0)
consec = ctx.get("consecutive_losses", 0)
risk_items = [
f"Mode: <code>{risk_mode.upper()}</code>",
f"Daily Loss: <code>${daily_loss:.2f}</code> | Streak: <code>{consec}L</code>",
]
# === Section 6: Entry Filters ===
filters = ctx.get("entry_filters", [])
filter_items = []
for f in filters:
passed = f.get("passed", True)
name = f.get("name", "")
detail = f.get("detail", "")
icon = "✅" if passed else "❌"
filter_items.append(f"{icon} {name}: {detail}")
# === Build message ===
sections = [
self._build_section("Trade", trade_items),
self._build_section("AI Signal", ai_items),
self._build_section("SMC", smc_items),
self._build_section("Market", market_items),
self._build_section("Risk", risk_items),
]
if filter_items:
sections.append(self._build_section("Entry Filters", filter_items))
body = "\n\n".join(s for s in sections if s)
msg = f"""{emoji} <b>{direction}</b> #{trade.ticket}
{body}
<i>{trade.signal_reason[:80]}</i>
{datetime.now(WIB).strftime('%H:%M')} WIB"""
return msg.strip()
def _format_trade_close(self, trade: TradeInfo, ctx: dict) -> str:
"""Format trade close notification with ALL status as text array."""
# Determine profit/loss styling
if trade.profit > 0:
emoji = "✅"
profit_str = f"+${trade.profit:.2f}"
elif trade.profit < 0:
emoji = "❌"
profit_str = f"-${abs(trade.profit):.2f}"
else:
emoji = ""
profit_str = "$0"
# Calculate percentage change
pct_change = (trade.profit / trade.balance_before * 100) if trade.balance_before > 0 else 0
pct_str = f"+{pct_change:.2f}%" if pct_change >= 0 else f"{pct_change:.2f}%"
# Duration formatting
duration_mins = trade.duration_seconds // 60
duration_str = f"{duration_mins}m" if duration_mins > 0 else f"{trade.duration_seconds}s"
# Result label
if trade.profit > 0:
result = "WIN"
elif trade.profit < 0:
result = "LOSS"
else:
result = "BE"
# Balance change
bal_change = trade.balance_after - trade.balance_before
bal_change_str = f"+${bal_change:.2f}" if bal_change >= 0 else f"-${abs(bal_change):.2f}"
# Win rate
win_rate = ctx.get("win_rate", 0)
session_trades = ctx.get("session_trades", 0)
session_wins = ctx.get("session_wins", 0)
# === Section 1: Trade Result ===
trade_items = [
f"<b>{trade.symbol}</b> {trade.order_type}",
f"Entry: <code>{trade.entry_price:.2f}</code> → Exit: <code>{trade.close_price:.2f}</code>",
f"Lot: <code>{trade.lot_size}</code> | Pips: <code>{trade.profit_pips:+.1f}</code>",
f"<b>P/L: {profit_str}</b> ({pct_str})",
f"Duration: <code>{duration_str}</code>",
]
# === Section 2: Exit ===
exit_reason = ctx.get("exit_reason", "unknown")
exit_items = [
f"Reason: <code>{exit_reason}</code>",
f"Regime: <code>{trade.regime or 'unknown'}</code> | Vol: <code>{trade.volatility or 'unknown'}</code>",
f"Session: <code>{ctx.get('session', 'Unknown')}</code>",
]
# === Section 3: Balance ===
balance_items = [
f"Before: <code>${trade.balance_before:,.2f}</code>",
f"After: <code>${trade.balance_after:,.2f}</code> (<b>{bal_change_str}</b>)",
]
# === Section 4: Session Stats ===
session_profit = ctx.get("session_profit", 0)
session_pnl_str = f"+${session_profit:.2f}" if session_profit >= 0 else f"-${abs(session_profit):.2f}"
consec = ctx.get("consecutive_losses", 0)
stats_items = [
f"Trades: <code>{session_wins}W</code> / <code>{session_trades}T</code>",
f"Win Rate: <code>{win_rate:.1f}%</code>",
f"Session P/L: <b>{session_pnl_str}</b>",
f"Streak: <code>{consec}L</code> | Mode: <code>{ctx.get('risk_mode', 'normal').upper()}</code>",
]
# === Build message ===
msg = f"""{emoji} <b>{result}</b> #{trade.ticket}
{self._build_section("Trade", trade_items)}
{self._build_section("Exit", exit_items)}
{self._build_section("Balance", balance_items)}
{self._build_section("Session Stats", stats_items)}
{datetime.now(WIB).strftime('%H:%M')} WIB"""
return msg.strip()
def _format_market_update(self, condition: MarketCondition, ctx: dict) -> str:
"""Format market condition update with full context as text array."""
# Signal emoji
if condition.ml_signal == "BUY":
signal_emoji = "🟢"
elif condition.ml_signal == "SELL":
signal_emoji = "🔴"
else:
signal_emoji = "⚪"
status = "✅ READY" if condition.can_trade else "⛔ WAIT"
# Extra context
h1_bias = ctx.get("h1_bias", "NEUTRAL")
threshold = ctx.get("dynamic_threshold", 0.5)
quality = ctx.get("market_quality", "unknown")
score = ctx.get("market_score", 0)
smc_signal = ctx.get("smc_signal", "")
smc_conf = ctx.get("smc_confidence", 0)
# === Section 1: Price ===
price_items = [
f"<b>{condition.symbol}</b> <code>${condition.price:.2f}</code>",
f"ATR: <code>{condition.atr:.2f}</code> | Spread: <code>{condition.spread:.1f}</code>",
]
# === Section 2: AI Signal ===
signal_items = [
f"{signal_emoji} ML: <code>{condition.ml_signal}</code> {condition.ml_confidence:.0%} / thresh {threshold:.0%}",
f"SMC: <code>{smc_signal or 'NONE'}</code> ({smc_conf:.0%})",
f"Quality: <code>{quality.upper()}</code> (score:{score})",
f"Trend: <code>{condition.trend_direction}</code> | H1: <code>{h1_bias}</code>",
]
# === Section 3: Market ===
market_items = [
f"Regime: <code>{condition.regime}</code> | Vol: <code>{condition.volatility}</code>",
f"Session: <code>{condition.session}</code>",
f"Status: {status}",
]
# === Section 4: Risk ===
risk_mode = ctx.get("risk_mode", "normal")
daily_loss = ctx.get("daily_loss", 0)
consec = ctx.get("consecutive_losses", 0)
session_trades = ctx.get("session_trades", 0)
session_profit = ctx.get("session_profit", 0)
sp_str = f"+${session_profit:.2f}" if session_profit >= 0 else f"-${abs(session_profit):.2f}"
risk_items = [
f"Mode: <code>{risk_mode.upper()}</code> | Streak: <code>{consec}L</code>",
f"Daily Loss: <code>${daily_loss:.2f}</code>",
f"Session: <code>{session_trades}</code> trades, <b>{sp_str}</b>",
]
msg = f"""📊 <b>MARKET UPDATE</b>
{self._build_section("Price", price_items)}
{self._build_section("AI Signal", signal_items)}
{self._build_section("Market", market_items)}
{self._build_section("Risk", risk_items)}
{datetime.now(WIB).strftime('%H:%M')} WIB"""
return msg.strip()
def _format_daily_summary(
self,
trades: List[TradeInfo],
start_balance: float,
end_balance: float,
market_condition: Optional[MarketCondition] = None,
) -> str:
"""Format daily trading summary with ALL stats as text array."""
# Calculate stats
total_trades = len(trades)
winning_trades = sum(1 for t in trades if t.profit > 0)
losing_trades = sum(1 for t in trades if t.profit < 0)
total_profit = sum(t.profit for t in trades)
gross_profit = sum(t.profit for t in trades if t.profit > 0)
gross_loss = sum(abs(t.profit) for t in trades if t.profit < 0)
win_rate = (winning_trades / total_trades * 100) if total_trades > 0 else 0
# Profit factor
profit_factor = (gross_profit / gross_loss) if gross_loss > 0 else float('inf') if gross_profit > 0 else 0
pf_str = f"{profit_factor:.2f}" if profit_factor != float('inf') else "∞"
# Average trade
avg_profit = (total_profit / total_trades) if total_trades > 0 else 0
# Day result
day_pct = ((end_balance - start_balance) / start_balance * 100) if start_balance > 0 else 0
if total_profit > 0:
day_emoji = "🎉"
elif total_profit < 0:
day_emoji = "📉"
else:
day_emoji = ""
profit_str = f"+${total_profit:.2f}" if total_profit >= 0 else f"-${abs(total_profit):.2f}"
pct_str = f"+{day_pct:.2f}%" if day_pct >= 0 else f"{day_pct:.2f}%"
# === Section 1: Result ===
result_items = [
f"<b>P/L: {profit_str}</b> ({pct_str})",
f"Gross Win: <code>+${gross_profit:.2f}</code>",
f"Gross Loss: <code>-${gross_loss:.2f}</code>",
f"Bal Start: <code>${start_balance:,.2f}</code>",
f"Bal End: <code>${end_balance:,.2f}</code>",
]
# === Section 2: Stats ===
stats_items = [
f"Total: <code>{total_trades}</code> trades",
f"Wins: <code>{winning_trades}</code> | Losses: <code>{losing_trades}</code>",
f"Win Rate: <code>{win_rate:.1f}%</code>",
f"Profit Factor: <code>{pf_str}</code>",
f"Avg/Trade: <code>${avg_profit:.2f}</code>",
]
# === Section 3: Recent Trades ===
recent = trades[-5:]
trade_items = []
for t in recent:
sign = "+" if t.profit >= 0 else "-"
amt = abs(t.profit)
result_emoji = "✅" if t.profit > 0 else "❌" if t.profit < 0 else ""
trade_items.append(f"{result_emoji} {t.order_type}: {sign}${amt:.2f}")
if not trade_items:
trade_items = ["No trades"]
msg = f"""{day_emoji} <b>DAILY REPORT</b> {datetime.now(WIB).strftime('%Y-%m-%d')}
{self._build_section("Result", result_items)}
{self._build_section("Stats", stats_items)}
{self._build_section("Recent Trades", trade_items)}
{datetime.now(WIB).strftime('%H:%M')} WIB"""
return msg.strip()
def _format_alert(self, alert_type: str, message: str) -> str:
"""Format alert message as text array."""
alert_emojis = {
"flash_crash": "🚨",
"high_volatility": "⚡",
"connection_error": "📡",
"model_retrain": "🔄",
"market_close": "🔔",
"low_balance": "💰",
}
emoji = alert_emojis.get(alert_type, "⚠️")
title = alert_type.upper().replace('_', ' ')
alert_items = [message]
msg = f"""{emoji} <b>{title}</b>
{self._build_section("Detail", alert_items)}
{datetime.now(WIB).strftime('%H:%M')} WIB"""
return msg.strip()
def _format_system_status(
self,
balance: float,
equity: float,
open_positions: int,
session: str,
ml_status: str,
uptime_hours: float,
) -> str:
"""Format system status message as text array."""
status_items = [
f"Bal: <code>${balance:,.0f}</code>",
f"Eq: <code>${equity:,.0f}</code>",
f"Pos: <code>{open_positions}</code>",
f"Session: <code>{session}</code>",
f"ML: <code>{ml_status}</code>",
f"Uptime: <code>{uptime_hours:.1f}h</code>",
]
msg = f"""🤖 <b>STATUS</b> 🟢
{self._build_section("System", status_items)}
{datetime.now(WIB).strftime('%H:%M')} WIB"""
return msg.strip()
# ========== HIGH-LEVEL NOTIFICATION METHODS ==========
async def notify_trade_open(
self,
ticket: int,
symbol: str,
order_type: str,
lot_size: float,
entry_price: float,
stop_loss: float,
take_profit: float,
ml_confidence: float,
signal_reason: str,
regime: str,
volatility: str,
# ALL extra context as dict
context: dict = None,
):
"""Send trade open notification with ALL features."""
trade = TradeInfo(
ticket=ticket,
symbol=symbol,
order_type=order_type,
lot_size=lot_size,
entry_price=entry_price,
stop_loss=stop_loss,
take_profit=take_profit,
ml_confidence=ml_confidence,
signal_reason=signal_reason,
regime=regime,
volatility=volatility,
)
msg = self._format_trade_open(trade, context or {})
await self.send_message(msg)
logger.info(f"Telegram: Trade open notification sent for #{ticket}")
async def notify_trade_close(
self,
ticket: int,
symbol: str,
order_type: str,
lot_size: float,
entry_price: float,
close_price: float,
profit: float,
profit_pips: float,
balance_before: float,
balance_after: float,
duration_seconds: int,
ml_confidence: float = 0,
regime: str = "",
volatility: str = "",
# ALL extra context as dict
context: dict = None,
):
"""Send trade close notification with ALL status."""
trade = TradeInfo(
ticket=ticket,
symbol=symbol,
order_type=order_type,
lot_size=lot_size,
entry_price=entry_price,
close_price=close_price,
profit=profit,
profit_pips=profit_pips,
balance_before=balance_before,
balance_after=balance_after,
duration_seconds=duration_seconds,
ml_confidence=ml_confidence,
regime=regime,
volatility=volatility,
)
# Track for daily summary
self._daily_trades.append(trade)
msg = self._format_trade_close(trade, context or {})
await self.send_message(msg)
logger.info(f"Telegram: Trade close notification sent for #{ticket}")
async def notify_market_update(
self,
symbol: str,
price: float,
regime: str,
volatility: str,
ml_signal: str,
ml_confidence: float,
trend_direction: str,
session: str,
can_trade: bool,
atr: float = 0,
spread: float = 0,
# ALL extra context as dict
context: dict = None,
):
"""Send market condition update with full context."""
condition = MarketCondition(
symbol=symbol,
price=price,
regime=regime,
volatility=volatility,
ml_signal=ml_signal,
ml_confidence=ml_confidence,
trend_direction=trend_direction,
session=session,
can_trade=can_trade,
atr=atr,
spread=spread,
)
msg = self._format_market_update(condition, context or {})
await self.send_message(msg, disable_notification=True)
logger.info("Telegram: Market update sent")
async def notify_alert(self, alert_type: str, message: str):
"""Send alert notification."""
msg = self._format_alert(alert_type, message)
await self.send_message(msg)
logger.info(f"Telegram: Alert sent - {alert_type}")
async def notify_system_status(
self,
balance: float,
equity: float,
open_positions: int,
session: str,
ml_status: str,
uptime_hours: float,
):
"""Send system status update."""
msg = self._format_system_status(
balance, equity, open_positions,
session, ml_status, uptime_hours
)
await self.send_message(msg, disable_notification=True)
logger.info("Telegram: System status sent")
async def send_daily_summary(
self,
start_balance: float,
end_balance: float,
market_condition: Optional[MarketCondition] = None,
):
"""Send daily trading summary."""
msg = self._format_daily_summary(
self._daily_trades,
start_balance,
end_balance,
market_condition,
)
await self.send_message(msg)
# Generate and send chart if possible
chart_path = await self._generate_daily_chart(
self._daily_trades,
start_balance,
end_balance,
)
if chart_path:
await self.send_photo(
chart_path,
caption=f"📊 Daily Performance Chart - {datetime.now(WIB).strftime('%Y-%m-%d')}"
)
# Reset daily tracking
self._daily_trades = []
self._last_daily_report = datetime.now(WIB)
logger.info("Telegram: Daily summary sent")
async def _generate_daily_chart(
self,
trades: List[TradeInfo],
start_balance: float,
end_balance: float,
) -> Optional[str]:
"""Generate daily performance chart."""
try:
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
if not trades:
return None
# Create figure with dark theme (shadcn-inspired)
plt.style.use('dark_background')
fig, axes = plt.subplots(2, 2, figsize=(12, 8))
fig.patch.set_facecolor('#0a0a0a')
# Color palette (shadcn-inspired)
colors = {
'profit': '#22c55e', # Green
'loss': '#ef4444', # Red
'neutral': '#64748b', # Slate
'primary': '#3b82f6', # Blue
'bg': '#0a0a0a',
'card': '#1c1c1c',
'text': '#fafafa',
}
# 1. Equity Curve
ax1 = axes[0, 0]
ax1.set_facecolor(colors['card'])
balance_curve = [start_balance]
for t in trades:
balance_curve.append(balance_curve[-1] + t.profit)
x = range(len(balance_curve))
ax1.fill_between(x, balance_curve, alpha=0.3, color=colors['primary'])
ax1.plot(x, balance_curve, color=colors['primary'], linewidth=2)
ax1.set_title('Equity Curve', color=colors['text'], fontsize=12, fontweight='bold')
ax1.set_xlabel('Trade #', color=colors['text'])
ax1.set_ylabel('Balance ($)', color=colors['text'])
ax1.tick_params(colors=colors['text'])
ax1.grid(True, alpha=0.2)
# 2. P/L per Trade
ax2 = axes[0, 1]
ax2.set_facecolor(colors['card'])
profits = [t.profit for t in trades]
bar_colors = [colors['profit'] if p > 0 else colors['loss'] for p in profits]
ax2.bar(range(len(profits)), profits, color=bar_colors, alpha=0.8)
ax2.axhline(y=0, color=colors['neutral'], linestyle='-', linewidth=1)
ax2.set_title('P/L per Trade', color=colors['text'], fontsize=12, fontweight='bold')
ax2.set_xlabel('Trade #', color=colors['text'])
ax2.set_ylabel('Profit ($)', color=colors['text'])
ax2.tick_params(colors=colors['text'])
ax2.grid(True, alpha=0.2)
# 3. Win/Loss Pie Chart
ax3 = axes[1, 0]
ax3.set_facecolor(colors['card'])
wins = sum(1 for t in trades if t.profit > 0)
losses = sum(1 for t in trades if t.profit < 0)
be = sum(1 for t in trades if t.profit == 0)
sizes = [wins, losses, be] if be > 0 else [wins, losses]
pie_colors = [colors['profit'], colors['loss'], colors['neutral']][:len(sizes)]
labels = ['Wins', 'Losses', 'BE'][:len(sizes)]
if sum(sizes) > 0:
wedges, texts, autotexts = ax3.pie(
sizes, labels=labels, autopct='%1.1f%%',
colors=pie_colors, startangle=90
)
for text in texts:
text.set_color(colors['text'])
for autotext in autotexts:
autotext.set_color(colors['text'])
ax3.set_title('Win Rate', color=colors['text'], fontsize=12, fontweight='bold')
# 4. Summary Stats Box
ax4 = axes[1, 1]
ax4.set_facecolor(colors['card'])
ax4.axis('off')
total_profit = sum(t.profit for t in trades)
win_rate = (wins / len(trades) * 100) if trades else 0
avg_profit = total_profit / len(trades) if trades else 0
stats_text = f"""
Daily Summary
─────────────────
Total Trades: {len(trades)}
Win Rate: {win_rate:.1f}%
Net P/L: ${total_profit:+,.2f}
Avg Trade: ${avg_profit:+,.2f}
Start Balance: ${start_balance:,.2f}
End Balance: ${end_balance:,.2f}
Day Change: {((end_balance-start_balance)/start_balance*100):+.2f}%
"""
ax4.text(0.1, 0.9, stats_text, transform=ax4.transAxes,
fontsize=11, verticalalignment='top',
fontfamily='monospace', color=colors['text'])
ax4.set_title('Statistics', color=colors['text'], fontsize=12, fontweight='bold')
plt.tight_layout()
# Save chart
chart_path = self._charts_dir / f"daily_{datetime.now(WIB).strftime('%Y%m%d_%H%M%S')}.png"
plt.savefig(chart_path, dpi=150, facecolor=colors['bg'], edgecolor='none')
plt.close()
return str(chart_path)
except ImportError:
logger.warning("matplotlib not available for chart generation")
return None
except Exception as e:
logger.error(f"Chart generation failed: {e}")
return None
def set_daily_start_balance(self, balance: float):
"""Set the starting balance for daily tracking."""
self._daily_start_balance = balance
self._daily_trades = []
async def send_startup_message(
self,
symbol: str,
capital: float,
balance: float,
mode: str,
ml_model_status: str,
news_status: str = "SAFE",
# ALL extra context as dict
context: dict = None,
):
"""Send bot startup notification with ALL features as text array."""
ctx = context or {}
config_items = [
f"Symbol: <code>{symbol}</code>",
f"Mode: <code>{mode}</code>",
f"Capital: <code>${capital:,.2f}</code>",
f"Balance: <code>${balance:,.2f}</code>",
f"ML: <code>{ml_model_status}</code>",
]
risk_items = [
f"Risk/Trade: <code>{ctx.get('risk_per_trade', 1)}%</code>",
f"Max Daily Loss: <code>{ctx.get('max_daily_loss', 5)}%</code>",
f"Max Total Loss: <code>{ctx.get('max_total_loss', 10)}%</code>",
f"SL: <code>Smart (ATR-based + Broker safety net)</code>",
f"Max Lot: <code>{ctx.get('max_lot', 0.02)}</code>",
f"Max Positions: <code>{ctx.get('max_positions', 2)}</code>",
f"Cooldown: <code>{ctx.get('cooldown_seconds', 150)}s</code>",
]
# Risk state (loaded from file)
daily_loss = ctx.get("daily_loss", 0)
total_loss = ctx.get("total_loss", 0)
consec = ctx.get("consecutive_losses", 0)
risk_mode = ctx.get("risk_mode", "normal")
state_items = [
f"Mode: <code>{risk_mode.upper()}</code>",
f"Daily Loss: <code>${daily_loss:.2f}</code>",
f"Total Loss: <code>${total_loss:.2f}</code>",
f"Streak: <code>{consec}L</code>",
]
session = ctx.get("session", "Unknown")
can_trade = ctx.get("can_trade", False)
vol = ctx.get("volatility", "unknown")
session_icon = "✅" if can_trade else "⛔"
session_items = [
f"{session_icon} {session}",
f"Volatility: <code>{vol}</code>",
]
news_emoji = "✅" if news_status == "SAFE" else "⚠️"
msg = f"""🚀 <b>BOT STARTED</b>
{self._build_section("Config", config_items)}
{self._build_section("Risk Settings", risk_items)}
{self._build_section("Risk State", state_items)}
{self._build_section("Session", session_items)}
{news_emoji} News: <code>{news_status}</code>
{datetime.now(WIB).strftime('%Y-%m-%d %H:%M')} WIB"""
await self.send_message(msg.strip())
logger.info("Telegram: Startup message sent")
async def send_news_alert(
self,
event_name: str,
condition: str,
reason: str,
buffer_minutes: int = 60,
):
"""Send news alert when high-impact news blocks trading."""
emoji_map = {
"DANGER_NEWS": "🚨",
"DANGER_SENTIMENT": "⚠️",
"CAUTION": "⚡",
"SAFE": "✅",
}
emoji = emoji_map.get(condition, "📰")
news_items = [
f"Event: <code>{event_name[:40]}</code>",
f"Reason: <code>{reason[:50]}</code>",
f"Buffer: <code>{buffer_minutes}m</code>",
]
msg = f"""{emoji} <b>NEWS</b> {condition}
{self._build_section("Detail", news_items)}
{datetime.now(WIB).strftime('%H:%M')} WIB"""
await self.send_message(msg.strip())
logger.info(f"Telegram: News alert sent - {event_name}")
async def send_hourly_analysis(
self,
# Account info
balance: float,
equity: float,
floating_pnl: float,
# Position info
open_positions: int,
position_details: list,
# Market info
symbol: str,
current_price: float,
session: str,
regime: str,
volatility: str,
# ML/AI info
ml_signal: str,
ml_confidence: float,
dynamic_threshold: float,
market_quality: str,
market_score: int,
# Risk info
daily_pnl: float,
daily_trades: int,
risk_mode: str,
max_daily_loss: float,
# Bot info
uptime_hours: float,
total_loops: int,
avg_execution_ms: float,
# News info (optional)
news_status: str = "SAFE",
news_reason: str = "No high-impact news",
# ALL extra context as dict
context: dict = None,
):
"""Send comprehensive hourly analysis report with ALL features."""
now = datetime.now(WIB)
ctx = context or {}
# Floating P/L emoji
float_prefix = "+" if floating_pnl >= 0 else ""
daily_prefix = "+" if daily_pnl >= 0 else ""
# Risk mode indicator
risk_display = risk_mode.upper()
# Market quality indicator
quality_display = market_quality.upper()
# Can trade indicator
can_trade = ml_confidence >= dynamic_threshold and market_quality.lower() != "avoid"
trade_status = "READY" if can_trade else "WAIT"
# === Section 1: Account ===
account_items = [
f"Bal: <code>${balance:,.2f}</code>",
f"Eq: <code>${equity:,.2f}</code>",
f"Float: <b>{float_prefix}${floating_pnl:.2f}</b>",
f"Day: <b>{daily_prefix}${daily_pnl:.2f}</b> ({daily_trades} trades)",
]
# === Section 2: Positions ===
pos_items = []
for pos in position_details[:5]:
t = pos.get("ticket", 0)
d = pos.get("direction", "?")
p = pos.get("profit", 0)
m = pos.get("momentum", 0)
tp_prob = pos.get("tp_probability", 50)
ps = f"+${p:.2f}" if p >= 0 else f"-${abs(p):.2f}"
pos_items.append(f"#{t} {d}: <b>{ps}</b> M:{m:+.0f} TP:{tp_prob:.0f}%")
if not pos_items:
pos_items = ["No positions"]
# === Section 3: Market ===
h1_bias = ctx.get("h1_bias", "NEUTRAL")
atr = ctx.get("atr", 0)
spread = ctx.get("spread", 0)
market_items = [
f"{symbol} <code>${current_price:,.2f}</code>",
f"ATR: <code>{atr:.2f}</code> | Spread: <code>{spread:.1f}</code>",
f"Session: <code>{session}</code>",
f"Regime: <code>{regime}</code> | Vol: <code>{volatility}</code>",
f"H1 Bias: <code>{h1_bias}</code>",
]
# === Section 4: AI Signal ===
smc_signal = ctx.get("smc_signal", "")
smc_conf = ctx.get("smc_confidence", 0)
ai_items = [
f"ML: <code>{ml_signal}</code> {ml_confidence:.0%} / thresh {dynamic_threshold:.0%}",
f"SMC: <code>{smc_signal or 'NONE'}</code> ({smc_conf:.0%})",
f"Quality: <code>{quality_display}</code> (score:{market_score}) → {trade_status}",
]
# === Section 5: Risk ===
consec = ctx.get("consecutive_losses", 0)
total_loss = ctx.get("total_loss", 0)
risk_items = [
f"Mode: <code>{risk_display}</code>",
f"Daily Loss: <code>${abs(min(0, daily_pnl)):.2f}</code> / <code>${max_daily_loss:.2f}</code>",
f"Total Loss: <code>${total_loss:.2f}</code> | Streak: <code>{consec}L</code>",
]
# === Section 6: Entry Filters ===
filters = ctx.get("entry_filters", [])
filter_items = []
for f in filters:
passed = f.get("passed", True)
name = f.get("name", "")
detail = f.get("detail", "")
icon = "✅" if passed else "❌"
filter_items.append(f"{icon} {name}: {detail}")
# === Section 7: Bot ===
bot_items = [
f"Uptime: <code>{uptime_hours:.1f}h</code> | Loops: <code>{total_loops}</code>",
f"Avg Exec: <code>{avg_execution_ms:.0f}ms</code>",
]
news_emoji = "✅" if news_status == "SAFE" else "⚠️"
# Build sections list (skip empty)
sections = [
self._build_section("Account", account_items),
self._build_section(f"Positions ({open_positions})", pos_items),
self._build_section("Market", market_items),
self._build_section("AI Signal", ai_items),
self._build_section("Risk", risk_items),
]
if filter_items:
sections.append(self._build_section("Entry Filters", filter_items))
sections.append(self._build_section("Bot", bot_items))
body = "\n\n".join(s for s in sections if s)
msg = f"""📊 <b>HOURLY</b> {now.strftime('%H:%M')} WIB
{body}
{news_emoji} News: <code>{news_status}</code>
{now.strftime('%Y-%m-%d %H:%M')} WIB"""
await self.send_message(msg.strip(), disable_notification=True)
logger.info("Telegram: Hourly analysis report sent")
async def send_shutdown_message(
self,
balance: float,
total_trades: int,
total_profit: float,
uptime_hours: float,
# ALL extra context as dict
context: dict = None,
):
"""Send bot shutdown notification with ALL status."""
ctx = context or {}
profit_str = f"+${total_profit:.2f}" if total_profit >= 0 else f"-${abs(total_profit):.2f}"
emoji = "✅" if total_profit >= 0 else "❌"
session_items = [
f"Balance: <code>${balance:,.2f}</code>",
f"Total Trades: <code>{total_trades}</code>",
f"{emoji} P/L: <b>{profit_str}</b>",
f"Uptime: <code>{uptime_hours:.1f}h</code>",
]
risk_mode = ctx.get("risk_mode", "normal")
daily_loss = ctx.get("daily_loss", 0)
daily_profit = ctx.get("daily_profit", 0)
total_loss = ctx.get("total_loss", 0)
consec = ctx.get("consecutive_losses", 0)
session = ctx.get("session", "Unknown")
risk_items = [
f"Mode: <code>{risk_mode.upper()}</code> | Streak: <code>{consec}L</code>",
f"Daily: <code>+${daily_profit:.2f}</code> / <code>-${daily_loss:.2f}</code>",
f"Total Loss: <code>${total_loss:.2f}</code>",
f"Session: <code>{session}</code>",
]
msg = f"""🔴 <b>BOT STOPPED</b>
{self._build_section("Session Summary", session_items)}
{self._build_section("Risk State", risk_items)}
{datetime.now(WIB).strftime('%Y-%m-%d %H:%M')} WIB"""
await self.send_message(msg.strip())
logger.info("Telegram: Shutdown message sent")
def create_telegram_notifier() -> TelegramNotifier:
"""Create Telegram notifier from environment variables."""
from dotenv import load_dotenv
load_dotenv()
bot_token = os.getenv("TELEGRAM_BOT_TOKEN", "")
chat_id = os.getenv("TELEGRAM_CHAT_ID", "")
enabled = bool(bot_token and chat_id)
if not enabled:
logger.warning("Telegram notifier disabled - missing BOT_TOKEN or CHAT_ID")
return TelegramNotifier(
bot_token=bot_token,
chat_id=chat_id,
enabled=enabled,
)
if __name__ == "__main__":
# Test telegram notifier
import asyncio
async def test():
notifier = create_telegram_notifier()
# Test startup message
await notifier.send_startup_message(
symbol="XAUUSD",
capital=5000,
balance=6160,
mode="small",
ml_model_status="Loaded (76 features)",
context={
"risk_per_trade": 1,
"max_daily_loss": 5,
"max_total_loss": 10,
"max_lot": 0.02,
"max_positions": 2,
"cooldown_seconds": 150,
"daily_loss": 0,
"total_loss": 0,
"consecutive_losses": 0,
"risk_mode": "normal",
"session": "London-NY Overlap",
"can_trade": True,
"volatility": "medium",
},
)
# Test trade open
await notifier.notify_trade_open(
ticket=12345678,
symbol="XAUUSD",
order_type="BUY",
lot_size=0.02,
entry_price=2850.00,
stop_loss=2840.00,
take_profit=2870.00,
ml_confidence=0.71,
signal_reason="Bullish BOS + FVG confirmed by ML",
regime="medium_volatility",
volatility="medium",
context={
"dynamic_threshold": 0.55,
"market_quality": "good",
"market_score": 72,
"smc_signal": "BUY",
"smc_confidence": 0.75,
"smc_fvg": True,
"smc_ob": True,
"smc_bos": True,
"smc_choch": False,
"session": "London-NY Overlap",
"h1_bias": "BULLISH",
"risk_mode": "normal",
"daily_loss": 0,
"consecutive_losses": 0,
"entry_filters": [
{"name": "Flash Crash", "passed": True, "detail": "OK"},
{"name": "Regime Filter", "passed": True, "detail": "medium_volatility"},
{"name": "Risk Check", "passed": True, "detail": "OK"},
{"name": "Session Filter", "passed": True, "detail": "London-NY Overlap"},
{"name": "ML Confidence", "passed": True, "detail": "71% >= 55%"},
{"name": "Cooldown", "passed": True, "detail": "OK"},
],
},
)
# Test trade close
await notifier.notify_trade_close(
ticket=12345678,
symbol="XAUUSD",
order_type="BUY",
lot_size=0.02,
entry_price=2850.00,
close_price=2865.00,
profit=30.00,
profit_pips=150,
balance_before=6130.00,
balance_after=6160.00,
duration_seconds=2700,
ml_confidence=0.71,
regime="medium_volatility",
volatility="medium",
context={
"exit_reason": "take_profit",
"risk_mode": "normal",
"daily_loss": 0,
"daily_profit": 30.00,
"consecutive_losses": 0,
"total_loss": 0,
"session_trades": 3,
"session_wins": 2,
"session_profit": 30.00,
"win_rate": 66.7,
"session": "London-NY Overlap",
},
)
# Test market update
await notifier.notify_market_update(
symbol="XAUUSD",
price=2855.50,
regime="medium_volatility",
volatility="medium",
ml_signal="BUY",
ml_confidence=0.68,
trend_direction="UPTREND",
session="London-NY Overlap",
can_trade=True,
atr=12.5,
spread=3.2,
context={
"h1_bias": "BULLISH",
"dynamic_threshold": 0.55,
"market_quality": "good",
"market_score": 72,
"smc_signal": "BUY",
"smc_confidence": 0.75,
"risk_mode": "normal",
"daily_loss": 0,
"consecutive_losses": 0,
"session_trades": 1,
"session_profit": 30.00,
},
)
# Test shutdown
await notifier.send_shutdown_message(
balance=6160.00,
total_trades=3,
total_profit=45.00,
uptime_hours=8.5,
context={
"risk_mode": "normal",
"daily_loss": 0,
"daily_profit": 45.00,
"total_loss": 0,
"consecutive_losses": 0,
"session": "NY Close",
},
)
await notifier.close()
asyncio.run(test())