diff --git a/main_live.py b/main_live.py index d89d395..367f648 100644 --- a/main_live.py +++ b/main_live.py @@ -61,6 +61,7 @@ from src.position_manager import SmartPositionManager from src.session_filter import SessionFilter, create_wib_session_filter from src.auto_trainer import AutoTrainer, create_auto_trainer from src.telegram_notifier import TelegramNotifier, create_telegram_notifier +from src.telegram_notifications import TelegramNotifications from src.smart_risk_manager import SmartRiskManager, create_smart_risk_manager from src.dynamic_confidence import DynamicConfidenceManager, create_dynamic_confidence # from src.news_agent import NewsAgent, create_news_agent, MarketCondition # DISABLED @@ -164,6 +165,9 @@ class TradingBot: # Initialize Telegram Notifier - smart notifications self.telegram = create_telegram_notifier() + # Initialize Telegram Notifications helper (extracts notification logic) + self.notifications = TelegramNotifications(self) + # News Agent DISABLED - backtest proved it costs $178 profit # ML model already handles volatility well self.news_agent = None @@ -187,6 +191,7 @@ class TradingBot: self._daily_start_balance: float = 0 self._total_session_profit: float = 0 self._total_session_trades: int = 0 + self._total_session_wins: int = 0 self._last_market_update_time: Optional[datetime] = None self._last_hourly_report_time: Optional[datetime] = None self._open_trade_info: Dict = {} # Track trade info for close notification @@ -599,10 +604,12 @@ class TradingBot: "avgExecutionMs": round(avg_ms, 1), "uptimeHours": round(uptime_hours, 1), "totalSessionTrades": self._total_session_trades, + "totalSessionWins": self._total_session_wins, "totalSessionProfit": round(self._total_session_profit, 2), + "winRate": round(self._total_session_wins / self._total_session_trades * 100, 1) if self._total_session_trades > 0 else 0, } except Exception: - return {"loopCount": 0, "avgExecutionMs": 0, "uptimeHours": 0, "totalSessionTrades": 0, "totalSessionProfit": 0} + return {"loopCount": 0, "avgExecutionMs": 0, "uptimeHours": 0, "totalSessionTrades": 0, "totalSessionWins": 0, "totalSessionProfit": 0, "winRate": 0} def _get_market_close_status(self) -> dict: """Get market close timing info for dashboard.""" @@ -676,21 +683,16 @@ class TradingBot: self.telegram.set_daily_start_balance(balance) # Send Telegram startup notification - ml_status = f"Loaded ({len(self.ml_model.feature_names)} features)" if self.ml_model.fitted else "Not loaded" - await self.telegram.send_startup_message( - symbol=self.config.symbol, - capital=self.config.capital, - balance=balance, - mode=self.config.capital_mode.value, - ml_model_status=ml_status, - news_status="DISABLED", - ) + await self.notifications.send_startup() except Exception as e: logger.error(f"Failed to connect to MT5: {e}") if not self.simulation: return + # Register Telegram commands + self._register_telegram_commands() + # Start main loop self._running = True self._dash_log("info", "Bot started - trading loop active") @@ -702,21 +704,12 @@ class TradingBot: logger.info("Stopping trading bot...") self._running = False - # Calculate uptime - uptime_hours = (datetime.now() - self._start_time).total_seconds() / 3600 - # Send Telegram shutdown notification + await self.notifications.send_shutdown() try: - balance = self.mt5.account_balance or self.config.capital - await self.telegram.send_shutdown_message( - balance=balance, - total_trades=self._total_session_trades, - total_profit=self._total_session_profit, - uptime_hours=uptime_hours, - ) await self.telegram.close() except Exception as e: - logger.error(f"Failed to send shutdown notification: {e}") + logger.error(f"Failed to close telegram session: {e}") self.mt5.disconnect() self._log_summary() @@ -823,6 +816,11 @@ class TradingBot: logger.debug(f"H1 bias error: {e}") return "NEUTRAL" + def _register_telegram_commands(self): + """Register Telegram command handlers from separate module.""" + from src.telegram_commands import register_commands + register_commands(self) + async def _main_loop(self): """Main trading loop - CANDLE-BASED (not time-based).""" last_position_check = time.time() @@ -893,6 +891,12 @@ class TradingBot: # Write dashboard status file (for Docker API) self._write_dashboard_status() + # Poll Telegram commands (non-blocking, every loop) + try: + await self.telegram.poll_commands() + except Exception: + pass + # Wait before next check (5 seconds between candle checks) await asyncio.sleep(5) @@ -920,13 +924,7 @@ class TradingBot: await self._emergency_close_all() except Exception as e: logger.critical(f"CRITICAL: Emergency close failed: {e}") - try: - await self.telegram.send_message( - f"CRITICAL: Flash crash {move_pct:.2f}% but emergency close FAILED!\n" - f"Error: {e}\nMANUAL INTERVENTION REQUIRED!" - ) - except: - pass + await self.notifications.send_flash_crash_critical(move_pct, e) return # --- POSITION MANAGEMENT (uses cached data — Fix 4) --- @@ -1034,18 +1032,9 @@ class TradingBot: await self._emergency_close_all() except Exception as e: logger.critical(f"CRITICAL: Emergency close failed completely: {e}") - # Try to send alert even if close failed - try: - await self.telegram.send_message( - f"🚨🚨 CRITICAL ERROR 🚨🚨\n\n" - f"Flash crash detected but emergency close FAILED!\n" - f"Error: {e}\n\n" - f"MANUAL INTERVENTION REQUIRED!" - ) - except: - pass + await self.notifications.send_flash_crash_critical(move_pct, e) return - + # 6. Check if trading is allowed account_balance = self.mt5.account_balance or self.config.capital account_equity = self.mt5.account_equity or self.config.capital @@ -1092,7 +1081,7 @@ class TradingBot: # Send hourly analysis report to Telegram (every 1 hour) # Placed here to ensure it's sent regardless of trading conditions - await self._send_hourly_analysis_if_due( + await self.notifications.send_hourly_analysis_if_due( df=df, regime_state=regime_state, ml_prediction=ml_prediction, @@ -1164,9 +1153,9 @@ class TradingBot: h1_tag = f" | H1: {h1_bias}" if h1_bias != "NEUTRAL" else "" logger.info(f"Price: {price:.2f} | Regime: {regime_state.regime.value if regime_state else 'N/A'} | SMC: {smc_signal.signal_type if smc_signal else 'NONE'} | ML: {ml_prediction.signal}({ml_prediction.confidence:.0%}){h1_tag}") - # Send market update to Telegram (every 30 minutes) - only after first loop - if self._loop_count > 0 and self._loop_count % 30 == 0: - await self._send_market_update(df, regime_state, ml_prediction) + # Market update disabled from auto-send (available via command) + # if self._loop_count > 0 and self._loop_count % 30 == 0: + # await self._send_market_update(df, regime_state, ml_prediction) # Track SMC signal for filter pipeline self._last_filter_results.append({"name": "SMC Signal", "passed": smc_signal is not None, "detail": f"{smc_signal.signal_type} ({smc_signal.confidence:.0%})" if smc_signal else "No signal"}) @@ -1574,33 +1563,15 @@ class TradingBot: session_status = self.session_filter.get_status_report() volatility = session_status.get("volatility", "unknown") - # Store trade info for close notification - self._open_trade_info[result.order_id] = { - "entry_price": signal.entry_price, - "open_time": datetime.now(), - "balance_before": self.mt5.account_balance, - "ml_confidence": signal.confidence, - "regime": regime, - "volatility": volatility, - } - - # Send Telegram notification - try: - await self.telegram.notify_trade_open( - ticket=result.order_id, - symbol=self.config.symbol, - order_type=signal.signal_type, - lot_size=position.lot_size, - entry_price=signal.entry_price, - stop_loss=signal.stop_loss, - take_profit=signal.take_profit, - ml_confidence=signal.confidence, - signal_reason=signal.reason, - regime=regime, - volatility=volatility, - ) - except Exception as e: - logger.warning(f"Failed to send trade open notification: {e}") + # Send Telegram notification (stores trade info + builds context internally) + await self.notifications.notify_trade_open( + result=result, + signal=signal, + position=position, + regime=regime, + volatility=volatility, + session_status=session_status, + ) else: logger.error(f"Order failed: {result.comment} (code: {result.retcode})") @@ -1791,23 +1762,23 @@ class TradingBot: except Exception as e: logger.warning(f"Failed to log trade open: {e}") - # Send Telegram notification - try: - await self.telegram.notify_trade_open( - ticket=result.order_id, - symbol=self.config.symbol, - order_type=signal.signal_type, - lot_size=position.lot_size, - entry_price=signal.entry_price, - stop_loss=0, # No SL - take_profit=signal.take_profit, - ml_confidence=signal.confidence, - signal_reason=f"SAFE MODE: {signal.reason}", - regime=regime, - volatility=volatility, - ) - except Exception as e: - logger.warning(f"Failed to send trade open notification: {e}") + # Send Telegram notification (stores trade info + builds context internally) + await self.notifications.notify_trade_open( + result=result, + signal=signal, + position=position, + regime=regime, + volatility=volatility, + session_status=session_status, + safe_mode=True, + smc_fvg=smc_fvg, + smc_ob=smc_ob, + smc_bos=smc_bos, + smc_choch=smc_choch, + dynamic_threshold=dynamic_threshold, + market_quality=market_quality, + market_score=market_score, + ) else: logger.error(f"Order failed: {result.comment} (code: {result.retcode})") @@ -1845,7 +1816,7 @@ class TradingBot: risk_result = self.smart_risk.record_trade_result(profit) self.smart_risk.unregister_position(action.ticket) self.position_manager._peak_profits.pop(action.ticket, None) - await self._notify_trade_close_smart(action.ticket, profit, current_price, action.reason) + await self.notifications.notify_trade_close_smart(action.ticket, profit, current_price, action.reason) logger.info(f"CLOSED #{action.ticket}: {action.reason}") continue # Skip SmartRiskManager eval for this ticket @@ -1928,18 +1899,18 @@ class TradingBot: logger.warning(f"Failed to log trade close: {e}") # Send notification - await self._notify_trade_close_smart(ticket, profit, current_price, message) + await self.notifications.notify_trade_close_smart(ticket, profit, current_price, message) # Check for critical limit violations and send alerts if risk_result.get("total_limit_hit"): - await self._send_critical_limit_alert( + await self.notifications.send_critical_limit_alert( "TOTAL LOSS LIMIT", risk_result.get("total_loss", 0), self.smart_risk.max_total_loss_usd, self.smart_risk.max_total_loss_percent ) elif risk_result.get("daily_limit_hit"): - await self._send_critical_limit_alert( + await self.notifications.send_critical_limit_alert( "DAILY LOSS LIMIT", risk_result.get("daily_loss", 0), self.smart_risk.max_daily_loss_usd, @@ -1952,84 +1923,6 @@ class TradingBot: if self._loop_count % 60 == 0: logger.info(f"Position #{ticket}: {message}") - async def _notify_trade_close_smart(self, ticket: int, profit: float, current_price: float, reason: str): - """Send notification for smart close.""" - try: - trade_info = self._open_trade_info.pop(ticket, {}) - - balance_before = trade_info.get("balance_before", 0) - balance_after = self.mt5.account_balance or 0 - entry_price = trade_info.get("entry_price", current_price) - duration = int((datetime.now() - trade_info.get("open_time", datetime.now())).total_seconds()) - - # Track stats - self._total_session_profit += profit - self._total_session_trades += 1 - - await self.telegram.notify_trade_close( - ticket=ticket, - symbol=self.config.symbol, - order_type=trade_info.get("direction", "BUY"), - lot_size=trade_info.get("lot_size", 0.01), - entry_price=entry_price, - close_price=current_price, - profit=profit, - profit_pips=(current_price - entry_price) / 0.1, - balance_before=balance_before, - balance_after=balance_after, - duration_seconds=duration, - ml_confidence=trade_info.get("ml_confidence", 0), - regime=trade_info.get("regime", "unknown"), - volatility=trade_info.get("volatility", "unknown"), - ) - except Exception as e: - logger.warning(f"Failed to send close notification: {e}") - - async def _send_critical_limit_alert( - self, - limit_type: str, - current_loss: float, - max_loss: float, - max_percent: float - ): - """ - Send critical alert when loss limits are reached. - - Args: - limit_type: "DAILY LOSS LIMIT" or "TOTAL LOSS LIMIT" - current_loss: Current loss amount - max_loss: Maximum allowed loss - max_percent: Maximum loss percentage - """ - logger.critical("=" * 60) - logger.critical(f"CRITICAL: {limit_type} REACHED!") - logger.critical(f"Loss: ${current_loss:.2f} / ${max_loss:.2f} ({max_percent}%)") - logger.critical("TRADING HAS BEEN STOPPED!") - logger.critical("=" * 60) - - try: - if limit_type == "TOTAL LOSS LIMIT": - message = ( - f"🚨🚨 CRITICAL: TOTAL LOSS LIMIT REACHED 🚨🚨\n\n" - f"Total Loss: ${current_loss:.2f}\n" - f"Limit: ${max_loss:.2f} ({max_percent}%)\n\n" - f"ā›” TRADING STOPPED PERMANENTLY\n" - f"Manual reset required to resume trading.\n\n" - f"Please review your trading strategy." - ) - else: - message = ( - f"🚨 DAILY LOSS LIMIT REACHED 🚨\n\n" - f"Daily Loss: ${current_loss:.2f}\n" - f"Limit: ${max_loss:.2f} ({max_percent}%)\n\n" - f"ā›” TRADING STOPPED FOR TODAY\n" - f"Will resume tomorrow automatically." - ) - - await self.telegram.send_message(message) - except Exception as e: - logger.error(f"Failed to send critical alert: {e}") - async def _emergency_close_all(self, max_retries: int = 3): """ Emergency close all positions with retry logic and error handling. @@ -2089,262 +1982,21 @@ class TradingBot: if attempt < max_retries - 1: await asyncio.sleep(2) - # Send critical alert if any failed - if failed_tickets: - alert_msg = f"CRITICAL: Failed to close {len(failed_tickets)} positions: {failed_tickets}" - logger.error(alert_msg) - try: - await self.telegram.send_message( - f"🚨 EMERGENCY CLOSE FAILED!\n\n" - f"Failed tickets: {failed_tickets}\n" - f"Please close manually!" - ) - except: - pass # Don't let telegram failure stop us - else: - try: - await self.telegram.send_message( - f"🚨 EMERGENCY CLOSE COMPLETE\n\n" - f"Closed {closed_count} positions due to flash crash detection" - ) - except: - pass + # Send critical alert + await self.notifications.send_emergency_close_result(closed_count, failed_tickets) - async def _notify_trade_close(self, action, current_price: float): - """Send Telegram notification for trade close.""" - try: - ticket = action.ticket - - # Get trade info from our stored data - trade_info = self._open_trade_info.pop(ticket, {}) - entry_price = trade_info.get("entry_price", current_price) - open_time = trade_info.get("open_time", datetime.now()) - balance_before = trade_info.get("balance_before", self._daily_start_balance) - ml_confidence = trade_info.get("ml_confidence", 0) - regime = trade_info.get("regime", "unknown") - volatility = trade_info.get("volatility", "unknown") - - # Get current balance (after close) - balance_after = self.mt5.account_balance or self.config.capital - - # Calculate profit from action - profit = action.profit if hasattr(action, 'profit') else 0 - if profit == 0: - # Try to calculate from price difference (rough estimate) - profit = balance_after - balance_before - - # Calculate duration - duration_seconds = int((datetime.now() - open_time).total_seconds()) - - # Calculate pips (for XAUUSD, 1 pip = 0.1) - price_diff = current_price - entry_price - profit_pips = price_diff / 0.1 if "XAU" in self.config.symbol else price_diff / 0.0001 - - # Get order type from action - order_type = "BUY" # Default, will be extracted from action if available - - # Track session stats - self._total_session_profit += profit - self._total_session_trades += 1 - - await self.telegram.notify_trade_close( - ticket=ticket, - symbol=self.config.symbol, - order_type=order_type, - lot_size=0.2, # Will be extracted from actual position if available - entry_price=entry_price, - close_price=current_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, - ) - except Exception as e: - logger.warning(f"Failed to send trade close notification: {e}") - - async def _send_market_update(self, df, regime_state, ml_prediction): - """Send periodic market update to Telegram.""" - try: - now = datetime.now() - - # Only send market update every 30 minutes - if self._last_market_update_time: - time_since = (now - self._last_market_update_time).total_seconds() - if time_since < 1800: # 30 minutes - return - - session_status = self.session_filter.get_status_report() - - # Get ATR and spread - atr = df["atr"].tail(1).item() if "atr" in df.columns else 0 - tick = self.mt5.get_tick(self.config.symbol) - spread = (tick.ask - tick.bid) if tick else 0 - - # Determine trend direction - if "ema_9" in df.columns and "ema_21" in df.columns: - ema_9 = df["ema_9"].tail(1).item() - ema_21 = df["ema_21"].tail(1).item() - trend_direction = "UPTREND" if ema_9 > ema_21 else "DOWNTREND" - else: - trend_direction = "NEUTRAL" - - await self.telegram.notify_market_update( - symbol=self.config.symbol, - price=df["close"].tail(1).item(), - regime=regime_state.regime.value if regime_state else "unknown", - volatility=session_status.get("volatility", "unknown"), - ml_signal=ml_prediction.signal, - ml_confidence=ml_prediction.confidence, - trend_direction=trend_direction, - session=session_status.get("current_session", "Unknown"), - can_trade=session_status.get("can_trade", True), - atr=atr, - spread=spread, - ) - - self._last_market_update_time = now - logger.info("Telegram: Market update sent") - - except Exception as e: - logger.warning(f"Failed to send market update: {e}") - - async def _send_daily_summary(self): - """Send daily trading summary to Telegram.""" - try: - balance = self.mt5.account_balance or self.config.capital - await self.telegram.send_daily_summary( - start_balance=self._daily_start_balance, - end_balance=balance, - ) - logger.info("Telegram: Daily summary sent") - except Exception as e: - logger.warning(f"Failed to send daily summary: {e}") - - async def _send_hourly_analysis_if_due( - self, - df, - regime_state, - ml_prediction, - open_positions, - current_price: float, - ): - """ - Send comprehensive hourly analysis report to Telegram. - Interval: Every 1 hour - """ - now = datetime.now() - - # Check if 1 hour has passed since last report - if self._last_hourly_report_time: - time_since = (now - self._last_hourly_report_time).total_seconds() - if time_since < 3600: # 1 hour = 3600 seconds - return - - try: - # Gather all data for report - balance = self.mt5.account_balance or self.config.capital - equity = self.mt5.account_equity or self.config.capital - floating_pnl = equity - balance - - # Position details with Smart Risk data - position_details = [] - for row in open_positions.iter_rows(named=True): - ticket = row["ticket"] - profit = row.get("profit", 0) - position_type = row.get("type", 0) - direction = "BUY" if position_type == 0 else "SELL" - - # Get guard data if available - guard = self.smart_risk._position_guards.get(ticket) - momentum = guard.momentum_score if guard else 0 - tp_prob = guard.get_tp_probability() if guard else 50 - - position_details.append({ - "ticket": ticket, - "direction": direction, - "profit": profit, - "momentum": momentum, - "tp_probability": tp_prob, - }) - - # Session info - session_status = self.session_filter.get_status_report() - - # Dynamic confidence data - market_analysis = self.dynamic_confidence.analyze_market( - session=session_status.get("current_session", "Unknown"), - regime=regime_state.regime.value if regime_state else "unknown", - volatility=session_status.get("volatility", "medium"), - trend_direction=regime_state.regime.value if regime_state else "neutral", - has_smc_signal=False, - ml_signal=ml_prediction.signal, - ml_confidence=ml_prediction.confidence, - ) - - # Risk state - risk_rec = self.smart_risk.get_trading_recommendation() - - # Execution stats - avg_exec = (sum(self._execution_times) / len(self._execution_times) * 1000) if self._execution_times else 0 - uptime = (now - self._start_time).total_seconds() / 3600 # hours - - # Send the report - await self.telegram.send_hourly_analysis( - # Account - balance=balance, - equity=equity, - floating_pnl=floating_pnl, - # Positions - open_positions=len(open_positions), - position_details=position_details, - # Market - symbol=self.config.symbol, - current_price=current_price, - session=session_status.get("current_session", "Unknown"), - regime=regime_state.regime.value if regime_state else "unknown", - volatility=session_status.get("volatility", "unknown"), - # AI/ML - ml_signal=ml_prediction.signal, - ml_confidence=ml_prediction.confidence, - dynamic_threshold=market_analysis.confidence_threshold, - market_quality=market_analysis.quality.value, - market_score=market_analysis.score, - # Risk - daily_pnl=self._total_session_profit, - daily_trades=self._total_session_trades, - risk_mode=risk_rec.get("mode", "normal"), - max_daily_loss=self.smart_risk.max_daily_loss_usd, - # Bot - uptime_hours=uptime, - total_loops=self._loop_count, - avg_execution_ms=avg_exec, - # News - disabled - news_status="DISABLED", - news_reason="News agent disabled", - ) - - self._last_hourly_report_time = now - logger.info("Telegram: Hourly analysis report sent") - - except Exception as e: - logger.warning(f"Failed to send hourly analysis: {e}") - def _on_new_day(self): """Handle new trading day.""" logger.info("=" * 60) logger.info(f"NEW TRADING DAY: {date.today()}") logger.info("=" * 60) - # Send daily summary before resetting (run synchronously) - try: - import asyncio - asyncio.create_task(self._send_daily_summary()) - except Exception as e: - logger.warning(f"Could not send daily summary: {e}") + # Daily summary disabled from auto-send (available via command) + # try: + # import asyncio + # asyncio.create_task(self._send_daily_summary()) + # except Exception as e: + # logger.warning(f"Could not send daily summary: {e}") self._current_date = date.today() self.risk_engine.reset_daily_stats() diff --git a/src/telegram_commands.py b/src/telegram_commands.py new file mode 100644 index 0000000..ba2d858 --- /dev/null +++ b/src/telegram_commands.py @@ -0,0 +1,317 @@ +""" +Telegram Command Handlers +========================= +Handles all Telegram bot commands separately from main_live.py. + +Commands: + /status — Bot status & account overview + /market — Current market analysis & signals + /risk — Risk management state & settings + /positions — Open positions detail + /pos — Alias for /positions + /daily — Daily trading summary + /filters — Entry filter status + /help — List all available commands + +Integration: + from src.telegram_commands import register_commands + register_commands(bot) # bot = TradingBot instance +""" + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from datetime import datetime, date +from zoneinfo import ZoneInfo +from loguru import logger + +WIB = ZoneInfo("Asia/Jakarta") + + +def _fmt_usd(value: float) -> str: + """Format USD value with sign.""" + if value >= 0: + return f"+${value:.2f}" + return f"-${abs(value):.2f}" + + +def _timestamp() -> str: + """Return formatted WIB timestamp.""" + return datetime.now(WIB).strftime('%H:%M') + + +def register_commands(bot): + """ + Register all Telegram commands on the bot instance. + + Args: + bot: TradingBot instance (has .telegram, .mt5, .smart_risk, etc.) + """ + tg = bot.telegram + build = tg._build_section + + # ------------------------------------------------------------------ + # /status — Bot status & account overview + # ------------------------------------------------------------------ + async def cmd_status(): + balance = bot.mt5.account_balance or 0 + equity = bot.mt5.account_equity or 0 + floating = equity - balance + session_status = bot.session_filter.get_status_report() + risk_rec = bot.smart_risk.get_trading_recommendation() + risk_state = bot.smart_risk.get_state() + uptime = (datetime.now() - bot._start_time).total_seconds() / 3600 + avg_ms = (sum(bot._execution_times) / len(bot._execution_times) * 1000) if bot._execution_times else 0 + wr = (bot._total_session_wins / bot._total_session_trades * 100) if bot._total_session_trades > 0 else 0 + can_icon = "āœ…" if session_status.get("can_trade", False) else "ā›”" + + items_acct = [ + f"Bal: ${balance:,.2f}", + f"Eq: ${equity:,.2f}", + f"Float: {_fmt_usd(floating)}", + ] + items_session = [ + f"Trades: {bot._total_session_trades} ({bot._total_session_wins}W) | WR: {wr:.1f}%", + f"P/L: {_fmt_usd(bot._total_session_profit)}", + ] + items_risk = [ + f"Mode: {risk_rec.get('mode', 'normal').upper()}", + f"Daily Loss: ${risk_state.daily_loss:.2f} | Streak: {risk_state.consecutive_losses}L", + f"Total Loss: ${bot.smart_risk._total_loss:.2f}", + ] + items_bot = [ + f"{can_icon} {session_status.get('current_session', 'Unknown')} | Vol: {session_status.get('volatility', '?')}", + f"Uptime: {uptime:.1f}h | Loops: {bot._loop_count} | Exec: {avg_ms:.0f}ms", + ] + + return f"""šŸ¤– STATUS + +{build("Account", items_acct)} + +{build("Session", items_session)} + +{build("Risk", items_risk)} + +{build("Bot", items_bot)} + +ā° {_timestamp()} WIB""".strip() + + cmd_status._cmd_desc = "Bot status & account" + + # ------------------------------------------------------------------ + # /market — Current market analysis + # ------------------------------------------------------------------ + async def cmd_market(): + tick = bot.mt5.get_tick(bot.config.symbol) + price = tick.bid if tick else 0 + spread = (tick.ask - tick.bid) if tick else 0 + + session_status = bot.session_filter.get_status_report() + h1_bias = getattr(bot, "_h1_bias_cache", "NEUTRAL") + regime = getattr(bot, "_last_regime", None) + regime_str = regime.value if regime else "unknown" + ml_signal = getattr(bot, "_last_ml_signal", "HOLD") + ml_conf = getattr(bot, "_last_ml_confidence", 0) + smc_signal = getattr(bot, "_last_raw_smc_signal", "") + smc_conf = getattr(bot, "_last_raw_smc_confidence", 0) + threshold = getattr(bot, "_last_dynamic_threshold", 0.55) + quality = getattr(bot, "_last_market_quality", "unknown") + score = getattr(bot, "_last_market_score", 0) + + try: + df = bot.mt5.get_market_data(bot.config.symbol, bot.config.execution_timeframe, 50) + atr = df["atr"].tail(1).item() if "atr" in df.columns else 0 + except Exception: + atr = 0 + + sig_emoji = {"BUY": "🟢", "SELL": "šŸ”“"}.get(ml_signal, "⚪") + can_icon = "āœ… READY" if session_status.get("can_trade", False) else "ā›” WAIT" + + items_price = [ + f"{bot.config.symbol} ${price:.2f}", + f"ATR: {atr:.2f} | Spread: {spread:.1f}", + ] + items_signal = [ + f"{sig_emoji} ML: {ml_signal} {ml_conf:.0%} / thresh {threshold:.0%}", + f"SMC: {smc_signal or 'NONE'} ({smc_conf:.0%})", + f"Quality: {quality.upper()} (score:{score})", + f"H1 Bias: {h1_bias}", + ] + items_market = [ + f"Regime: {regime_str} | Vol: {session_status.get('volatility', '?')}", + f"Session: {session_status.get('current_session', 'Unknown')}", + f"Status: {can_icon}", + ] + + return f"""šŸ“Š MARKET + +{build("Price", items_price)} + +{build("AI Signal", items_signal)} + +{build("Market", items_market)} + +ā° {_timestamp()} WIB""".strip() + + cmd_market._cmd_desc = "Market analysis & signals" + + # ------------------------------------------------------------------ + # /risk — Risk management state + # ------------------------------------------------------------------ + async def cmd_risk(): + risk_state = bot.smart_risk.get_state() + risk_rec = bot.smart_risk.get_trading_recommendation() + balance = bot.mt5.account_balance or bot.config.capital + max_daily_usd = bot.smart_risk.max_daily_loss_usd + max_total_usd = balance * bot.smart_risk.max_total_loss_percent / 100 + + items_settings = [ + f"Risk/Trade: {bot.config.risk.risk_per_trade}%", + f"Max Daily Loss: {bot.config.risk.max_daily_loss}% (${max_daily_usd:.2f})", + f"Max Total Loss: {bot.smart_risk.max_total_loss_percent}% (${max_total_usd:.2f})", + f"Max Lot: {bot.smart_risk.max_lot_size}", + f"Max Positions: {bot.smart_risk.max_concurrent_positions}", + ] + items_state = [ + f"Mode: {risk_rec.get('mode', 'normal').upper()}", + f"Daily Loss: ${risk_state.daily_loss:.2f} / ${max_daily_usd:.2f}", + f"Daily Profit: ${risk_state.daily_profit:.2f}", + f"Total Loss: ${bot.smart_risk._total_loss:.2f}", + f"Streak: {risk_state.consecutive_losses}L", + ] + items_rec = [ + f"Lot: {risk_rec.get('recommended_lot', 0)}", + f"Reason: {risk_rec.get('reason', '')[:60]}", + ] + + return f"""šŸ›” RISK + +{build("Settings", items_settings)} + +{build("Current State", items_state)} + +{build("Recommendation", items_rec)} + +ā° {_timestamp()} WIB""".strip() + + cmd_risk._cmd_desc = "Risk management state" + + # ------------------------------------------------------------------ + # /positions — Open positions detail + # ------------------------------------------------------------------ + async def cmd_positions(): + positions = bot.mt5.get_open_positions( + symbol=bot.config.symbol, + magic=bot.config.magic_number, + ) + if positions is None or len(positions) == 0: + return f"šŸ“­ POSITIONS\n\nā”” No open positions\n\nā° {_timestamp()} WIB" + + pos_items = [] + total_profit = 0 + for row in positions.iter_rows(named=True): + ticket = row.get("ticket", 0) + direction = "BUY" if row.get("type", 0) == 0 else "SELL" + profit = row.get("profit", 0) + total_profit += profit + open_price = row.get("price_open", 0) + current = row.get("price_current", 0) + sl = row.get("sl", 0) + tp = row.get("tp", 0) + lot = row.get("volume", 0) + + guard = bot.smart_risk._position_guards.get(ticket) + momentum = guard.momentum_score if guard else 0 + + pos_items.append(f"#{ticket} {direction} {lot}") + pos_items.append(f" Open: {open_price:.2f} → Now: {current:.2f}") + pos_items.append(f" SL: {sl:.2f} | TP: {tp:.2f}") + pos_items.append(f" P/L: {_fmt_usd(profit)} | M: {momentum:+.0f}") + + summary = [f"Total: {len(positions)} positions, {_fmt_usd(total_profit)}"] + + return f"""šŸ“ˆ POSITIONS + +{build("Open", pos_items)} + +{build("Summary", summary)} + +ā° {_timestamp()} WIB""".strip() + + cmd_positions._cmd_desc = "Open positions detail" + + # ------------------------------------------------------------------ + # /daily — Daily trading summary + # ------------------------------------------------------------------ + async def cmd_daily(): + balance = bot.mt5.account_balance or bot.config.capital + trades = bot._total_session_trades + wins = bot._total_session_wins + losses = trades - wins + wr = (wins / trades * 100) if trades > 0 else 0 + day_change = ((balance - bot._daily_start_balance) / bot._daily_start_balance * 100) if bot._daily_start_balance > 0 else 0 + day_str = f"+{day_change:.2f}%" if day_change >= 0 else f"{day_change:.2f}%" + + items_balance = [ + f"Start: ${bot._daily_start_balance:,.2f}", + f"Now: ${balance:,.2f} ({day_str})", + f"P/L: {_fmt_usd(bot._total_session_profit)}", + ] + items_stats = [ + f"Trades: {trades}", + f"Wins: {wins} | Losses: {losses}", + f"Win Rate: {wr:.1f}%", + ] + + return f"""šŸ“‹ DAILY {date.today().strftime('%Y-%m-%d')} + +{build("Balance", items_balance)} + +{build("Stats", items_stats)} + +ā° {_timestamp()} WIB""".strip() + + cmd_daily._cmd_desc = "Daily trading summary" + + # ------------------------------------------------------------------ + # /filters — Entry filter status + # ------------------------------------------------------------------ + async def cmd_filters(): + filters = getattr(bot, "_last_filter_results", []) + if not filters: + return f"šŸ” FILTERS\n\nā”” No filter data yet (wait for next candle)\n\nā° {_timestamp()} WIB" + + filter_items = [] + for f in filters: + icon = "āœ…" if f.get("passed", True) else "āŒ" + filter_items.append(f"{icon} {f.get('name', '')}: {f.get('detail', '')}") + + passed = sum(1 for f in filters if f.get("passed", True)) + total = len(filters) + + return f"""šŸ” FILTERS ({passed}/{total} passed) + +{build("Entry Filters", filter_items)} + +ā° {_timestamp()} WIB""".strip() + + cmd_filters._cmd_desc = "Entry filter status" + + # ------------------------------------------------------------------ + # Register all commands + # ------------------------------------------------------------------ + tg.register_command("status", cmd_status) + tg.register_command("s", cmd_status) # alias + tg.register_command("market", cmd_market) + tg.register_command("m", cmd_market) # alias + tg.register_command("risk", cmd_risk) + tg.register_command("positions", cmd_positions) + tg.register_command("pos", cmd_positions) # alias + tg.register_command("p", cmd_positions) # alias + tg.register_command("daily", cmd_daily) + tg.register_command("d", cmd_daily) # alias + tg.register_command("filters", cmd_filters) + tg.register_command("f", cmd_filters) # alias + + logger.info("Telegram commands registered: /status /market /risk /positions /daily /filters /help") diff --git a/src/telegram_notifications.py b/src/telegram_notifications.py new file mode 100644 index 0000000..97e7f1d --- /dev/null +++ b/src/telegram_notifications.py @@ -0,0 +1,615 @@ +""" +Telegram Notification Helpers +============================= +Extracts all notification logic from main_live.py into a single module. + +This module handles: + - Building context dicts from bot state + - Sending trade open/close notifications + - Sending market updates, hourly reports + - Sending critical alerts, emergency notifications + - Startup & shutdown notifications + +Integration: + from src.telegram_notifications import TelegramNotifications + self.notifications = TelegramNotifications(bot) +""" + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import asyncio +from datetime import datetime +from loguru import logger + + +class TelegramNotifications: + """ + High-level notification helper that reads bot state and sends + formatted Telegram messages via bot.telegram (TelegramNotifier). + """ + + def __init__(self, bot): + """ + Args: + bot: TradingBot instance (has .telegram, .mt5, .smart_risk, etc.) + """ + self.bot = bot + + # ------------------------------------------------------------------ + # Startup notification + # ------------------------------------------------------------------ + async def send_startup(self): + """Send bot startup notification with full context.""" + bot = self.bot + balance = bot.mt5.account_balance or bot.config.capital + session_status = bot.session_filter.get_status_report() + risk_state = bot.smart_risk.get_state() + risk_rec = bot.smart_risk.get_trading_recommendation() + ml_status = ( + f"Loaded ({len(bot.ml_model.feature_names)} features)" + if bot.ml_model.fitted + else "Not loaded" + ) + + ctx = { + "risk_per_trade": bot.config.risk.risk_per_trade, + "max_daily_loss": bot.config.risk.max_daily_loss, + "max_total_loss": bot.smart_risk.max_total_loss_percent, + "max_lot": bot.smart_risk.max_lot_size, + "max_positions": bot.smart_risk.max_concurrent_positions, + "cooldown_seconds": bot._trade_cooldown_seconds, + "daily_loss": risk_state.daily_loss, + "total_loss": bot.smart_risk._total_loss, + "consecutive_losses": risk_state.consecutive_losses, + "risk_mode": risk_rec.get("mode", "normal"), + "session": session_status.get("current_session", "Unknown"), + "can_trade": session_status.get("can_trade", False), + "volatility": session_status.get("volatility", "unknown"), + } + + await bot.telegram.send_startup_message( + symbol=bot.config.symbol, + capital=bot.config.capital, + balance=balance, + mode=bot.config.capital_mode.value, + ml_model_status=ml_status, + news_status="DISABLED", + context=ctx, + ) + + # ------------------------------------------------------------------ + # Shutdown notification + # ------------------------------------------------------------------ + async def send_shutdown(self): + """Send bot shutdown notification with session summary.""" + bot = self.bot + try: + balance = bot.mt5.account_balance or bot.config.capital + uptime_hours = (datetime.now() - bot._start_time).total_seconds() / 3600 + risk_state = bot.smart_risk.get_state() + + ctx = { + "risk_mode": bot.smart_risk.get_trading_recommendation().get("mode", "normal"), + "daily_loss": risk_state.daily_loss, + "daily_profit": risk_state.daily_profit, + "total_loss": bot.smart_risk._total_loss, + "consecutive_losses": risk_state.consecutive_losses, + "session": bot.session_filter.get_status_report().get("current_session", "Unknown"), + } + + await bot.telegram.send_shutdown_message( + balance=balance, + total_trades=bot._total_session_trades, + total_profit=bot._total_session_profit, + uptime_hours=uptime_hours, + context=ctx, + ) + except Exception as e: + logger.error(f"Failed to send shutdown notification: {e}") + + # ------------------------------------------------------------------ + # Trade close — smart position manager + # ------------------------------------------------------------------ + async def notify_trade_close_smart( + self, + ticket: int, + profit: float, + current_price: float, + reason: str, + ): + """Send notification for smart close (from SmartRiskManager).""" + bot = self.bot + try: + trade_info = bot._open_trade_info.pop(ticket, {}) + + balance_before = trade_info.get("balance_before", 0) + balance_after = bot.mt5.account_balance or 0 + entry_price = trade_info.get("entry_price", current_price) + duration = int( + (datetime.now() - trade_info.get("open_time", datetime.now())).total_seconds() + ) + + # Track stats + bot._total_session_profit += profit + bot._total_session_trades += 1 + if profit > 0: + bot._total_session_wins += 1 + + ctx = self._build_close_context(reason) + + await bot.telegram.notify_trade_close( + ticket=ticket, + symbol=bot.config.symbol, + order_type=trade_info.get("direction", "BUY"), + lot_size=trade_info.get("lot_size", 0.01), + entry_price=entry_price, + close_price=current_price, + profit=profit, + profit_pips=(current_price - entry_price) / 0.1, + balance_before=balance_before, + balance_after=balance_after, + duration_seconds=duration, + ml_confidence=trade_info.get("ml_confidence", 0), + regime=trade_info.get("regime", "unknown"), + volatility=trade_info.get("volatility", "unknown"), + context=ctx, + ) + except Exception as e: + logger.warning(f"Failed to send close notification: {e}") + + # ------------------------------------------------------------------ + # Trade close — position manager action + # ------------------------------------------------------------------ + async def notify_trade_close_action(self, action, current_price: float): + """Send notification for close via PositionManager action.""" + bot = self.bot + try: + ticket = action.ticket + trade_info = bot._open_trade_info.pop(ticket, {}) + entry_price = trade_info.get("entry_price", current_price) + open_time = trade_info.get("open_time", datetime.now()) + balance_before = trade_info.get("balance_before", bot._daily_start_balance) + ml_confidence = trade_info.get("ml_confidence", 0) + regime = trade_info.get("regime", "unknown") + volatility = trade_info.get("volatility", "unknown") + + balance_after = bot.mt5.account_balance or bot.config.capital + + profit = action.profit if hasattr(action, "profit") else 0 + if profit == 0: + profit = balance_after - balance_before + + duration_seconds = int((datetime.now() - open_time).total_seconds()) + + price_diff = current_price - entry_price + profit_pips = price_diff / 0.1 if "XAU" in bot.config.symbol else price_diff / 0.0001 + + # Track session stats + bot._total_session_profit += profit + bot._total_session_trades += 1 + if profit > 0: + bot._total_session_wins += 1 + + exit_reason = action.reason if hasattr(action, "reason") else "position_manager" + ctx = self._build_close_context(exit_reason) + + await bot.telegram.notify_trade_close( + ticket=ticket, + symbol=bot.config.symbol, + order_type=trade_info.get("direction", "BUY"), + lot_size=trade_info.get("lot_size", 0.01), + entry_price=entry_price, + close_price=current_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, + context=ctx, + ) + except Exception as e: + logger.warning(f"Failed to send trade close notification: {e}") + + # ------------------------------------------------------------------ + # Trade open notification + # ------------------------------------------------------------------ + async def notify_trade_open( + self, + result, + signal, + position, + regime: str, + volatility: str, + session_status: dict, + *, + safe_mode: bool = False, + smc_fvg: bool = False, + smc_ob: bool = False, + smc_bos: bool = False, + smc_choch: bool = False, + dynamic_threshold=None, + market_quality=None, + market_score=None, + ): + """Send trade open notification.""" + bot = self.bot + + # Store trade info for close notification (only if not already stored) + # Safe mode pre-stores with actual fill price/lot, so don't overwrite + if result.order_id not in bot._open_trade_info: + bot._open_trade_info[result.order_id] = { + "entry_price": signal.entry_price, + "open_time": datetime.now(), + "balance_before": bot.mt5.account_balance, + "ml_confidence": signal.confidence, + "regime": regime, + "volatility": volatility, + "direction": signal.signal_type, + "lot_size": position.lot_size, + } + + risk_state = bot.smart_risk.get_state() + risk_rec = bot.smart_risk.get_trading_recommendation() + + ctx = { + "dynamic_threshold": ( + float(dynamic_threshold) + if dynamic_threshold is not None + else getattr(bot, "_last_dynamic_threshold", bot.config.ml.confidence_threshold) + ), + "market_quality": ( + str(market_quality) + if market_quality is not None + else getattr(bot, "_last_market_quality", "unknown") + ), + "market_score": ( + int(market_score) + if market_score is not None + else getattr(bot, "_last_market_score", 0) + ), + "smc_signal": getattr(bot, "_last_raw_smc_signal", ""), + "smc_confidence": getattr(bot, "_last_raw_smc_confidence", 0), + "smc_fvg": smc_fvg or getattr(signal, "fvg_detected", False), + "smc_ob": smc_ob or getattr(signal, "ob_detected", False), + "smc_bos": smc_bos or getattr(signal, "bos_detected", False), + "smc_choch": smc_choch or getattr(signal, "choch_detected", False), + "session": session_status.get("current_session", "Unknown"), + "h1_bias": getattr(bot, "_h1_bias_cache", "NEUTRAL"), + "risk_mode": risk_rec.get("mode", "normal"), + "daily_loss": risk_state.daily_loss, + "consecutive_losses": risk_state.consecutive_losses, + "entry_filters": getattr(bot, "_last_filter_results", []), + } + + reason = f"SAFE MODE: {signal.reason}" if safe_mode else signal.reason + sl = 0 if safe_mode else signal.stop_loss + + try: + await bot.telegram.notify_trade_open( + ticket=result.order_id, + symbol=bot.config.symbol, + order_type=signal.signal_type, + lot_size=position.lot_size, + entry_price=signal.entry_price, + stop_loss=sl, + take_profit=signal.take_profit, + ml_confidence=signal.confidence, + signal_reason=reason, + regime=regime, + volatility=volatility, + context=ctx, + ) + except Exception as e: + logger.warning(f"Failed to send trade open notification: {e}") + + # ------------------------------------------------------------------ + # Critical limit alert + # ------------------------------------------------------------------ + async def send_critical_limit_alert( + self, + limit_type: str, + current_loss: float, + max_loss: float, + max_percent: float, + ): + """Send critical alert when loss limits are reached.""" + logger.critical("=" * 60) + logger.critical(f"CRITICAL: {limit_type} REACHED!") + logger.critical(f"Loss: ${current_loss:.2f} / ${max_loss:.2f} ({max_percent}%)") + logger.critical("TRADING HAS BEEN STOPPED!") + logger.critical("=" * 60) + + try: + if limit_type == "TOTAL LOSS LIMIT": + message = ( + f"🚨🚨 CRITICAL: TOTAL LOSS LIMIT REACHED 🚨🚨\n\n" + f"Total Loss: ${current_loss:.2f}\n" + f"Limit: ${max_loss:.2f} ({max_percent}%)\n\n" + f"ā›” TRADING STOPPED PERMANENTLY\n" + f"Manual reset required to resume trading.\n\n" + f"Please review your trading strategy." + ) + else: + message = ( + f"🚨 DAILY LOSS LIMIT REACHED 🚨\n\n" + f"Daily Loss: ${current_loss:.2f}\n" + f"Limit: ${max_loss:.2f} ({max_percent}%)\n\n" + f"ā›” TRADING STOPPED FOR TODAY\n" + f"Will resume tomorrow automatically." + ) + + await self.bot.telegram.send_message(message) + except Exception as e: + logger.error(f"Failed to send critical alert: {e}") + + # ------------------------------------------------------------------ + # Emergency close notification + # ------------------------------------------------------------------ + async def send_emergency_close_result( + self, + closed_count: int, + failed_tickets: list, + ): + """Send notification after emergency close attempt.""" + try: + if failed_tickets: + await self.bot.telegram.send_message( + f"🚨 EMERGENCY CLOSE FAILED!\n\n" + f"Failed tickets: {failed_tickets}\n" + f"Please close manually!" + ) + else: + await self.bot.telegram.send_message( + f"🚨 EMERGENCY CLOSE COMPLETE\n\n" + f"Closed {closed_count} positions due to flash crash detection" + ) + except Exception: + pass # Don't let telegram failure stop us + + async def send_flash_crash_critical(self, move_pct: float, error): + """Send critical alert when flash crash emergency close fails.""" + try: + await self.bot.telegram.send_message( + f"🚨🚨 CRITICAL ERROR 🚨🚨\n\n" + f"Flash crash detected but emergency close FAILED!\n" + f"Error: {error}\n\n" + f"MANUAL INTERVENTION REQUIRED!" + ) + except Exception: + pass + + # ------------------------------------------------------------------ + # Market update (on-demand via command, not auto-sent) + # ------------------------------------------------------------------ + async def send_market_update(self, df, regime_state, ml_prediction): + """Send market update to Telegram.""" + bot = self.bot + try: + now = datetime.now() + + if bot._last_market_update_time: + time_since = (now - bot._last_market_update_time).total_seconds() + if time_since < 1800: + return + + session_status = bot.session_filter.get_status_report() + + atr = df["atr"].tail(1).item() if "atr" in df.columns else 0 + tick = bot.mt5.get_tick(bot.config.symbol) + spread = (tick.ask - tick.bid) if tick else 0 + + if "ema_9" in df.columns and "ema_21" in df.columns: + ema_9 = df["ema_9"].tail(1).item() + ema_21 = df["ema_21"].tail(1).item() + trend_direction = "UPTREND" if ema_9 > ema_21 else "DOWNTREND" + else: + trend_direction = "NEUTRAL" + + ctx = { + "h1_bias": getattr(bot, "_h1_bias_cache", "NEUTRAL"), + "dynamic_threshold": getattr(bot, "_last_dynamic_threshold", 0.55), + "market_quality": getattr(bot, "_last_market_quality", "unknown"), + "market_score": getattr(bot, "_last_market_score", 0), + "smc_signal": getattr(bot, "_last_raw_smc_signal", ""), + "smc_confidence": getattr(bot, "_last_raw_smc_confidence", 0), + "consecutive_losses": bot.smart_risk.get_state().consecutive_losses, + "risk_mode": bot.smart_risk.get_trading_recommendation().get("mode", "normal"), + "daily_loss": bot.smart_risk.get_state().daily_loss, + "session_trades": bot._total_session_trades, + "session_profit": bot._total_session_profit, + } + + await bot.telegram.notify_market_update( + symbol=bot.config.symbol, + price=df["close"].tail(1).item(), + regime=regime_state.regime.value if regime_state else "unknown", + volatility=session_status.get("volatility", "unknown"), + ml_signal=ml_prediction.signal, + ml_confidence=ml_prediction.confidence, + trend_direction=trend_direction, + session=session_status.get("current_session", "Unknown"), + can_trade=session_status.get("can_trade", True), + atr=atr, + spread=spread, + context=ctx, + ) + + bot._last_market_update_time = now + logger.info("Telegram: Market update sent") + + except Exception as e: + logger.warning(f"Failed to send market update: {e}") + + # ------------------------------------------------------------------ + # Daily summary + # ------------------------------------------------------------------ + async def send_daily_summary(self): + """Send daily trading summary to Telegram.""" + bot = self.bot + try: + balance = bot.mt5.account_balance or bot.config.capital + await bot.telegram.send_daily_summary( + start_balance=bot._daily_start_balance, + end_balance=balance, + ) + logger.info("Telegram: Daily summary sent") + except Exception as e: + logger.warning(f"Failed to send daily summary: {e}") + + # ------------------------------------------------------------------ + # Hourly analysis report + # ------------------------------------------------------------------ + async def send_hourly_analysis_if_due( + self, + df, + regime_state, + ml_prediction, + open_positions, + current_price: float, + ): + """Send comprehensive hourly analysis report. Interval: 1 hour.""" + bot = self.bot + now = datetime.now() + + if bot._last_hourly_report_time: + time_since = (now - bot._last_hourly_report_time).total_seconds() + if time_since < 3600: + return + + try: + balance = bot.mt5.account_balance or bot.config.capital + equity = bot.mt5.account_equity or bot.config.capital + floating_pnl = equity - balance + + # Position details with Smart Risk data + position_details = [] + for row in open_positions.iter_rows(named=True): + ticket = row["ticket"] + profit = row.get("profit", 0) + position_type = row.get("type", 0) + direction = "BUY" if position_type == 0 else "SELL" + + guard = bot.smart_risk._position_guards.get(ticket) + momentum = guard.momentum_score if guard else 0 + tp_prob = guard.get_tp_probability() if guard else 50 + + position_details.append({ + "ticket": ticket, + "direction": direction, + "profit": profit, + "momentum": momentum, + "tp_probability": tp_prob, + }) + + session_status = bot.session_filter.get_status_report() + + market_analysis = bot.dynamic_confidence.analyze_market( + session=session_status.get("current_session", "Unknown"), + regime=regime_state.regime.value if regime_state else "unknown", + volatility=session_status.get("volatility", "medium"), + trend_direction=regime_state.regime.value if regime_state else "neutral", + has_smc_signal=False, + ml_signal=ml_prediction.signal, + ml_confidence=ml_prediction.confidence, + ) + + risk_rec = bot.smart_risk.get_trading_recommendation() + + avg_exec = ( + (sum(bot._execution_times) / len(bot._execution_times) * 1000) + if bot._execution_times + else 0 + ) + uptime = (now - bot._start_time).total_seconds() / 3600 + + # Get ATR and spread + atr = 0 + spread = 0 + try: + df_latest = bot.mt5.get_market_data( + symbol=bot.config.symbol, + timeframe=bot.config.execution_timeframe, + count=50, + ) + if len(df_latest) > 0 and "atr" in df_latest.columns: + atr = df_latest["atr"].tail(1).item() + tick = bot.mt5.get_tick(bot.config.symbol) + spread = (tick.ask - tick.bid) if tick else 0 + except Exception: + pass + + ctx = { + "h1_bias": getattr(bot, "_h1_bias_cache", "NEUTRAL"), + "smc_signal": getattr(bot, "_last_raw_smc_signal", ""), + "smc_confidence": getattr(bot, "_last_raw_smc_confidence", 0), + "atr": atr, + "spread": spread, + "total_loss": bot.smart_risk._total_loss, + "consecutive_losses": bot.smart_risk.get_state().consecutive_losses, + "entry_filters": getattr(bot, "_last_filter_results", []), + } + + await bot.telegram.send_hourly_analysis( + balance=balance, + equity=equity, + floating_pnl=floating_pnl, + open_positions=len(open_positions), + position_details=position_details, + symbol=bot.config.symbol, + current_price=current_price, + session=session_status.get("current_session", "Unknown"), + regime=regime_state.regime.value if regime_state else "unknown", + volatility=session_status.get("volatility", "unknown"), + ml_signal=ml_prediction.signal, + ml_confidence=ml_prediction.confidence, + dynamic_threshold=market_analysis.confidence_threshold, + market_quality=market_analysis.quality.value, + market_score=market_analysis.score, + daily_pnl=bot._total_session_profit, + daily_trades=bot._total_session_trades, + risk_mode=risk_rec.get("mode", "normal"), + max_daily_loss=bot.smart_risk.max_daily_loss_usd, + uptime_hours=uptime, + total_loops=bot._loop_count, + avg_execution_ms=avg_exec, + news_status="DISABLED", + news_reason="News agent disabled", + context=ctx, + ) + + bot._last_hourly_report_time = now + logger.info("Telegram: Hourly analysis report sent") + + except Exception as e: + logger.warning(f"Failed to send hourly analysis: {e}") + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + def _build_close_context(self, exit_reason: str) -> dict: + """Build context dict for trade close notifications.""" + bot = self.bot + risk_state = bot.smart_risk.get_state() + win_rate = ( + (bot._total_session_wins / bot._total_session_trades * 100) + if bot._total_session_trades > 0 + else 0 + ) + return { + "exit_reason": exit_reason, + "risk_mode": bot.smart_risk.get_trading_recommendation().get("mode", "normal"), + "daily_loss": risk_state.daily_loss, + "daily_profit": risk_state.daily_profit, + "consecutive_losses": risk_state.consecutive_losses, + "total_loss": bot.smart_risk._total_loss, + "session_trades": bot._total_session_trades, + "session_wins": bot._total_session_wins, + "session_profit": bot._total_session_profit, + "win_rate": win_rate, + "session": bot.session_filter.get_status_report().get("current_session", "Unknown"), + } diff --git a/src/telegram_notifier.py b/src/telegram_notifier.py index 3c55909..a9fe799 100644 --- a/src/telegram_notifier.py +++ b/src/telegram_notifier.py @@ -4,8 +4,8 @@ Telegram Notifier Module Smart Telegram integration for AI Trading Bot. Features: -- Trade notifications with detailed P/L -- Market condition updates (educational) +- Trade notifications with ALL features as text array +- Market condition updates with full context - ML prediction insights - Volatility alerts - Daily summary with charts @@ -113,6 +113,10 @@ class TelegramNotifier: 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): @@ -161,6 +165,90 @@ class TelegramNotifier: 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, @@ -234,10 +322,23 @@ class TelegramNotifier: 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) -> str: - """Format trade open notification - Compact Mobile Style.""" + 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" @@ -253,20 +354,98 @@ class TelegramNotifier: 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 - msg = f"""{emoji} {direction} #{trade.ticket} -ā”œ {trade.symbol} -ā”œ Entry: {trade.entry_price:.2f} -ā”œ Lot: {trade.lot_size} -ā”œ SL: {sl_display} (-${potential_loss:.0f}) -ā”œ TP: {trade.take_profit:.2f} (+${potential_profit:.0f}) -ā”œ R:R: 1:{rr_ratio:.1f} -ā”œ AI: {trade.ml_confidence:.0%} | {trade.regime} -ā”” {trade.signal_reason[:50]} -ā° {datetime.now(WIB).strftime('%H:%M')} WIB""" - return msg + # === 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}", + ] - def _format_trade_close(self, trade: TradeInfo) -> str: - """Format trade close notification - Compact Mobile Style.""" + # === 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 = "āœ…" @@ -294,21 +473,66 @@ class TelegramNotifier: else: result = "BE" - msg = f"""{emoji} {result} #{trade.ticket} -ā”œ {trade.symbol} {trade.order_type} -ā”œ Entry: {trade.entry_price:.2f} -ā”œ Exit: {trade.close_price:.2f} -ā”œ Lot: {trade.lot_size} -ā”œ P/L: {profit_str} ({pct_str}) -ā”œ Pips: {trade.profit_pips:+.1f} -ā”œ Duration: {duration_str} -ā”œ Bal Before: ${trade.balance_before:,.2f} -ā”” Bal After: ${trade.balance_after:,.2f} -ā° {datetime.now(WIB).strftime('%H:%M')} WIB""" - return msg + # 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}" - def _format_market_update(self, condition: MarketCondition) -> str: - """Format market condition update - Compact Mobile Style.""" + # 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 = "🟢" @@ -317,16 +541,63 @@ class TelegramNotifier: else: signal_emoji = "⚪" - status = "āœ…" if condition.can_trade else "ā›”" + status = "āœ… READY" if condition.can_trade else "ā›” WAIT" - msg = f"""šŸ“Š {condition.symbol} ${condition.price:.2f} -ā”œ {signal_emoji} {condition.ml_signal} {condition.ml_confidence:.0%} -ā”œ {condition.trend_direction} -ā”œ {condition.regime} -ā”œ {condition.session} -ā”” {status} -ā° {datetime.now(WIB).strftime('%H:%M')}""" - return msg + # 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, @@ -335,7 +606,7 @@ class TelegramNotifier: end_balance: float, market_condition: Optional[MarketCondition] = None, ) -> str: - """Format daily trading summary - Mobile Responsive with Code.""" + """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) @@ -359,49 +630,56 @@ class TelegramNotifier: if total_profit > 0: day_emoji = "šŸŽ‰" - day_result = "PROFIT" elif total_profit < 0: day_emoji = "šŸ“‰" - day_result = "LOSS" else: day_emoji = "āž–" - day_result = "BE" 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}%" - # Build trade history (last 5) - trade_lines = [] - for i, t in enumerate(trades[-5:]): + # === 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 "āž–" - prefix = "ā””" if i == len(trades[-5:]) - 1 else "ā”œ" - trade_lines.append(f"{prefix} {result_emoji} {t.order_type}: {sign}${amt:.2f}") - trade_str = "\n".join(trade_lines) if trade_lines else "ā”” No trades" + 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')} -Result -ā”œ P/L: {profit_str} ({pct_str}) -ā”œ Gross Win: +${gross_profit:.2f} -ā”œ Gross Loss: -${gross_loss:.2f} -ā”œ Bal Start: ${start_balance:,.2f} -ā”” Bal End: ${end_balance:,.2f} +{self._build_section("Result", result_items)} -Stats -ā”œ Total: {total_trades} trades -ā”œ Wins: {winning_trades} | Losses: {losing_trades} -ā”œ Win Rate: {win_rate:.1f}% -ā”œ Profit Factor: {pf_str} -ā”” Avg/Trade: ${avg_profit:.2f} +{self._build_section("Stats", stats_items)} -Recent Trades -{trade_str}""" - return msg +{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 - Compact Mobile Style.""" + """Format alert message as text array.""" alert_emojis = { "flash_crash": "🚨", "high_volatility": "⚔", @@ -413,10 +691,14 @@ class TelegramNotifier: emoji = alert_emojis.get(alert_type, "āš ļø") title = alert_type.upper().replace('_', ' ') + alert_items = [message] + msg = f"""{emoji} {title} -ā”” {message} -ā° {datetime.now(WIB).strftime('%H:%M')}""" - return msg + +{self._build_section("Detail", alert_items)} + +ā° {datetime.now(WIB).strftime('%H:%M')} WIB""" + return msg.strip() def _format_system_status( self, @@ -427,16 +709,22 @@ class TelegramNotifier: ml_status: str, uptime_hours: float, ) -> str: - """Format system status message - Compact Mobile Style.""" + """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 🟢 -ā”œ Bal: ${balance:,.0f} -ā”œ Eq: ${equity:,.0f} -ā”œ Pos: {open_positions} -ā”œ {session} -ā”œ ML: {ml_status} -ā”” Up: {uptime_hours:.1f}h -ā° {datetime.now(WIB).strftime('%H:%M')}""" - return msg + +{self._build_section("System", status_items)} + +ā° {datetime.now(WIB).strftime('%H:%M')} WIB""" + return msg.strip() # ========== HIGH-LEVEL NOTIFICATION METHODS ========== @@ -453,8 +741,10 @@ class TelegramNotifier: signal_reason: str, regime: str, volatility: str, + # ALL extra context as dict + context: dict = None, ): - """Send trade open notification.""" + """Send trade open notification with ALL features.""" trade = TradeInfo( ticket=ticket, symbol=symbol, @@ -469,7 +759,7 @@ class TelegramNotifier: volatility=volatility, ) - msg = self._format_trade_open(trade) + msg = self._format_trade_open(trade, context or {}) await self.send_message(msg) logger.info(f"Telegram: Trade open notification sent for #{ticket}") @@ -489,8 +779,10 @@ class TelegramNotifier: ml_confidence: float = 0, regime: str = "", volatility: str = "", + # ALL extra context as dict + context: dict = None, ): - """Send trade close notification with detailed P/L.""" + """Send trade close notification with ALL status.""" trade = TradeInfo( ticket=ticket, symbol=symbol, @@ -511,7 +803,7 @@ class TelegramNotifier: # Track for daily summary self._daily_trades.append(trade) - msg = self._format_trade_close(trade) + msg = self._format_trade_close(trade, context or {}) await self.send_message(msg) logger.info(f"Telegram: Trade close notification sent for #{ticket}") @@ -528,8 +820,10 @@ class TelegramNotifier: can_trade: bool, atr: float = 0, spread: float = 0, + # ALL extra context as dict + context: dict = None, ): - """Send market condition update.""" + """Send market condition update with full context.""" condition = MarketCondition( symbol=symbol, price=price, @@ -544,7 +838,7 @@ class TelegramNotifier: spread=spread, ) - msg = self._format_market_update(condition) + msg = self._format_market_update(condition, context or {}) await self.send_message(msg, disable_notification=True) logger.info("Telegram: Market update sent") @@ -745,27 +1039,68 @@ Day Change: {((end_balance-start_balance)/start_balance*100):+.2f}% mode: str, ml_model_status: str, news_status: str = "SAFE", + # ALL extra context as dict + context: dict = None, ): - """Send bot startup notification - Compact Mobile Style.""" + """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 -Config -ā”œ Symbol: {symbol} -ā”œ Mode: {mode} -ā”œ Capital: ${capital:,.2f} -ā”œ Balance: ${balance:,.2f} -ā”” ML: {ml_model_status} +{self._build_section("Config", config_items)} -Risk Settings -ā”œ Risk/Trade: 1% -ā”œ Max Daily Loss: 5% -ā”œ Max Total Loss: 10% -ā”” SL: Smart (No Hard) +{self._build_section("Risk Settings", risk_items)} -{news_emoji} News: {news_status} +{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) + await self.send_message(msg.strip()) logger.info("Telegram: Startup message sent") async def send_news_alert( @@ -784,13 +1119,19 @@ Day Change: {((end_balance-start_balance)/start_balance*100):+.2f}% } emoji = emoji_map.get(condition, "šŸ“°") - msg = f"""{emoji} NEWS {condition} -ā”œ {event_name[:30]} -ā”œ {reason[:35]} -ā”” Buffer: {buffer_minutes}m -ā° {datetime.now(WIB).strftime('%H:%M')}""" + news_items = [ + f"Event: {event_name[:40]}", + f"Reason: {reason[:50]}", + f"Buffer: {buffer_minutes}m", + ] - await self.send_message(msg) + 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( @@ -801,7 +1142,7 @@ Day Change: {((end_balance-start_balance)/start_balance*100):+.2f}% floating_pnl: float, # Position info open_positions: int, - position_details: list, # List of dicts with ticket, direction, profit, momentum, tp_prob + position_details: list, # Market info symbol: str, current_price: float, @@ -826,103 +1167,118 @@ Day Change: {((end_balance-start_balance)/start_balance*100):+.2f}% # 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. - Interval: Every 1 hour - """ + """Send comprehensive hourly analysis report with ALL features.""" now = datetime.now(WIB) + ctx = context or {} # Floating P/L emoji - float_emoji = "+" if floating_pnl >= 0 else "" - daily_emoji = "+" if daily_pnl >= 0 else "" + float_prefix = "+" if floating_pnl >= 0 else "" + daily_prefix = "+" if daily_pnl >= 0 else "" # Risk mode indicator - risk_indicators = { - "normal": "NORMAL", - "recovery": "RECOVERY", - "protected": "PROTECTED", - "stopped": "STOPPED", - } - risk_display = risk_indicators.get(risk_mode.lower(), risk_mode.upper()) + risk_display = risk_mode.upper() # Market quality indicator - quality_indicators = { - "excellent": "EXCELLENT", - "good": "GOOD", - "moderate": "MODERATE", - "poor": "POOR", - "avoid": "AVOID", - } - quality_display = quality_indicators.get(market_quality.lower(), market_quality.upper()) - - # Build position details string - pos_lines = [] - for pos in position_details[:5]: # Max 5 positions - ticket = pos.get("ticket", 0) - direction = pos.get("direction", "?") - profit = pos.get("profit", 0) - momentum = pos.get("momentum", 0) - tp_prob = pos.get("tp_probability", 50) - - profit_str = f"+${profit:.2f}" if profit >= 0 else f"-${abs(profit):.2f}" - mom_str = f"+{momentum:.0f}" if momentum >= 0 else f"{momentum:.0f}" - - pos_lines.append(f" #{ticket} {direction}: {profit_str} | M:{mom_str} | TP:{tp_prob:.0f}%") - - positions_str = "\n".join(pos_lines) if pos_lines else " No open positions" - - # ML signal strength - if ml_confidence >= 0.75: - signal_strength = "STRONG" - elif ml_confidence >= 0.65: - signal_strength = "MODERATE" - else: - signal_strength = "WEAK" + 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" - # Build position list with details - pos_lines = [] - for i, pos in enumerate(position_details[:5]): # Max 5 + # === 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}" - prefix = "ā””" if i == len(position_details[:5]) - 1 else "ā”œ" - pos_lines.append(f"{prefix} #{t} {d}: {ps} M:{m:+.0f}") - pos_str = "\n".join(pos_lines) if pos_lines else "ā”” No positions" + 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 -Account -ā”œ Bal: ${balance:,.2f} -ā”œ Eq: ${equity:,.2f} -ā”œ Float: {float_emoji}${floating_pnl:.2f} -ā”” Day: {daily_emoji}${daily_pnl:.2f} ({daily_trades} trades) +{body} -Positions ({open_positions}) -{pos_str} +{news_emoji} News: {news_status} +ā° {now.strftime('%Y-%m-%d %H:%M')} WIB""" -Market -ā”œ {symbol} ${current_price:,.2f} -ā”œ {session} -ā”” {regime} | {volatility} - -AI Signal -ā”œ {ml_signal} {ml_confidence:.0%} / thresh {dynamic_threshold:.0%} -ā”” Quality: {quality_display} (score:{market_score}) → {trade_status} - -Risk {risk_display} -ā”” Daily Loss: ${abs(min(0, daily_pnl)):.2f} / ${max_daily_loss:.2f} - -{"āœ…" if news_status == "SAFE" else "āš ļø"} News: {news_status}""" - - await self.send_message(msg, disable_notification=True) + await self.send_message(msg.strip(), disable_notification=True) logger.info("Telegram: Hourly analysis report sent") async def send_shutdown_message( @@ -931,21 +1287,42 @@ Day Change: {((end_balance-start_balance)/start_balance*100):+.2f}% total_trades: int, total_profit: float, uptime_hours: float, + # ALL extra context as dict + context: dict = None, ): - """Send bot shutdown notification - Compact Mobile Style.""" + """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 -Session Summary -ā”œ Balance: ${balance:,.2f} -ā”œ Total Trades: {total_trades} -ā”œ {emoji} P/L: {profit_str} -ā”” Uptime: {uptime_hours:.1f}h +{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) + await self.send_message(msg.strip()) logger.info("Telegram: Shutdown message sent") @@ -981,40 +1358,136 @@ if __name__ == "__main__": capital=5000, balance=6160, mode="small", - ml_model_status="Loaded (37 features)", + 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 close notification + # 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.2, - entry_price=4950.00, - close_price=4965.00, + 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=125, + duration_seconds=2700, ml_confidence=0.71, regime="medium_volatility", - volatility="high", + 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=4965.00, + price=2855.50, regime="medium_volatility", - volatility="high", + volatility="medium", ml_signal="BUY", - ml_confidence=0.71, + ml_confidence=0.68, trend_direction="UPTREND", session="London-NY Overlap", can_trade=True, - atr=15.5, - spread=2.1, + 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()