""" 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: {e}") elif cmd == "help": await self._send_help() processed += 1 else: await self.send_message(f"❓ Unknown: /{cmd}\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"""📋 COMMANDS {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"{title}"] 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"{trade.symbol}", f"Entry: {trade.entry_price:.2f}", f"Lot: {trade.lot_size}", f"SL: {sl_display} (-${potential_loss:.0f})", f"TP: {trade.take_profit:.2f} (+${potential_profit:.0f})", f"R:R: 1:{rr_ratio:.1f}", ] # === 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: {ml_conf:.0%} / thresh {threshold:.0%}", f"Quality: {quality.upper()} (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: {smc_signal or 'NONE'} ({smc_conf:.0%})", f"Patterns: {', '.join(patterns) if patterns else 'None'}", ] # === 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: {session}", f"Regime: {regime} | Vol: {vol}", f"H1 Bias: {h1_bias}", ] # === 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: {risk_mode.upper()}", f"Daily Loss: ${daily_loss:.2f} | Streak: {consec}L", ] # === 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} {direction} #{trade.ticket} {body} {trade.signal_reason[:80]} ⏰ {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"{trade.symbol} {trade.order_type}", f"Entry: {trade.entry_price:.2f} → Exit: {trade.close_price:.2f}", f"Lot: {trade.lot_size} | Pips: {trade.profit_pips:+.1f}", f"P/L: {profit_str} ({pct_str})", f"Duration: {duration_str}", ] # === Section 2: Exit === exit_reason = ctx.get("exit_reason", "unknown") exit_items = [ f"Reason: {exit_reason}", f"Regime: {trade.regime or 'unknown'} | Vol: {trade.volatility or 'unknown'}", f"Session: {ctx.get('session', 'Unknown')}", ] # === Section 3: Balance === balance_items = [ f"Before: ${trade.balance_before:,.2f}", f"After: ${trade.balance_after:,.2f} ({bal_change_str})", ] # === 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: {session_wins}W / {session_trades}T", f"Win Rate: {win_rate:.1f}%", f"Session P/L: {session_pnl_str}", f"Streak: {consec}L | Mode: {ctx.get('risk_mode', 'normal').upper()}", ] # === Build message === msg = f"""{emoji} {result} #{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"{condition.symbol} ${condition.price:.2f}", f"ATR: {condition.atr:.2f} | Spread: {condition.spread:.1f}", ] # === Section 2: AI Signal === signal_items = [ f"{signal_emoji} ML: {condition.ml_signal} {condition.ml_confidence:.0%} / thresh {threshold:.0%}", f"SMC: {smc_signal or 'NONE'} ({smc_conf:.0%})", f"Quality: {quality.upper()} (score:{score})", f"Trend: {condition.trend_direction} | H1: {h1_bias}", ] # === Section 3: Market === market_items = [ f"Regime: {condition.regime} | Vol: {condition.volatility}", f"Session: {condition.session}", 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: {risk_mode.upper()} | Streak: {consec}L", f"Daily Loss: ${daily_loss:.2f}", f"Session: {session_trades} trades, {sp_str}", ] msg = f"""📊 MARKET UPDATE {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"P/L: {profit_str} ({pct_str})", f"Gross Win: +${gross_profit:.2f}", f"Gross Loss: -${gross_loss:.2f}", f"Bal Start: ${start_balance:,.2f}", f"Bal End: ${end_balance:,.2f}", ] # === Section 2: Stats === stats_items = [ f"Total: {total_trades} trades", f"Wins: {winning_trades} | Losses: {losing_trades}", f"Win Rate: {win_rate:.1f}%", f"Profit Factor: {pf_str}", f"Avg/Trade: ${avg_profit:.2f}", ] # === 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} DAILY REPORT {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} {title} {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: ${balance:,.0f}", f"Eq: ${equity:,.0f}", f"Pos: {open_positions}", f"Session: {session}", f"ML: {ml_status}", f"Uptime: {uptime_hours:.1f}h", ] msg = f"""🤖 STATUS 🟢 {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: {symbol}", f"Mode: {mode}", f"Capital: ${capital:,.2f}", f"Balance: ${balance:,.2f}", f"ML: {ml_model_status}", ] risk_items = [ f"Risk/Trade: {ctx.get('risk_per_trade', 1)}%", f"Max Daily Loss: {ctx.get('max_daily_loss', 5)}%", f"Max Total Loss: {ctx.get('max_total_loss', 10)}%", f"SL: Smart (ATR-based + Broker safety net)", f"Max Lot: {ctx.get('max_lot', 0.02)}", f"Max Positions: {ctx.get('max_positions', 2)}", f"Cooldown: {ctx.get('cooldown_seconds', 150)}s", ] # 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: {risk_mode.upper()}", f"Daily Loss: ${daily_loss:.2f}", f"Total Loss: ${total_loss:.2f}", f"Streak: {consec}L", ] 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: {vol}", ] news_emoji = "✅" if news_status == "SAFE" else "⚠️" msg = f"""🚀 BOT STARTED {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: {news_status} ⏰ {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: {event_name[:40]}", f"Reason: {reason[:50]}", f"Buffer: {buffer_minutes}m", ] msg = f"""{emoji} NEWS {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: ${balance:,.2f}", f"Eq: ${equity:,.2f}", f"Float: {float_prefix}${floating_pnl:.2f}", f"Day: {daily_prefix}${daily_pnl:.2f} ({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}: {ps} 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} ${current_price:,.2f}", f"ATR: {atr:.2f} | Spread: {spread:.1f}", f"Session: {session}", f"Regime: {regime} | Vol: {volatility}", f"H1 Bias: {h1_bias}", ] # === Section 4: AI Signal === smc_signal = ctx.get("smc_signal", "") smc_conf = ctx.get("smc_confidence", 0) ai_items = [ f"ML: {ml_signal} {ml_confidence:.0%} / thresh {dynamic_threshold:.0%}", f"SMC: {smc_signal or 'NONE'} ({smc_conf:.0%})", f"Quality: {quality_display} (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: {risk_display}", f"Daily Loss: ${abs(min(0, daily_pnl)):.2f} / ${max_daily_loss:.2f}", f"Total Loss: ${total_loss:.2f} | Streak: {consec}L", ] # === 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: {uptime_hours:.1f}h | Loops: {total_loops}", f"Avg Exec: {avg_execution_ms:.0f}ms", ] 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"""📊 HOURLY {now.strftime('%H:%M')} WIB {body} {news_emoji} News: {news_status} ⏰ {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: ${balance:,.2f}", f"Total Trades: {total_trades}", f"{emoji} P/L: {profit_str}", f"Uptime: {uptime_hours:.1f}h", ] 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: {risk_mode.upper()} | Streak: {consec}L", f"Daily: +${daily_profit:.2f} / -${daily_loss:.2f}", f"Total Loss: ${total_loss:.2f}", f"Session: {session}", ] msg = f"""🔴 BOT STOPPED {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())